> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Stream Video Room audio to your WebSocket

> Start and stop a server-initiated audio media fork, receive 20 ms PCM frames, and handle stream metadata and reconnects.

# Stream Video Room audio to your WebSocket

Use a media fork to send live audio from a scheduled Video Room to a WebSocket server that you operate. This guide walks through starting a fork, receiving its frames, and stopping it safely.

Before you start, create a scheduled room and have its UUID and a participant identity ready. Your receiver must be reachable at a public `wss://` URL on port 443. Keep your API key and WebSocket credentials in a secret store, not in source control.

## What a media fork does

A media fork is the Video Rooms counterpart of the voice [`listen` verb](/reference/programmable-voice-dsl#listen): Orbit starts the connection to your `wss://` endpoint and pushes live room audio to it. Orbit joins the room as a hidden, listen-only participant; it does not publish media.

Media forks support **audio only**. Video is not available through this endpoint. Omit `media` or set it to `"audio"`; any other `media` value returns `400 MEDIA_FORK_AUDIO_ONLY`.

## Authentication and roles

Call the start and stop endpoints with an API key in `X-API-Key`. The caller must have the `owner` or `admin` role and the `video:write` scope. The start and stop actions are recorded as `video_room.media_fork_started` and `video_room.media_fork_stopped` audit events in your workspace.

`wsAuth` is separate from API authentication. When supplied, Orbit sends your username and password as HTTP Basic authentication on its WebSocket handshake to your receiver. Use distinct credentials for this endpoint and validate them before accepting the WebSocket upgrade.

If your workspace has recording-consent enforcement enabled, include a valid `recording_consent_receipt_id` when starting the fork. The setting and receipt are controlled by your workspace; without a required receipt, the API returns `422 RECORDING_CONSENT_REQUIRED`.

## Start a media fork

Send `POST /api/v1/video/rooms-scheduled/{id}/media-fork`. Set `participant_id` to one participant's current room identity to capture that participant, or set it to `"all"` to capture everyone. An `"all"` fork also includes participants who join later, but it can start only when at least one participant is already in the room.

`mixType` defaults to `"mono"`. For a single-participant target, you can choose `"stereo"`: the chosen participant is on channel 0 and the other participants are mixed on channel 1. `stereo` with `participant_id: "all"` is rejected by request validation. `sampleRate` defaults to `16000` and accepts `8000`, `16000`, `24000`, or `48000` Hz. `metadata` is an optional JSON object of up to 4 KB; Orbit returns it in the first WebSocket message.

The endpoint URL must use `wss://`, port 443, and resolve to a public address. Local, private, and internal addresses are rejected. The API accepts the request with `202` and returns a `fork_id`; audio begins after Orbit connects to your receiver and joins the room.

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/ROOM_UUID/media-fork" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "wss://media.example.com/orbit-fork",
    "participant_id": "user_42",
    "mixType": "stereo",
    "sampleRate": 16000,
    "wsAuth": { "username": "orbit-fork", "password": "replace-with-a-secret" },
    "metadata": { "case_id": "C-1042" }
  }'
```

For a room-wide mono stream, change the request body to `"participant_id": "all"` and omit `mixType` (or set it to `"mono"`). Do not combine `"all"` with `"stereo"`.

### Receive and authenticate frames with Node.js

The following example uses the [`ws`](https://www.npmjs.com/package/ws) package and Node's built-in HTTPS server. Configure a trusted TLS certificate for your public hostname and set `FORK_USERNAME` and `FORK_PASSWORD` to the same values you send in `wsAuth`. The upgrade handler checks Basic credentials before passing the connection to the WebSocket server.

```js theme={null}
import { readFileSync } from 'node:fs'
import { timingSafeEqual } from 'node:crypto'
import https from 'node:https'
import { WebSocketServer } from 'ws'

const username = process.env.FORK_USERNAME ?? ''
const password = process.env.FORK_PASSWORD ?? ''
if (!username || !password) throw new Error('Set FORK_USERNAME and FORK_PASSWORD')

function sameSecret(received, expected) {
  const a = Buffer.from(received)
  const b = Buffer.from(expected)
  return a.length === b.length && timingSafeEqual(a, b)
}

function hasValidBasicAuth(header) {
  if (!header?.startsWith('Basic ')) return false
  const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8')
  const separator = decoded.indexOf(':')
  if (separator < 0) return false
  return sameSecret(decoded.slice(0, separator), username) &&
    sameSecret(decoded.slice(separator + 1), password)
}

const keyFile = process.env.TLS_KEY_FILE
const certFile = process.env.TLS_CERT_FILE
if (!keyFile || !certFile) throw new Error('Set TLS_KEY_FILE and TLS_CERT_FILE')

const server = https.createServer({
  key: readFileSync(keyFile),
  cert: readFileSync(certFile),
})
const wss = new WebSocketServer({ noServer: true })

server.on('upgrade', (request, socket, head) => {
  const path = new URL(request.url ?? '/', 'https://receiver.invalid').pathname
  if (path !== '/orbit-fork' || !hasValidBasicAuth(request.headers.authorization)) {
    socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nWWW-Authenticate: Basic realm="media-fork"\r\n\r\n')
    socket.destroy()
    return
  }

  wss.handleUpgrade(request, socket, head, (ws) => {
    wss.emit('connection', ws, request)
  })
})

wss.on('connection', (ws) => {
  let forkId
  ws.on('message', (data, isBinary) => {
    if (isBinary) {
      const frame = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data)
      // Write raw PCM to stdout for a downstream consumer; keep logs on stderr.
      // Pause this socket if stdout is backpressured to avoid unbounded buffering.
      if (!process.stdout.write(frame)) {
        ws.pause()
        process.stdout.once('drain', () => ws.resume())
      }
      return
    }

    let message
    try {
      message = JSON.parse(data.toString())
    } catch {
      ws.close(1003, 'Expected JSON control message')
      return
    }

    if (message.type === 'metadata') {
      forkId = message.fork_id
      console.error('Media fork connected', {
        forkId,
        roomId: message.room_id,
        participantId: message.participant_id,
        sampleRate: message.sampleRate,
        mixType: message.mixType,
        channels: message.channels,
        encoding: message.encoding,
        frameMs: message.frame_ms,
        metadata: message.metadata,
      })
    } else if (message.type === 'stopped') {
      console.error('Media fork stopped', { forkId: message.fork_id, reason: message.reason })
    }
  })

  ws.on('close', () => {
    // Orbit may reconnect once after a customer-side socket loss. A new
    // connection starts with metadata again, so initialize per connection.
    console.error('Media fork socket closed', { forkId })
  })
})

server.listen(443)
```

The example accepts multiple connections, including Orbit's one reconnect, and writes only binary audio frames to stdout; control messages go to stderr. In production, pipe stdout to a downstream consumer that handles backpressure and processes frames promptly.

## Wire format

Each connection starts with a text JSON message describing the stream and echoing your custom metadata. Binary messages follow, each containing one 20 ms frame of signed 16-bit little-endian PCM (`L16`). A final JSON text message arrives before Orbit closes the socket when the fork stops.

```json theme={null}
{
  "type": "metadata",
  "fork_id": "vmf_3f9c2a7d1e8b4c6a9d0e5f1a2b3c4d5e",
  "room_id": "8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002",
  "participant_id": "user_42",
  "sampleRate": 16000,
  "mixType": "stereo",
  "channels": 2,
  "encoding": "L16",
  "frame_ms": 20,
  "metadata": { "case_id": "C-1042" }
}
```

At 16 kHz, a mono frame is 640 bytes and a stereo frame is 1,280 bytes. Stereo samples are interleaved left and right. Orbit sends frames during silence too; silent frames contain zeros. Use `sampleRate`, `channels`, and `encoding` from the metadata message to configure your consumer rather than assuming the example values.

When the fork stops, the last text message is shaped like this:

```json theme={null}
{ "type": "stopped", "fork_id": "vmf_3f9c2a7d1e8b4c6a9d0e5f1a2b3c4d5e", "reason": "stopped" }
```

## Lifecycle and stop conditions

Stop a fork explicitly with `DELETE /api/v1/video/rooms-scheduled/{id}/media-fork/{forkId}`, using the room UUID and the `fork_id` returned by the start request. A successful response reports `stopped` if it has ended, or `stopping` while shutdown completes; in the latter case, wait for the final `stopped` WebSocket message.

Orbit also stops a fork when the room ends, when the selected participant leaves, or when an `"all"` fork's room has been empty for 60 seconds. If your receiver loses the customer-side socket, Orbit attempts one reconnect and sends the metadata message again; if it cannot reconnect, the fork ends. Each fork is capped at four hours. The final message's `reason` can be `stopped`, `room_disconnected`, `participant_left`, `room_empty`, `max_duration`, `audio_stream_failed`, or `shutdown`. Start a new fork after an automatic stop if you still need the stream.

## Limits and errors

You can run up to 5 active forks in one room and 20 in one tenant. Exceeding either cap returns `429 MEDIA_FORK_LIMIT_REACHED`. These are tenant-level usage limits; manage concurrent forks within your workspace.

| Status | Code | What to check |
| - | - | - |
| `400` | `MEDIA_FORK_AUDIO_ONLY` | `media` must be omitted or set to `"audio"`. |
| `400` | `VALIDATION_ERROR` | Check required fields and types. `stereo` requires a specific participant; `participant_id: "all"` cannot be combined with `stereo`. The WebSocket URL must use `wss://` on port 443. |
| `400` | `MEDIA_FORK_URL_REJECTED` | Use a publicly resolvable hostname; local, private, and internal destinations are blocked. |
| `403` | — | The API key needs the `owner` or `admin` role and `video:write` scope. |
| `404` | `MEDIA_FORK_PARTICIPANT_NOT_FOUND` | The requested participant identity is not currently in the room. |
| `404` | `NOT_FOUND` | Check that the scheduled room exists in this tenant. |
| `404` | `MEDIA_FORK_NOT_FOUND` | The fork ID does not identify an active fork in this room. |
| `409` | `MEDIA_FORK_ROOM_EMPTY` | Join the room before starting an `"all"` fork. |
| `422` | `RECORDING_CONSENT_REQUIRED` | Your workspace requires a consent receipt; include `recording_consent_receipt_id`. |
| `429` | `MEDIA_FORK_LIMIT_REACHED` | Stop an active fork or wait for one to end before starting another. |
| `502` | `VIDEO_MEDIA_FORK_GATEWAY_ERROR` | The media service could not be reached. Retry the start request; if the response includes `error.details.fork_id`, Orbit attempts to stop that start attempt. |
| `503` | `SERVICE_UNAVAILABLE` | Orbit Media is not configured for scheduled rooms. Contact your workspace administrator. |

## Use cases and related guides

Send frames to a live speech-to-text service, analyze talk time or audio quality, or feed a real-time analytics pipeline. Your receiver controls what it stores and processes; apply your workspace's consent and retention settings to that data.

For the voice-call equivalent, see the [`listen` verb reference](/reference/programmable-voice-dsl#listen). To manage scheduled Video Rooms and their participants, see [Video Room consoles](/guides/video-room-consoles). For the full endpoint contract, see [Audio media fork in the Video API reference](/api-reference/video#audio-media-fork).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.