> ## 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.

# Video API

> Orbit Media-backed video rooms — schedule, join, record, end meetings, and issue per-participant access tokens

# Video API

Real-time video rooms backed by **Orbit Media** (Devotel's hosted SFU; forked from [LiveKit OSS](https://github.com/livekit/livekit) under Apache-2 License — see [attribution](/legal/attribution)). Two flavors:

* **Scheduled rooms** (`/api/v1/video/rooms-scheduled`) — DB-backed, first-class rooms with a host, schedule, capacity, recording, and per-room guest invites. Use when you want the room to outlive a single session, surface in the inbox as a `video` conversation, or hand out shareable join links.
* **Ad-hoc rooms** (`/api/v1/video/rooms`) — fire-and-forget Orbit Media rooms created directly against the Orbit Media room service. Use for "click to start a call" flows where the room ends when participants leave.

**Base path:** `/api/v1/video`

**Authentication:** API key (`X-API-Key`) on every authenticated endpoint. The public guest-redeem endpoint (`POST /video/invites/:inviteToken/redeem`) takes the invite token itself as the credential and requires no API key.

**Role requirements:** mutating endpoints (create, delete, end, recording start/stop, invite create/revoke, participant kick/mute, [simulive](#simulive-broadcast) configure/cancel) require `owner`, `admin`, or `developer`. Read endpoints (list, get) and `POST /rooms-scheduled/:id/join` are available to any authenticated tenant member; the `join` route silently downgrades `role: "host"` to `participant` for callers that lack the host-mint role.

**Orbit Media configuration:** every endpoint returns `503 SERVICE_UNAVAILABLE` if Orbit Media is not configured on the cluster, but the two flavors honour different variables:

* **Ad-hoc rooms** (`/video/rooms`) accept the legacy `DEVOTEL_LIVEKIT_URL` / `DEVOTEL_LIVEKIT_API_KEY` as a fallback when `DEVOTEL_ORBIT_MEDIA_URL` / `DEVOTEL_ORBIT_MEDIA_API_KEY` are unset, so they stay in service on a cluster that still carries only the legacy vars.
* **Scheduled rooms** (`/video/rooms-scheduled`, including join, end, recording, egress, and participant endpoints) require `DEVOTEL_ORBIT_MEDIA_URL` and `DEVOTEL_ORBIT_MEDIA_API_KEY` directly — there is no `DEVOTEL_LIVEKIT_*` fallback, so a cluster set up with only the legacy vars returns `503` on these endpoints.

Set `DEVOTEL_ORBIT_MEDIA_URL` and `DEVOTEL_ORBIT_MEDIA_API_KEY` to keep both flavors in service. The legacy `DEVOTEL_LIVEKIT_*` vars remain as deprecated ad-hoc-only fallbacks and will be removed in a future release; prefer `DEVOTEL_ORBIT_MEDIA_*` for all new and migrating deployments.

**Wire compatibility:** Orbit Media speaks the same JWT-signed signaling protocol as the LiveKit OSS fork it descends from. The published [`@orbit/media-client`](https://www.npmjs.com/package/@orbit/media-client) browser SDK is the supported client. The upstream `livekit-client` SDK still works at the protocol level today but is not part of Orbit's supported surface and may diverge.

***

## Scheduled rooms

First-class video rooms backed by `tenant_<id>.video_rooms`. Each room creates a companion `conversations` row (`channel='video'`) so the inbox renders it alongside other channels.

<Note>
  **Recurring meetings.** Pass an optional RFC 5545 RRULE body in `recurrence_rule` to turn a scheduled room into a recurring series — `FREQ=DAILY/WEEKLY/MONTHLY/YEARLY` with `BYDAY`, `INTERVAL`, `COUNT`, and `UNTIL`. When set, the `.ics` calendar attachment on guest invites carries the matching `RRULE` line so Gmail, Apple Mail, and Outlook render the full series. Omit the field for a single occurrence. Per-occurrence overrides (`EXDATE`, single-instance reschedule) and series-update cancel/re-send are not yet exposed.
</Note>

### Schedule a Room

`POST /api/v1/video/rooms-scheduled`

Create a new scheduled video room. Tenant-isolated. Optionally pre-fills a start time, sets a recurrence rule, enables auto-recording, and caps capacity. Returns a host token immediately so the creator can join without a second round-trip.

<ParamField body="name" type="string" required>
  Human-readable room title (1–200 chars). Surfaces in the inbox and join UIs.
</ParamField>

<ParamField body="scheduled_at" type="string">
  ISO-8601 timestamp with offset (e.g. `2026-05-10T14:00:00Z`). Omit or `null` for "start now".
</ParamField>

<ParamField body="recurrence_rule" type="string">
  Optional RFC 5545 RRULE body (value only — omit the `RRULE:` prefix), e.g. `FREQ=WEEKLY;BYDAY=MO;COUNT=10`. Makes the room a recurring series; the `.ics` attachment on guest invites carries the matching `RRULE` line. 8–512 characters. `FREQ` must be one of `DAILY`, `WEEKLY`, `MONTHLY`, `YEARLY`; `INTERVAL` (when present) must be ≥ 1; `COUNT` (when present) must be ≤ 520; `UNTIL` (when present) must be `YYYYMMDD` or `YYYYMMDDThhmmssZ`. Invalid values are rejected with `400`. Omit for a single occurrence.
</ParamField>

<ParamField body="recording_enabled" type="boolean" default="false">
  When `true`, recording starts automatically the moment the first participant joins.
</ParamField>

<ParamField body="max_participants" type="integer">
  Hard cap on concurrent participants (2–300). Orbit Media rejects join attempts past this number. **Omit to inherit your plan's per-tier participant cap** — there is no fixed default; an absent value resolves to the tenant's tier ceiling. Supplying a value above your tier cap is rejected with `VIDEO_PARTICIPANTS_CAP_EXCEEDED` (400).
</ParamField>

<ParamField body="settings" type="object">
  Arbitrary key-value pairs persisted on the room row. Surfaces back on `GET /:id`.
</ParamField>

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X POST "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled" \
      -H "X-API-Key: dv_live_sk_your_key_here" \
      -H "Content-Type: application/json" \
      -d '{
      "name": "Onboarding call - Acme",
      "scheduled_at": "2026-05-10T14:00:00Z",
      "recording_enabled": true,
      "max_participants": 4
    }'
    ```

    ```typescript Node.js theme={null}
    import { Orbit } from '@devotel/orbit-sdk'

    const orbit = new Orbit({ apiKey: process.env.ORBIT_API_KEY! })

    const room = await orbit.video.roomsScheduled.create({
      name: 'Onboarding call - Acme',
      scheduled_at: '2026-05-10T14:00:00Z',
      recording_enabled: true,
      max_participants: 4,
    })
    console.log(room.data.room_id, room.data.host_token)
    ```

    ```python Python theme={null}
    import os, requests

    headers = {"X-API-Key": os.environ["ORBIT_API_KEY"], "Content-Type": "application/json"}
    r = requests.post("https://api.orbit.devotel.io/api/v1/video/rooms-scheduled", headers=headers, json={
      "name": "Onboarding call - Acme",
      "scheduled_at": "2026-05-10T14:00:00Z",
      "recording_enabled": True,
      "max_participants": 4
    })
    print(r.json())
    ```

    ```go Go theme={null}
    package main

    import (
    	"bytes"
    	"net/http"
    	"os"
    )

    func main() {
    	req, _ := http.NewRequest("POST", "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled", bytes.NewBuffer([]byte(`{
      "name": "Onboarding call - Acme",
      "scheduled_at": "2026-05-10T14:00:00Z",
      "recording_enabled": true,
      "max_participants": 4
    }`)))
    	req.Header.Set("X-API-Key", os.Getenv("ORBIT_API_KEY"))
    	req.Header.Set("Content-Type", "application/json")
    	http.DefaultClient.Do(req)
    }
    ```

    ```php PHP theme={null}
    <?php
    $ch = curl_init('https://api.orbit.devotel.io/api/v1/video/rooms-scheduled');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
      'X-API-Key: ' . getenv('ORBIT_API_KEY'),
      'Content-Type: application/json',
    ]);
    curl_setopt($ch, CURLOPT_POSTFIELDS, <<<JSON
    {
      "name": "Onboarding call - Acme",
      "scheduled_at": "2026-05-10T14:00:00Z",
      "recording_enabled": true,
      "max_participants": 4
    }
    JSON);
    echo curl_exec($ch);
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```json 201 theme={null}
  {
    "data": {
      "room_id": "8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002",
      "room_sid": "RM_AbCdEfGhIjKl",
      "host_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
      "ws_url": "wss://media.orbit.devotel.io"
    },
    "meta": {
      "request_id": "req_video_001",
      "timestamp": "2026-05-10T12:00:00Z"
    }
  }
  ```
</ResponseExample>

**Error codes:** `503 SERVICE_UNAVAILABLE` (Orbit Media not configured), `400` (validation), `403` (caller lacks owner/admin/developer), `500 VIDEO_ROOM_CREATION_FAILED`.

***

### List Scheduled Rooms

`GET /api/v1/video/rooms-scheduled`

Retrieve rooms scheduled by the current tenant. Filter by lifecycle status.

<ParamField query="status" type="string">
  Filter by lifecycle: `scheduled`, `live`, `ended`. Omit for all statuses.
</ParamField>

<ParamField query="limit" type="integer" default="100">
  Results to return (1–500).
</ParamField>

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X GET "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled?status=live&limit=50" \
      -H "X-API-Key: dv_live_sk_your_key_here"
    ```

    ```typescript Node.js theme={null}
    const rooms = await orbit.video.roomsScheduled.list({ status: 'live', limit: 50 })
    console.log(rooms.data)
    ```

    ```python Python theme={null}
    import os, requests
    headers = {"X-API-Key": os.environ["ORBIT_API_KEY"]}
    r = requests.get("https://api.orbit.devotel.io/api/v1/video/rooms-scheduled",
                     headers=headers, params={"status": "live", "limit": 50})
    print(r.json())
    ```

    ```go Go theme={null}
    package main

    import (
    	"net/http"
    	"os"
    )

    func main() {
    	req, _ := http.NewRequest("GET", "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled?status=live&limit=50", nil)
    	req.Header.Set("X-API-Key", os.Getenv("ORBIT_API_KEY"))
    	http.DefaultClient.Do(req)
    }
    ```

    ```php PHP theme={null}
    <?php
    $ch = curl_init('https://api.orbit.devotel.io/api/v1/video/rooms-scheduled?status=live&limit=50');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HTTPHEADER, ['X-API-Key: ' . getenv('ORBIT_API_KEY')]);
    echo curl_exec($ch);
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": [
      {
        "id": "8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002",
        "name": "Onboarding call - Acme",
        "room_sid": "RM_AbCdEfGhIjKl",
        "host_user_id": "user_2x7Q...",
        "status": "live",
        "scheduled_at": "2026-05-10T14:00:00Z",
        "started_at": "2026-05-10T14:00:12Z",
        "ended_at": null,
        "recording_enabled": true,
        "recording_url": null,
        "max_participants": 4,
        "settings": { "conversation_id": "conversation_kPdE..." },
        "created_at": "2026-05-09T22:14:00Z",
        "updated_at": "2026-05-10T14:00:12Z"
      }
    ],
    "meta": { "request_id": "req_video_002", "timestamp": "2026-05-10T14:01:00Z" }
  }
  ```
</ResponseExample>

**Error codes:** `503 SERVICE_UNAVAILABLE`, `400` (invalid query), `500 VIDEO_ROOM_LIST_FAILED`.

***

### Get a Scheduled Room

`GET /api/v1/video/rooms-scheduled/{id}`

Fetch a single room plus the live participant roster.

<ParamField path="id" type="string" required>
  Room UUID returned from `POST /rooms-scheduled`.
</ParamField>

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X GET "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002" \
      -H "X-API-Key: dv_live_sk_your_key_here"
    ```

    ```typescript Node.js theme={null}
    const detail = await orbit.video.roomsScheduled.get('8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002')
    console.log(detail.data.room, detail.data.participants)
    ```

    ```python Python theme={null}
    import os, requests
    headers = {"X-API-Key": os.environ["ORBIT_API_KEY"]}
    r = requests.get("https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002", headers=headers)
    print(r.json())
    ```

    ```go Go theme={null}
    package main

    import (
    	"net/http"
    	"os"
    )

    func main() {
    	req, _ := http.NewRequest("GET", "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002", nil)
    	req.Header.Set("X-API-Key", os.Getenv("ORBIT_API_KEY"))
    	http.DefaultClient.Do(req)
    }
    ```

    ```php PHP theme={null}
    <?php
    $ch = curl_init('https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HTTPHEADER, ['X-API-Key: ' . getenv('ORBIT_API_KEY')]);
    echo curl_exec($ch);
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "room": {
        "id": "8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002",
        "name": "Onboarding call - Acme",
        "room_sid": "RM_AbCdEfGhIjKl",
        "status": "live",
        "scheduled_at": "2026-05-10T14:00:00Z",
        "started_at": "2026-05-10T14:00:12Z",
        "ended_at": null,
        "recording_enabled": true,
        "max_participants": 4
      },
      "participants": [
        {
          "identity": "user_2x7Q...",
          "display_name": "Jane Doe",
          "role": "host",
          "joined_at": "2026-05-10T14:00:12Z",
          "left_at": null
        }
      ]
    },
    "meta": { "request_id": "req_video_003", "timestamp": "2026-05-10T14:02:00Z" }
  }
  ```
</ResponseExample>

**Error codes:** `503 SERVICE_UNAVAILABLE`, `400` (id not a UUID), `404 NOT_FOUND`, `500 VIDEO_ROOM_FETCH_FAILED`.

***

### Join a Scheduled Room

`POST /api/v1/video/rooms-scheduled/{id}/join`

Generate a per-participant Orbit Media access token. The resulting JWT is short-lived and scoped to one room + identity.

<ParamField body="identity" type="string" required>
  Unique participant identity (1–200 chars). Used as the Orbit Media participant id. Reusing the same identity in the same room kicks the prior session.
</ParamField>

<ParamField body="display_name" type="string">
  Friendly name shown to other participants.
</ParamField>

<ParamField body="role" type="string" default="participant">
  `participant` or `host`. Server-side guard: callers that are not `owner` / `admin` / `developer` are silently downgraded to `participant`.
</ParamField>

<ParamField body="user_id" type="string">
  Optional UUID of an Orbit user this participant represents. Defaults to the calling user.
</ParamField>

<ParamField body="contact_id" type="string">
  Optional UUID of a contact the participant represents (CRM linkage).
</ParamField>

<ParamField body="ttl_seconds" type="integer">
  Token TTL in seconds (60–86400). Defaults to the service-configured value.
</ParamField>

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X POST "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/join" \
      -H "X-API-Key: dv_live_sk_your_key_here" \
      -H "Content-Type: application/json" \
      -d '{
      "identity": "user_42",
      "display_name": "Jane Doe",
      "role": "host"
    }'
    ```

    ```typescript Node.js theme={null}
    const token = await orbit.video.roomsScheduled.join('8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002', {
      identity: 'user_42',
      display_name: 'Jane Doe',
      role: 'host',
    })
    console.log(token.data.token, token.data.ws_url)
    ```

    ```python Python theme={null}
    import os, requests
    headers = {"X-API-Key": os.environ["ORBIT_API_KEY"], "Content-Type": "application/json"}
    r = requests.post("https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/join",
                      headers=headers, json={
                        "identity": "user_42",
                        "display_name": "Jane Doe",
                        "role": "host"
                      })
    print(r.json())
    ```

    ```go Go theme={null}
    package main

    import (
    	"bytes"
    	"net/http"
    	"os"
    )

    func main() {
    	req, _ := http.NewRequest("POST", "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/join",
    		bytes.NewBuffer([]byte(`{"identity":"user_42","display_name":"Jane Doe","role":"host"}`)))
    	req.Header.Set("X-API-Key", os.Getenv("ORBIT_API_KEY"))
    	req.Header.Set("Content-Type", "application/json")
    	http.DefaultClient.Do(req)
    }
    ```

    ```php PHP theme={null}
    <?php
    $ch = curl_init('https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/join');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
      'X-API-Key: ' . getenv('ORBIT_API_KEY'),
      'Content-Type: application/json',
    ]);
    curl_setopt($ch, CURLOPT_POSTFIELDS, '{"identity":"user_42","display_name":"Jane Doe","role":"host"}');
    echo curl_exec($ch);
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
      "token_type": "livekit_access_token",
      "ws_url": "wss://media.orbit.devotel.io",
      "livekit_url": "wss://media.orbit.devotel.io",
      "room_sid": "RM_AbCdEfGhIjKl",
      "participant_tier": "host",
      "lobby_downgraded": false,
      "permissions": {
        "can_publish": true,
        "can_subscribe": true,
        "can_publish_data": true,
        "hidden": false,
        "room_admin": true
      },
      "expires_at": "2026-05-10T15:05:00Z",
      "expires_in_seconds": 3600
    },
    "meta": { "request_id": "req_video_004", "timestamp": "2026-05-10T14:05:00Z" }
  }
  ```

  `token` is a media-server access JWT — feed it to the supported [`@orbit/media-client`](https://www.npmjs.com/package/@orbit/media-client) browser SDK (or the upstream `livekit-client` SDK pointed at the `livekit_url` from this response — NOT at `livekit.cloud`, which was retired 2026-05-19), not an HTTP `Authorization` header. `token_type` (`livekit_access_token`) and `livekit_url` are protocol-level names inherited from the LiveKit OSS fork Orbit Media descends from — `livekit_url` is the front-end-facing media-server WebSocket URL (an `orbit-media` host, never a `livekit.cloud` one); consume it and fall back to the legacy `ws_url` alias if absent. `participant_tier` and `permissions` are the EFFECTIVE grant after any host→participant downgrade and waiting-room clamp — check `permissions.can_publish` before showing a publish control. `lobby_downgraded` is `true` when an armed waiting room forced this join to receive-only `viewer`; show a "waiting for host to admit you" state instead of a publish button. Re-mint the token before `expires_at` (`expires_in_seconds` from issue) to avoid failing mid-session.
</ResponseExample>

**Error codes:** `503 SERVICE_UNAVAILABLE`, `400` (validation), `404` (room not found), `500 VIDEO_ROOM_JOIN_FAILED`.

***

### End a Scheduled Room

`POST /api/v1/video/rooms-scheduled/{id}/end`

Terminate the room immediately. All participants are disconnected and the room transitions to `ended`. Recording (if active) is stopped and flushed.

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X POST "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/end" \
      -H "X-API-Key: dv_live_sk_your_key_here"
    ```

    ```typescript Node.js theme={null}
    await orbit.video.roomsScheduled.end('8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002')
    ```

    ```python Python theme={null}
    import os, requests
    headers = {"X-API-Key": os.environ["ORBIT_API_KEY"]}
    r = requests.post("https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/end", headers=headers)
    print(r.status_code)
    ```

    ```go Go theme={null}
    package main

    import (
    	"net/http"
    	"os"
    )

    func main() {
    	req, _ := http.NewRequest("POST", "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/end", nil)
    	req.Header.Set("X-API-Key", os.Getenv("ORBIT_API_KEY"))
    	http.DefaultClient.Do(req)
    }
    ```

    ```php PHP theme={null}
    <?php
    $ch = curl_init('https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/end');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
    curl_setopt($ch, CURLOPT_HTTPHEADER, ['X-API-Key: ' . getenv('ORBIT_API_KEY')]);
    echo curl_exec($ch);
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```http 204 No Content theme={null}
  ```
</ResponseExample>

**Error codes:** `503 SERVICE_UNAVAILABLE`, `400` (id not a UUID), `403` (caller lacks owner/admin/developer), `404` (room not found), `500 VIDEO_ROOM_END_FAILED`.

***

### Start Recording

`POST /api/v1/video/rooms-scheduled/{id}/recording/start`

Begin an Orbit Media egress recording on the room. Returns the egress id to track via the Orbit Media webhook lifecycle. If `recording_enabled` was set at creation time the recording auto-starts and this call is a no-op.

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X POST "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/recording/start" \
      -H "X-API-Key: dv_live_sk_your_key_here"
    ```

    ```typescript Node.js theme={null}
    const egress = await orbit.video.roomsScheduled.recording.start('8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002')
    console.log(egress.data.egress_id)
    ```

    ```python Python theme={null}
    import os, requests
    headers = {"X-API-Key": os.environ["ORBIT_API_KEY"]}
    r = requests.post("https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/recording/start", headers=headers)
    print(r.json())
    ```

    ```go Go theme={null}
    package main

    import (
    	"net/http"
    	"os"
    )

    func main() {
    	req, _ := http.NewRequest("POST", "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/recording/start", nil)
    	req.Header.Set("X-API-Key", os.Getenv("ORBIT_API_KEY"))
    	http.DefaultClient.Do(req)
    }
    ```

    ```php PHP theme={null}
    <?php
    $ch = curl_init('https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/recording/start');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
    curl_setopt($ch, CURLOPT_HTTPHEADER, ['X-API-Key: ' . getenv('ORBIT_API_KEY')]);
    echo curl_exec($ch);
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "egress_id": "EG_QrStUvWxYz"
    },
    "meta": { "request_id": "req_video_005", "timestamp": "2026-05-10T14:06:00Z" }
  }
  ```
</ResponseExample>

**Error codes:** `503 SERVICE_UNAVAILABLE`, `403` (caller lacks owner/admin/developer), `404` (room not found), `500 VIDEO_RECORDING_START_FAILED`.

***

### Stop Recording

`POST /api/v1/video/rooms-scheduled/{id}/recording/stop`

End the Orbit Media egress. The recording is finalised and the resulting URL (MP4 / HLS depending on egress settings) is written back to the room row asynchronously via the Orbit Media webhook.

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X POST "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/recording/stop" \
      -H "X-API-Key: dv_live_sk_your_key_here"
    ```

    ```typescript Node.js theme={null}
    await orbit.video.roomsScheduled.recording.stop('8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002')
    ```

    ```python Python theme={null}
    import os, requests
    headers = {"X-API-Key": os.environ["ORBIT_API_KEY"]}
    r = requests.post("https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/recording/stop", headers=headers)
    print(r.status_code)
    ```

    ```go Go theme={null}
    package main

    import (
    	"net/http"
    	"os"
    )

    func main() {
    	req, _ := http.NewRequest("POST", "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/recording/stop", nil)
    	req.Header.Set("X-API-Key", os.Getenv("ORBIT_API_KEY"))
    	http.DefaultClient.Do(req)
    }
    ```

    ```php PHP theme={null}
    <?php
    $ch = curl_init('https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/recording/stop');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
    curl_setopt($ch, CURLOPT_HTTPHEADER, ['X-API-Key: ' . getenv('ORBIT_API_KEY')]);
    echo curl_exec($ch);
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```http 204 No Content theme={null}
  ```
</ResponseExample>

**Error codes:** `503 SERVICE_UNAVAILABLE`, `403`, `404` (room not found), `500 VIDEO_RECORDING_STOP_FAILED`.

***

## Guest invites

Issue tokenised join links to people outside your tenant. The invite token IS the credential — anyone who holds it can redeem it (subject to `expires_at` + `max_uses`) without an Orbit account.

### Create an Invite

`POST /api/v1/video/rooms-scheduled/{id}/invites`

Mint a guest-redeemable invite for a scheduled room. The returned `invite_token` is what you embed in a shareable URL.

<ParamField body="participant_name" type="string">
  Optional name pre-filled for the guest in the redeem flow (1–120 chars).
</ParamField>

<ParamField body="expires_in_hours" type="integer">
  Invite TTL in hours (1–168). Defaults to a service-configured value (typically 24).
</ParamField>

<ParamField body="max_uses" type="integer">
  Cap on the number of redemptions (1–500). Omit or `null` for unlimited.
</ParamField>

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X POST "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/invites" \
      -H "X-API-Key: dv_live_sk_your_key_here" \
      -H "Content-Type: application/json" \
      -d '{
      "participant_name": "Acme Customer",
      "expires_in_hours": 24,
      "max_uses": 5
    }'
    ```

    ```typescript Node.js theme={null}
    const invite = await orbit.video.roomsScheduled.invites.create('8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002', {
      participant_name: 'Acme Customer',
      expires_in_hours: 24,
      max_uses: 5,
    })
    console.log(invite.data.invite_token)
    ```

    ```python Python theme={null}
    import os, requests
    headers = {"X-API-Key": os.environ["ORBIT_API_KEY"], "Content-Type": "application/json"}
    r = requests.post("https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/invites",
                      headers=headers, json={
                        "participant_name": "Acme Customer",
                        "expires_in_hours": 24,
                        "max_uses": 5
                      })
    print(r.json())
    ```

    ```go Go theme={null}
    package main

    import (
    	"bytes"
    	"net/http"
    	"os"
    )

    func main() {
    	req, _ := http.NewRequest("POST", "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/invites",
    		bytes.NewBuffer([]byte(`{"participant_name":"Acme Customer","expires_in_hours":24,"max_uses":5}`)))
    	req.Header.Set("X-API-Key", os.Getenv("ORBIT_API_KEY"))
    	req.Header.Set("Content-Type", "application/json")
    	http.DefaultClient.Do(req)
    }
    ```

    ```php PHP theme={null}
    <?php
    $ch = curl_init('https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/invites');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
      'X-API-Key: ' . getenv('ORBIT_API_KEY'),
      'Content-Type: application/json',
    ]);
    curl_setopt($ch, CURLOPT_POSTFIELDS, '{"participant_name":"Acme Customer","expires_in_hours":24,"max_uses":5}');
    echo curl_exec($ch);
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```json 201 theme={null}
  {
    "data": {
      "id": "5b3c7f01-2e44-49ab-bce0-7d0a8c9f0b14",
      "room_id": "8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002",
      "invite_token": "p9Z-vQrX_3kT2hN1m4yL5wB6aE7s8C9d",
      "participant_name": "Acme Customer",
      "expires_at": "2026-05-11T22:00:00Z",
      "revoked_at": null,
      "max_uses": 5,
      "uses_count": 0,
      "created_at": "2026-05-10T22:00:00Z"
    },
    "meta": { "request_id": "req_video_006", "timestamp": "2026-05-10T22:00:00Z" }
  }
  ```
</ResponseExample>

**Error codes:** `503 SERVICE_UNAVAILABLE`, `400` (validation), `403`, `404` (room not found), `500 VIDEO_INVITE_CREATE_FAILED`. Rate-limited per the authenticated-write bucket.

***

### List Invites

`GET /api/v1/video/rooms-scheduled/{id}/invites`

Retrieve every invite issued for a room.

<ParamField query="include_expired" type="boolean" default="false">
  When `true`, also returns invites past `expires_at`.
</ParamField>

<ParamField query="limit" type="integer" default="100">
  Results to return (1–500).
</ParamField>

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X GET "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/invites?include_expired=true" \
      -H "X-API-Key: dv_live_sk_your_key_here"
    ```

    ```typescript Node.js theme={null}
    const invites = await orbit.video.roomsScheduled.invites.list('8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002', {
      include_expired: true,
    })
    console.log(invites.data)
    ```

    ```python Python theme={null}
    import os, requests
    headers = {"X-API-Key": os.environ["ORBIT_API_KEY"]}
    r = requests.get("https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/invites",
                     headers=headers, params={"include_expired": "true"})
    print(r.json())
    ```

    ```go Go theme={null}
    package main

    import (
    	"net/http"
    	"os"
    )

    func main() {
    	req, _ := http.NewRequest("GET", "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/invites?include_expired=true", nil)
    	req.Header.Set("X-API-Key", os.Getenv("ORBIT_API_KEY"))
    	http.DefaultClient.Do(req)
    }
    ```

    ```php PHP theme={null}
    <?php
    $ch = curl_init('https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/invites?include_expired=true');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HTTPHEADER, ['X-API-Key: ' . getenv('ORBIT_API_KEY')]);
    echo curl_exec($ch);
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": [
      {
        "id": "5b3c7f01-2e44-49ab-bce0-7d0a8c9f0b14",
        "room_id": "8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002",
        "invite_token": "p9Z-vQrX_3kT2hN1m4yL5wB6aE7s8C9d",
        "participant_name": "Acme Customer",
        "expires_at": "2026-05-11T22:00:00Z",
        "revoked_at": null,
        "max_uses": 5,
        "uses_count": 1,
        "created_at": "2026-05-10T22:00:00Z"
      }
    ],
    "meta": { "request_id": "req_video_007", "timestamp": "2026-05-10T22:30:00Z" }
  }
  ```
</ResponseExample>

**Error codes:** `503 SERVICE_UNAVAILABLE`, `400` (invalid query), `404` (room not found), `500 VIDEO_INVITE_LIST_FAILED`. Rate-limited per the authenticated-read bucket.

***

### Revoke an Invite

`DELETE /api/v1/video/rooms-scheduled/{id}/invites/{inviteId}`

Permanently revoke an invite. Subsequent redeem attempts return `404`.

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X DELETE "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/invites/5b3c7f01-2e44-49ab-bce0-7d0a8c9f0b14" \
      -H "X-API-Key: dv_live_sk_your_key_here"
    ```

    ```typescript Node.js theme={null}
    await orbit.video.roomsScheduled.invites.revoke(
      '8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002',
      '5b3c7f01-2e44-49ab-bce0-7d0a8c9f0b14',
    )
    ```

    ```python Python theme={null}
    import os, requests
    headers = {"X-API-Key": os.environ["ORBIT_API_KEY"]}
    r = requests.delete("https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/invites/5b3c7f01-2e44-49ab-bce0-7d0a8c9f0b14",
                        headers=headers)
    print(r.status_code)
    ```

    ```go Go theme={null}
    package main

    import (
    	"net/http"
    	"os"
    )

    func main() {
    	req, _ := http.NewRequest("DELETE", "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/invites/5b3c7f01-2e44-49ab-bce0-7d0a8c9f0b14", nil)
    	req.Header.Set("X-API-Key", os.Getenv("ORBIT_API_KEY"))
    	http.DefaultClient.Do(req)
    }
    ```

    ```php PHP theme={null}
    <?php
    $ch = curl_init('https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/invites/5b3c7f01-2e44-49ab-bce0-7d0a8c9f0b14');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
    curl_setopt($ch, CURLOPT_HTTPHEADER, ['X-API-Key: ' . getenv('ORBIT_API_KEY')]);
    echo curl_exec($ch);
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```http 204 No Content theme={null}
  ```
</ResponseExample>

**Error codes:** `503 SERVICE_UNAVAILABLE`, `400` (id / inviteId not UUIDs), `403`, `404` (invite not found), `500 VIDEO_INVITE_REVOKE_FAILED`. Rate-limited per the authenticated-write bucket.

***

### Redeem an Invite (public)

`POST /api/v1/video/invites/{inviteToken}/redeem`

**No authentication.** Guest-facing endpoint. Exchanges an invite token for a short-lived Orbit Media JWT scoped to one room. Rate-limited per IP (30 requests / minute).

<ParamField path="inviteToken" type="string" required>
  The opaque invite token returned by `POST /rooms-scheduled/{id}/invites`. 24–64 characters, `[A-Za-z0-9_-]+`.
</ParamField>

<ParamField body="display_name" type="string" required>
  Friendly name shown to other participants (1–120 chars).
</ParamField>

<ParamField body="identity" type="string">
  Optional URL-safe identity (`[A-Za-z0-9_:-]+`, 1–120 chars). If omitted, the service mints one.
</ParamField>

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X POST "https://api.orbit.devotel.io/api/v1/video/invites/p9Z-vQrX_3kT2hN1m4yL5wB6aE7s8C9d/redeem" \
      -H "Content-Type: application/json" \
      -d '{ "display_name": "Acme Customer" }'
    ```

    ```typescript Node.js theme={null}
    // Public endpoint — no API key. Call directly via fetch.
    const res = await fetch('https://api.orbit.devotel.io/api/v1/video/invites/p9Z-vQrX_3kT2hN1m4yL5wB6aE7s8C9d/redeem', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ display_name: 'Acme Customer' }),
    })
    const json = await res.json()
    console.log(json.data.token, json.data.livekit_url)
    ```

    ```python Python theme={null}
    import requests
    r = requests.post("https://api.orbit.devotel.io/api/v1/video/invites/p9Z-vQrX_3kT2hN1m4yL5wB6aE7s8C9d/redeem",
                      json={"display_name": "Acme Customer"})
    print(r.json())
    ```

    ```go Go theme={null}
    package main

    import (
    	"bytes"
    	"net/http"
    )

    func main() {
    	req, _ := http.NewRequest("POST", "https://api.orbit.devotel.io/api/v1/video/invites/p9Z-vQrX_3kT2hN1m4yL5wB6aE7s8C9d/redeem",
    		bytes.NewBuffer([]byte(`{"display_name":"Acme Customer"}`)))
    	req.Header.Set("Content-Type", "application/json")
    	http.DefaultClient.Do(req)
    }
    ```

    ```php PHP theme={null}
    <?php
    $ch = curl_init('https://api.orbit.devotel.io/api/v1/video/invites/p9Z-vQrX_3kT2hN1m4yL5wB6aE7s8C9d/redeem');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
    curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
    curl_setopt($ch, CURLOPT_POSTFIELDS, '{"display_name":"Acme Customer"}');
    echo curl_exec($ch);
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
      "ws_url": "wss://media.orbit.devotel.io",
      "livekit_url": "wss://media.orbit.devotel.io",
      "room_sid": "RM_AbCdEfGhIjKl",
      "room_id": "8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002",
      "room_name": "Onboarding call - Acme",
      "identity": "guest_8f2a01b7",
      "display_name": "Acme Customer",
      "expires_at": "2026-05-10T15:00:00Z"
    },
    "meta": { "request_id": "req_video_008", "timestamp": "2026-05-10T14:00:00Z" }
  }
  ```

  `ws_url` and `livekit_url` both carry the same media-server WebSocket URL. Consume `livekit_url` — it is the front-end-facing key. `ws_url` is a legacy alias preserved for clients written against the earlier surface; read `livekit_url` and fall back to `ws_url` if it is absent.
</ResponseExample>

**Error codes:** `400` (validation, including malformed token), `404` (invite not found / revoked / expired / max-uses exhausted), `429` (per-IP rate-limit), `500 VIDEO_INVITE_REDEEM_FAILED`.

***

## Host admin controls

Force-disconnect or force-mute participants in a live scheduled room.

### Kick a Participant

`POST /api/v1/video/rooms-scheduled/{id}/participants/{identity}/kick`

Immediately disconnect a participant from the room. Their Orbit Media session is terminated; they can rejoin only with a fresh token.

<ParamField path="identity" type="string" required>
  The Orbit Media identity to kick (matches the `identity` passed at join time).
</ParamField>

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X POST "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/participants/guest_8f2a01b7/kick" \
      -H "X-API-Key: dv_live_sk_your_key_here"
    ```

    ```typescript Node.js theme={null}
    await orbit.video.roomsScheduled.participants.kick(
      '8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002',
      'guest_8f2a01b7',
    )
    ```

    ```python Python theme={null}
    import os, requests
    headers = {"X-API-Key": os.environ["ORBIT_API_KEY"]}
    r = requests.post("https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/participants/guest_8f2a01b7/kick",
                      headers=headers)
    print(r.status_code)
    ```

    ```go Go theme={null}
    package main

    import (
    	"net/http"
    	"os"
    )

    func main() {
    	req, _ := http.NewRequest("POST", "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/participants/guest_8f2a01b7/kick", nil)
    	req.Header.Set("X-API-Key", os.Getenv("ORBIT_API_KEY"))
    	http.DefaultClient.Do(req)
    }
    ```

    ```php PHP theme={null}
    <?php
    $ch = curl_init('https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/participants/guest_8f2a01b7/kick');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
    curl_setopt($ch, CURLOPT_HTTPHEADER, ['X-API-Key: ' . getenv('ORBIT_API_KEY')]);
    echo curl_exec($ch);
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```http 204 No Content theme={null}
  ```
</ResponseExample>

**Error codes:** `503 SERVICE_UNAVAILABLE`, `400` (id not a UUID), `403`, `404` (participant or room not found), `500 VIDEO_PARTICIPANT_KICK_FAILED`. Rate-limited per the authenticated-write bucket.

***

### Mute a Participant

`POST /api/v1/video/rooms-scheduled/{id}/participants/{identity}/mute`

Force-mute a participant's audio or video track. The participant can unmute themselves; this is a host-driven hard mute, not a permanent block.

<ParamField body="kind" type="string" default="audio">
  `audio` or `video`. Selects which track to mute.
</ParamField>

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X POST "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/participants/guest_8f2a01b7/mute" \
      -H "X-API-Key: dv_live_sk_your_key_here" \
      -H "Content-Type: application/json" \
      -d '{ "kind": "audio" }'
    ```

    ```typescript Node.js theme={null}
    await orbit.video.roomsScheduled.participants.mute(
      '8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002',
      'guest_8f2a01b7',
      { kind: 'audio' },
    )
    ```

    ```python Python theme={null}
    import os, requests
    headers = {"X-API-Key": os.environ["ORBIT_API_KEY"], "Content-Type": "application/json"}
    r = requests.post("https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/participants/guest_8f2a01b7/mute",
                      headers=headers, json={"kind": "audio"})
    print(r.status_code)
    ```

    ```go Go theme={null}
    package main

    import (
    	"bytes"
    	"net/http"
    	"os"
    )

    func main() {
    	req, _ := http.NewRequest("POST", "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/participants/guest_8f2a01b7/mute",
    		bytes.NewBuffer([]byte(`{"kind":"audio"}`)))
    	req.Header.Set("X-API-Key", os.Getenv("ORBIT_API_KEY"))
    	req.Header.Set("Content-Type", "application/json")
    	http.DefaultClient.Do(req)
    }
    ```

    ```php PHP theme={null}
    <?php
    $ch = curl_init('https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/participants/guest_8f2a01b7/mute');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
      'X-API-Key: ' . getenv('ORBIT_API_KEY'),
      'Content-Type: application/json',
    ]);
    curl_setopt($ch, CURLOPT_POSTFIELDS, '{"kind":"audio"}');
    echo curl_exec($ch);
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```http 204 No Content theme={null}
  ```
</ResponseExample>

**Error codes:** `503 SERVICE_UNAVAILABLE`, `400` (validation, including `kind` not in {`audio`,`video`}), `403`, `404` (participant or room not found), `500 VIDEO_PARTICIPANT_MUTE_FAILED`. Rate-limited per the authenticated-write bucket.

***

## Simulive broadcast

Schedule a pre-recorded video to play out to an audience as if it were live — "simulive" (a.k.a. premiere / played-as-live), the standard webinar-platform capability behind Zoom Webinars, Livestorm, and ON24 premieres. Point at an already-uploaded recording, set a start time, and every viewer's player seeks to the SAME wall-clock-synced position of the file — nobody starts from zero on join.

This is a facet of an existing [scheduled room](#schedule-a-room), not a separate resource: there's no simulive id — the config lives on the room itself (`settings.simulive`), and every response is scoped by the room's `id`.

**Role requirements:** `GET /simulive` and `GET /simulive/playout` are available to any authenticated tenant member with the `video:read` scope (`video:write` works as a superset). `PUT /simulive` and `POST /simulive/cancel` require `owner`, `admin`, or `developer` plus the `video:write` scope.

These endpoints read/write the room row directly and never call out to Orbit Media, so — unlike the endpoints above — they do **not** return `503 SERVICE_UNAVAILABLE` when Orbit Media is unconfigured.

### Get Simulive Config

`GET /api/v1/video/rooms-scheduled/{id}/simulive`

Fetch the room's simulive configuration together with the live-computed playout (position, `broadcast_state`).

**Rate limit:** 120 requests/minute per tenant (`auth-read` bucket).

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X GET "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/simulive" \
      -H "X-API-Key: dv_live_sk_your_key_here"
    ```

    ```typescript Node.js theme={null}
    const simulive = await orbit.video.roomsScheduled.simulive.get('8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002')
    console.log(simulive.data.config.status, simulive.data.playout.broadcast_state)
    ```

    ```python Python theme={null}
    import os, requests
    headers = {"X-API-Key": os.environ["ORBIT_API_KEY"]}
    r = requests.get("https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/simulive", headers=headers)
    print(r.json())
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "room_id": "8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002",
      "room_status": "scheduled",
      "config": {
        "status": "scheduled",
        "title": "Q3 product premiere",
        "source_url": "https://cdn.example.com/recordings/q3-premiere.m3u8",
        "source_recording_id": "rec_8f2a01b7",
        "duration_seconds": 2400,
        "scheduled_start_at": "2026-07-20T17:00:00Z",
        "loop": false,
        "chat_enabled": true,
        "qa_enabled": true,
        "created_at": "2026-07-15T09:00:00.000Z",
        "updated_at": "2026-07-15T09:00:00.000Z",
        "canceled_at": null
      },
      "playout": {
        "broadcast_state": "upcoming",
        "is_live": false,
        "position_seconds": 0,
        "duration_seconds": 2400,
        "seconds_until_start": 41400,
        "seconds_remaining": null,
        "loop": false,
        "loop_iteration": 0,
        "source_url": "https://cdn.example.com/recordings/q3-premiere.m3u8",
        "title": "Q3 product premiere",
        "scheduled_start_at": "2026-07-20T17:00:00Z",
        "chat_enabled": true,
        "qa_enabled": true,
        "server_time": "2026-07-20T05:30:00.000Z"
      }
    },
    "meta": { "request_id": "req_video_simulive_001", "timestamp": "2026-07-20T05:30:00Z" }
  }
  ```
</ResponseExample>

**Error codes:** `422 VALIDATION_ERROR` (`id` not a UUID), `404 NOT_FOUND`, `500 VIDEO_SIMULIVE_GET_FAILED`.

***

### Configure a Simulive Broadcast

`PUT /api/v1/video/rooms-scheduled/{id}/simulive`

Create or update the room's simulive config. Every field is optional so you can build the config up incrementally (upload the recording, then set the schedule); set `status: "scheduled"` once `source_url`, `duration_seconds`, and `scheduled_start_at` are all in place — the request is rejected until they are. This is a full merge over the existing config (unset fields keep their current value), not a full replacement.

<ParamField body="status" type="string">
  `draft` or `scheduled`. Moving to `scheduled` requires `source_url`, a positive `duration_seconds`, and `scheduled_start_at` to already be set (in this request or a prior one) — otherwise the request is rejected and `status` stays unchanged. Moving to `draft` or `scheduled` clears a prior cancellation.
</ParamField>

<ParamField body="title" type="string">
  Optional broadcast title shown to viewers (up to 200 chars). `null` clears it.
</ParamField>

<ParamField body="source_url" type="string">
  Playable URL (HLS `.m3u8` or MP4) of the pre-recorded asset a broadcast player dereferences client-side. Must be `http(s)`. `null` clears it.
</ParamField>

<ParamField body="source_recording_id" type="string">
  Optional provenance pointer back to the unified recording behind `source_url` (up to 128 chars). `null` clears it.
</ParamField>

<ParamField body="duration_seconds" type="number">
  Total length of the source recording, in seconds (greater than 0, up to 86400 — 24 hours). Drives the playhead math — set it to the recording's actual runtime. `null` clears it.
</ParamField>

<ParamField body="scheduled_start_at" type="string">
  ISO-8601 wall-clock time the playout begins. `null` clears it.
</ParamField>

<ParamField body="loop" type="boolean" default="false">
  When `true`, playout restarts from `0` instead of ending once `duration_seconds` elapses.
</ParamField>

<ParamField body="chat_enabled" type="boolean" default="true">
  Whether live chat is layered over the broadcast.
</ParamField>

<ParamField body="qa_enabled" type="boolean" default="true">
  Whether live Q\&A is layered over the broadcast.
</ParamField>

**Rate limit:** 60 requests/minute per tenant (`auth-write` bucket).

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X PUT "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/simulive" \
      -H "X-API-Key: dv_live_sk_your_key_here" \
      -H "Content-Type: application/json" \
      -d '{
      "status": "scheduled",
      "title": "Q3 product premiere",
      "source_url": "https://cdn.example.com/recordings/q3-premiere.m3u8",
      "duration_seconds": 2400,
      "scheduled_start_at": "2026-07-20T17:00:00Z"
    }'
    ```

    ```typescript Node.js theme={null}
    const simulive = await orbit.video.roomsScheduled.simulive.update('8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002', {
      status: 'scheduled',
      title: 'Q3 product premiere',
      source_url: 'https://cdn.example.com/recordings/q3-premiere.m3u8',
      duration_seconds: 2400,
      scheduled_start_at: '2026-07-20T17:00:00Z',
    })
    console.log(simulive.data.config.status)
    ```

    ```python Python theme={null}
    import os, requests
    headers = {"X-API-Key": os.environ["ORBIT_API_KEY"], "Content-Type": "application/json"}
    r = requests.put("https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/simulive",
                     headers=headers, json={
                       "status": "scheduled",
                       "title": "Q3 product premiere",
                       "source_url": "https://cdn.example.com/recordings/q3-premiere.m3u8",
                       "duration_seconds": 2400,
                       "scheduled_start_at": "2026-07-20T17:00:00Z"
                     })
    print(r.json())
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "room_id": "8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002",
      "config": {
        "status": "scheduled",
        "title": "Q3 product premiere",
        "source_url": "https://cdn.example.com/recordings/q3-premiere.m3u8",
        "source_recording_id": null,
        "duration_seconds": 2400,
        "scheduled_start_at": "2026-07-20T17:00:00Z",
        "loop": false,
        "chat_enabled": true,
        "qa_enabled": true,
        "created_at": "2026-07-20T05:30:00.000Z",
        "updated_at": "2026-07-20T05:30:00.000Z",
        "canceled_at": null
      },
      "playout": {
        "broadcast_state": "upcoming",
        "is_live": false,
        "position_seconds": 0,
        "duration_seconds": 2400,
        "seconds_until_start": 41400,
        "seconds_remaining": null,
        "loop": false,
        "loop_iteration": 0,
        "source_url": "https://cdn.example.com/recordings/q3-premiere.m3u8",
        "title": "Q3 product premiere",
        "scheduled_start_at": "2026-07-20T17:00:00Z",
        "chat_enabled": true,
        "qa_enabled": true,
        "server_time": "2026-07-20T05:30:00.000Z"
      }
    },
    "meta": { "request_id": "req_video_simulive_002", "timestamp": "2026-07-20T05:30:00Z" }
  }
  ```
</ResponseExample>

**Error codes:** `422 VALIDATION_ERROR` (field-level, e.g. `id` not a UUID, `source_url` not http(s), `duration_seconds` out of range), `400 VIDEO_SIMULIVE_INVALID` (business rule, e.g. requesting `status: "scheduled"` while `source_url` / `duration_seconds` / `scheduled_start_at` are still missing — the message lists exactly which), `403` (caller lacks owner/admin/developer or `video:write`), `404 NOT_FOUND` (room not found), `500 VIDEO_SIMULIVE_UPDATE_FAILED`. Rate-limited per the authenticated-write bucket.

***

### Cancel a Simulive Broadcast

`POST /api/v1/video/rooms-scheduled/{id}/simulive/cancel`

Cancel a scheduled (or draft) simulive broadcast. Idempotent — canceling an already-canceled broadcast is a no-op that preserves the original `canceled_at`. To resume broadcasting on the same room, `PUT /simulive` with `status: "draft"` or `"scheduled"`, which clears the cancellation.

**Rate limit:** 60 requests/minute per tenant (`auth-write` bucket).

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X POST "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/simulive/cancel" \
      -H "X-API-Key: dv_live_sk_your_key_here"
    ```

    ```typescript Node.js theme={null}
    await orbit.video.roomsScheduled.simulive.cancel('8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002')
    ```

    ```python Python theme={null}
    import os, requests
    headers = {"X-API-Key": os.environ["ORBIT_API_KEY"]}
    r = requests.post("https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/simulive/cancel", headers=headers)
    print(r.json())
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "room_id": "8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002",
      "config": {
        "status": "canceled",
        "title": "Q3 product premiere",
        "source_url": "https://cdn.example.com/recordings/q3-premiere.m3u8",
        "source_recording_id": null,
        "duration_seconds": 2400,
        "scheduled_start_at": "2026-07-20T17:00:00Z",
        "loop": false,
        "chat_enabled": true,
        "qa_enabled": true,
        "created_at": "2026-07-20T05:30:00.000Z",
        "updated_at": "2026-07-20T06:00:00.000Z",
        "canceled_at": "2026-07-20T06:00:00.000Z"
      },
      "playout": {
        "broadcast_state": "canceled",
        "is_live": false,
        "position_seconds": 0,
        "duration_seconds": 2400,
        "seconds_until_start": null,
        "seconds_remaining": null,
        "loop": false,
        "loop_iteration": 0,
        "source_url": "https://cdn.example.com/recordings/q3-premiere.m3u8",
        "title": "Q3 product premiere",
        "scheduled_start_at": "2026-07-20T17:00:00Z",
        "chat_enabled": true,
        "qa_enabled": true,
        "server_time": "2026-07-20T06:00:00.000Z"
      }
    },
    "meta": { "request_id": "req_video_simulive_003", "timestamp": "2026-07-20T06:00:00Z" }
  }
  ```
</ResponseExample>

**Error codes:** `422 VALIDATION_ERROR` (`id` not a UUID), `403` (caller lacks owner/admin/developer or `video:write`), `404 NOT_FOUND` (room not found), `500 VIDEO_SIMULIVE_CANCEL_FAILED`. Rate-limited per the authenticated-write bucket.

***

### Get Simulive Playout

`GET /api/v1/video/rooms-scheduled/{id}/simulive/playout`

The lightweight, poll-friendly playhead a broadcast player hits repeatedly to stay wall-clock-synced with every other viewer — no audit trail, minimal payload (no `room_status` / `config`, just `room_id` and `playout`).

<Note>
  **Polling contract.** There is no push/websocket channel for playout — a player polls this endpoint (every 5–15 seconds is a reasonable default) and seeks its `<video>` element to `playout.position_seconds` of `playout.source_url`. Branch on `broadcast_state`: show a countdown using `seconds_until_start` while `upcoming`; play from `position_seconds` and keep polling while `live`; stop and show an ended state once `ended`; hide the player entirely on `canceled` or `not_configured`. When `loop` is `true`, watch `loop_iteration` to detect a wraparound instead of treating a drop in `position_seconds` as a seek backwards.
</Note>

**Rate limit:** 120 requests/minute per tenant (`auth-read` bucket).

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X GET "https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/simulive/playout" \
      -H "X-API-Key: dv_live_sk_your_key_here"
    ```

    ```typescript Node.js theme={null}
    const { data } = await orbit.video.roomsScheduled.simulive.playout('8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002')
    videoEl.currentTime = data.playout.position_seconds
    ```

    ```python Python theme={null}
    import os, requests
    headers = {"X-API-Key": os.environ["ORBIT_API_KEY"]}
    r = requests.get("https://api.orbit.devotel.io/api/v1/video/rooms-scheduled/8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002/simulive/playout", headers=headers)
    print(r.json())
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "room_id": "8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002",
      "playout": {
        "broadcast_state": "live",
        "is_live": true,
        "position_seconds": 184.201,
        "duration_seconds": 2400,
        "seconds_until_start": null,
        "seconds_remaining": 2215.799,
        "loop": false,
        "loop_iteration": 0,
        "source_url": "https://cdn.example.com/recordings/q3-premiere.m3u8",
        "title": "Q3 product premiere",
        "scheduled_start_at": "2026-07-20T17:00:00Z",
        "chat_enabled": true,
        "qa_enabled": true,
        "server_time": "2026-07-20T17:03:04.201Z"
      }
    },
    "meta": { "request_id": "req_video_simulive_004", "timestamp": "2026-07-20T17:03:04Z" }
  }
  ```
</ResponseExample>

**Error codes:** `422 VALIDATION_ERROR` (`id` not a UUID), `404 NOT_FOUND` (room not found), `500 VIDEO_SIMULIVE_PLAYOUT_FAILED`.

***

## Ad-hoc rooms

Fire-and-forget Orbit Media rooms. No DB row, no inbox conversation, no recording. Use when the room only needs to live for one session and disappear when participants leave. Room names are tenant-prefixed on the Orbit Media side (invariant #TI-P0-3) so collisions across tenants are impossible.

### Create an Ad-hoc Room

`POST /api/v1/video/rooms`

Create an Orbit Media room and receive a host token in the same response.

<ParamField body="name" type="string" required>
  URL-safe room name (3–200 chars, `[a-zA-Z0-9_-]+`). Tenant prefix is prepended internally; `display_name` echoes the un-prefixed name.
</ParamField>

<ParamField body="max_participants" type="integer">
  Hard cap on concurrent participants (2–300).
</ParamField>

<ParamField body="metadata" type="object">
  Arbitrary key-value pairs forwarded to Orbit Media.
</ParamField>

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X POST "https://api.orbit.devotel.io/api/v1/video/rooms" \
      -H "X-API-Key: dv_live_sk_your_key_here" \
      -H "Content-Type: application/json" \
      -d '{
      "name": "quick-sync",
      "max_participants": 8
    }'
    ```

    ```typescript Node.js theme={null}
    const room = await orbit.video.rooms.create({
      name: 'quick-sync',
      max_participants: 8,
    })
    console.log(room.data.room_name, room.data.token)
    ```

    ```python Python theme={null}
    import os, requests
    headers = {"X-API-Key": os.environ["ORBIT_API_KEY"], "Content-Type": "application/json"}
    r = requests.post("https://api.orbit.devotel.io/api/v1/video/rooms", headers=headers, json={
      "name": "quick-sync",
      "max_participants": 8
    })
    print(r.json())
    ```

    ```go Go theme={null}
    package main

    import (
    	"bytes"
    	"net/http"
    	"os"
    )

    func main() {
    	req, _ := http.NewRequest("POST", "https://api.orbit.devotel.io/api/v1/video/rooms",
    		bytes.NewBuffer([]byte(`{"name":"quick-sync","max_participants":8}`)))
    	req.Header.Set("X-API-Key", os.Getenv("ORBIT_API_KEY"))
    	req.Header.Set("Content-Type", "application/json")
    	http.DefaultClient.Do(req)
    }
    ```

    ```php PHP theme={null}
    <?php
    $ch = curl_init('https://api.orbit.devotel.io/api/v1/video/rooms');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
      'X-API-Key: ' . getenv('ORBIT_API_KEY'),
      'Content-Type: application/json',
    ]);
    curl_setopt($ch, CURLOPT_POSTFIELDS, '{"name":"quick-sync","max_participants":8}');
    echo curl_exec($ch);
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```json 201 theme={null}
  {
    "data": {
      "room_name": "tenant_abc1234_quick-sync",
      "display_name": "quick-sync",
      "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
      "url": "wss://media.orbit.devotel.io"
    },
    "meta": { "request_id": "req_video_009", "timestamp": "2026-05-10T14:00:00Z" }
  }
  ```

  The wire-level `room_name` keeps the `tenant_<id>_` prefix; pass that to Orbit Media. The dashboard uses `display_name` for rendering.
</ResponseExample>

**Error codes:** `503 SERVICE_UNAVAILABLE`, `400` (validation, including non-URL-safe name), `403`, `500 VIDEO_ROOM_CREATION_FAILED`.

***

### List Ad-hoc Rooms

`GET /api/v1/video/rooms`

List active Orbit Media rooms owned by the current tenant. Cursor-paginated by room name.

<ParamField query="cursor" type="string">
  Room name to resume from. Returned in the previous page's `pagination.next_cursor`.
</ParamField>

<ParamField query="limit" type="integer" default="25">
  Results per page (max 200).
</ParamField>

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X GET "https://api.orbit.devotel.io/api/v1/video/rooms?limit=20" \
      -H "X-API-Key: dv_live_sk_your_key_here"
    ```

    ```typescript Node.js theme={null}
    const rooms = await orbit.video.rooms.list({ limit: 20 })
    console.log(rooms.data, rooms.pagination)
    ```

    ```python Python theme={null}
    import os, requests
    headers = {"X-API-Key": os.environ["ORBIT_API_KEY"]}
    r = requests.get("https://api.orbit.devotel.io/api/v1/video/rooms", headers=headers, params={"limit": 20})
    print(r.json())
    ```

    ```go Go theme={null}
    package main

    import (
    	"net/http"
    	"os"
    )

    func main() {
    	req, _ := http.NewRequest("GET", "https://api.orbit.devotel.io/api/v1/video/rooms?limit=20", nil)
    	req.Header.Set("X-API-Key", os.Getenv("ORBIT_API_KEY"))
    	http.DefaultClient.Do(req)
    }
    ```

    ```php PHP theme={null}
    <?php
    $ch = curl_init('https://api.orbit.devotel.io/api/v1/video/rooms?limit=20');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HTTPHEADER, ['X-API-Key: ' . getenv('ORBIT_API_KEY')]);
    echo curl_exec($ch);
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": [
      {
        "name": "tenant_abc1234_quick-sync",
        "display_name": "quick-sync",
        "num_participants": 2,
        "max_participants": 8,
        "creation_time": "2026-05-10T14:00:00Z",
        "metadata": ""
      }
    ],
    "pagination": {
      "next_cursor": null,
      "has_more": false,
      "total": 1
    },
    "meta": { "request_id": "req_video_010", "timestamp": "2026-05-10T14:05:00Z" }
  }
  ```
</ResponseExample>

**Error codes:** `503 SERVICE_UNAVAILABLE`, `400 INVALID_CURSOR`, `500 VIDEO_ROOM_LIST_FAILED`.

***

### Get an Ad-hoc Room

`GET /api/v1/video/rooms/{name}`

Fetch a specific ad-hoc room with its live participant roster. Pass either the prefixed wire name or the bare display name.

<ParamField path="name" type="string" required>
  Room name (with or without the `tenant_<id>_` prefix).
</ParamField>

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X GET "https://api.orbit.devotel.io/api/v1/video/rooms/quick-sync" \
      -H "X-API-Key: dv_live_sk_your_key_here"
    ```

    ```typescript Node.js theme={null}
    const detail = await orbit.video.rooms.get('quick-sync')
    console.log(detail.data.participants)
    ```

    ```python Python theme={null}
    import os, requests
    headers = {"X-API-Key": os.environ["ORBIT_API_KEY"]}
    r = requests.get("https://api.orbit.devotel.io/api/v1/video/rooms/quick-sync", headers=headers)
    print(r.json())
    ```

    ```go Go theme={null}
    package main

    import (
    	"net/http"
    	"os"
    )

    func main() {
    	req, _ := http.NewRequest("GET", "https://api.orbit.devotel.io/api/v1/video/rooms/quick-sync", nil)
    	req.Header.Set("X-API-Key", os.Getenv("ORBIT_API_KEY"))
    	http.DefaultClient.Do(req)
    }
    ```

    ```php PHP theme={null}
    <?php
    $ch = curl_init('https://api.orbit.devotel.io/api/v1/video/rooms/quick-sync');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HTTPHEADER, ['X-API-Key: ' . getenv('ORBIT_API_KEY')]);
    echo curl_exec($ch);
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "name": "tenant_abc1234_quick-sync",
      "display_name": "quick-sync",
      "num_participants": 2,
      "max_participants": 8,
      "creation_time": "2026-05-10T14:00:00Z",
      "metadata": "",
      "participants": [
        {
          "identity": "user_42",
          "name": "Jane Doe",
          "joined_at": "2026-05-10T14:00:30Z",
          "is_publishing": true
        }
      ]
    },
    "meta": { "request_id": "req_video_011", "timestamp": "2026-05-10T14:02:00Z" }
  }
  ```
</ResponseExample>

**Error codes:** `503 SERVICE_UNAVAILABLE`, `404 NOT_FOUND`, `500 VIDEO_ROOM_DETAILS_FAILED`.

***

### Delete an Ad-hoc Room

`DELETE /api/v1/video/rooms/{name}`

Close the Orbit Media room and disconnect every participant.

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X DELETE "https://api.orbit.devotel.io/api/v1/video/rooms/quick-sync" \
      -H "X-API-Key: dv_live_sk_your_key_here"
    ```

    ```typescript Node.js theme={null}
    await orbit.video.rooms.delete('quick-sync')
    ```

    ```python Python theme={null}
    import os, requests
    headers = {"X-API-Key": os.environ["ORBIT_API_KEY"]}
    r = requests.delete("https://api.orbit.devotel.io/api/v1/video/rooms/quick-sync", headers=headers)
    print(r.status_code)
    ```

    ```go Go theme={null}
    package main

    import (
    	"net/http"
    	"os"
    )

    func main() {
    	req, _ := http.NewRequest("DELETE", "https://api.orbit.devotel.io/api/v1/video/rooms/quick-sync", nil)
    	req.Header.Set("X-API-Key", os.Getenv("ORBIT_API_KEY"))
    	http.DefaultClient.Do(req)
    }
    ```

    ```php PHP theme={null}
    <?php
    $ch = curl_init('https://api.orbit.devotel.io/api/v1/video/rooms/quick-sync');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
    curl_setopt($ch, CURLOPT_HTTPHEADER, ['X-API-Key: ' . getenv('ORBIT_API_KEY')]);
    echo curl_exec($ch);
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```http 204 No Content theme={null}
  ```
</ResponseExample>

**Error codes:** `503 SERVICE_UNAVAILABLE`, `403` (caller lacks owner/admin/developer), `500 VIDEO_ROOM_CLOSE_FAILED`.

***

### Issue a Participant Token

`POST /api/v1/video/rooms/{name}/token`

Generate a per-participant Orbit Media join token for an ad-hoc room. Use this when the host needs to invite someone else into the room they created.

<ParamField body="participant_name" type="string" required>
  Friendly participant name (1–200 chars). Also used as the Orbit Media identity.
</ParamField>

<ParamField body="metadata" type="object">
  Arbitrary key-value pairs attached to the Orbit Media participant.
</ParamField>

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X POST "https://api.orbit.devotel.io/api/v1/video/rooms/quick-sync/token" \
      -H "X-API-Key: dv_live_sk_your_key_here" \
      -H "Content-Type: application/json" \
      -d '{ "participant_name": "Jane Doe" }'
    ```

    ```typescript Node.js theme={null}
    const token = await orbit.video.rooms.token('quick-sync', { participant_name: 'Jane Doe' })
    console.log(token.data.token, token.data.url)
    ```

    ```python Python theme={null}
    import os, requests
    headers = {"X-API-Key": os.environ["ORBIT_API_KEY"], "Content-Type": "application/json"}
    r = requests.post("https://api.orbit.devotel.io/api/v1/video/rooms/quick-sync/token",
                      headers=headers, json={"participant_name": "Jane Doe"})
    print(r.json())
    ```

    ```go Go theme={null}
    package main

    import (
    	"bytes"
    	"net/http"
    	"os"
    )

    func main() {
    	req, _ := http.NewRequest("POST", "https://api.orbit.devotel.io/api/v1/video/rooms/quick-sync/token",
    		bytes.NewBuffer([]byte(`{"participant_name":"Jane Doe"}`)))
    	req.Header.Set("X-API-Key", os.Getenv("ORBIT_API_KEY"))
    	req.Header.Set("Content-Type", "application/json")
    	http.DefaultClient.Do(req)
    }
    ```

    ```php PHP theme={null}
    <?php
    $ch = curl_init('https://api.orbit.devotel.io/api/v1/video/rooms/quick-sync/token');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
      'X-API-Key: ' . getenv('ORBIT_API_KEY'),
      'Content-Type: application/json',
    ]);
    curl_setopt($ch, CURLOPT_POSTFIELDS, '{"participant_name":"Jane Doe"}');
    echo curl_exec($ch);
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
      "url": "wss://media.orbit.devotel.io"
    },
    "meta": { "request_id": "req_video_012", "timestamp": "2026-05-10T14:01:00Z" }
  }
  ```
</ResponseExample>

**Error codes:** `503 SERVICE_UNAVAILABLE`, `400` (validation), `403`, `500 TOKEN_GENERATION_FAILED`.

***

## Room templates

Save a room's setup once — participant cap, recording and egress defaults, media policy, E2EE, region, and the waiting-room lobby — as a named, reusable **room template** ("meeting template"), then spin up consistently-configured rooms from it. Templates are tenant-scoped and stored as a non-secret config preset; recording-storage credentials and consent receipts are never persisted on a template.

Reads (`list`, `get`) require the `video:read` scope (`video:write` works as a superset). Writes (`create`, `update`, `delete`, `instantiate`) require the `owner`, `admin`, or `developer` role plus the `video:write` scope. Each tenant may keep up to 100 templates.

The template `config` is a bag of optional knobs that map 1:1 to the identically-named [Schedule a Room](#schedule-a-room) fields: `max_participants`, `max_duration_minutes`, `recording_enabled`, `recording_format`, `recording_output`, `simulcast`, `spatial_layers`, `max_bitrate_kbps`, `video_codec`, `svc_scalability_mode`, `e2ee`, `e2ee_key_provider`, `virtual_background`, `noise_suppression`, `spatial_audio`, `audio_only`, `captions_required`, `region`, `waiting_room`, and `waiting_room_auto_promote`. An empty `config` (`{}`) is a valid "all defaults" template. Unknown keys are rejected, and `svc_scalability_mode` is only valid with a `vp9` or `av1` `video_codec`.

### List Room Templates

`GET /api/v1/video/room-templates`

Returns the tenant's saved templates. Requires the `video:read` scope.

**Rate limit:** 120 requests/minute per tenant (`auth-read` bucket).

<RequestExample>
  ```bash cURL theme={null}
  curl -X GET "https://api.orbit.devotel.io/api/v1/video/room-templates" \
    -H "X-API-Key: dv_live_sk_your_key_here"
  ```
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "templates": [
        {
          "id": "vrtpl_8e7c9a023e944f4fb2a891d6b9b3a002",
          "name": "Sales demo",
          "description": "Recorded demo with a lobby",
          "config": {
            "max_participants": 25,
            "recording_enabled": true,
            "recording_format": "composite",
            "waiting_room": true,
            "region": "eu"
          },
          "created_at": "2026-07-06T10:00:00.000Z",
          "updated_at": "2026-07-06T10:00:00.000Z",
          "created_by": "user_2ab..."
        }
      ],
      "total": 1
    },
    "meta": { "request_id": "req_video_rt_001", "timestamp": "2026-07-06T10:05:00Z" }
  }
  ```
</ResponseExample>

### Get a Room Template

`GET /api/v1/video/room-templates/{id}`

Fetch a single template by its `vrtpl_`-prefixed id. Requires the `video:read` scope. A missing id returns `404 NOT_FOUND`.

<RequestExample>
  ```bash cURL theme={null}
  curl -X GET "https://api.orbit.devotel.io/api/v1/video/room-templates/vrtpl_8e7c9a023e944f4fb2a891d6b9b3a002" \
    -H "X-API-Key: dv_live_sk_your_key_here"
  ```
</RequestExample>

### Save a Room Template

`POST /api/v1/video/room-templates`

Create a new template. Owner/admin/developer only. Names must be unique within the tenant (case-insensitive).

<ParamField body="name" type="string" required>
  Label shown in the template picker (1–120 characters). Unique within the tenant (case-insensitive).
</ParamField>

<ParamField body="description" type="string">
  Optional operator description (up to 500 characters).
</ParamField>

<ParamField body="config" type="object" required>
  The reusable, non-secret room-config preset (see the knob list above). May be an empty object for an "all defaults" template.
</ParamField>

**Rate limit:** 60 requests/minute per tenant (`auth-write` bucket).

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X POST "https://api.orbit.devotel.io/api/v1/video/room-templates" \
      -H "X-API-Key: dv_live_sk_your_key_here" \
      -H "Content-Type: application/json" \
      -d '{
        "name": "Sales demo",
        "description": "Recorded demo with a lobby",
        "config": {
          "max_participants": 25,
          "recording_enabled": true,
          "recording_format": "composite",
          "waiting_room": true,
          "region": "eu"
        }
      }'
    ```

    ```python Python theme={null}
    import os, requests
    headers = {"X-API-Key": os.environ["ORBIT_API_KEY"], "Content-Type": "application/json"}
    r = requests.post("https://api.orbit.devotel.io/api/v1/video/room-templates", headers=headers, json={
        "name": "Sales demo",
        "description": "Recorded demo with a lobby",
        "config": {"max_participants": 25, "recording_enabled": True, "recording_format": "composite", "waiting_room": True, "region": "eu"},
    })
    print(r.json())
    ```

    ```go Go theme={null}
    package main

    import (
    	"bytes"
    	"net/http"
    	"os"
    )

    func main() {
    	body := []byte(`{"name":"Sales demo","config":{"max_participants":25,"recording_enabled":true}}`)
    	req, _ := http.NewRequest("POST", "https://api.orbit.devotel.io/api/v1/video/room-templates", bytes.NewBuffer(body))
    	req.Header.Set("X-API-Key", os.Getenv("ORBIT_API_KEY"))
    	req.Header.Set("Content-Type", "application/json")
    	http.DefaultClient.Do(req)
    }
    ```

    ```php PHP theme={null}
    <?php
    $ch = curl_init('https://api.orbit.devotel.io/api/v1/video/room-templates');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_HTTPHEADER, ['X-API-Key: ' . getenv('ORBIT_API_KEY'), 'Content-Type: application/json']);
    curl_setopt($ch, CURLOPT_POSTFIELDS, '{"name":"Sales demo","config":{"max_participants":25,"recording_enabled":true}}');
    echo curl_exec($ch);
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```json 201 theme={null}
  {
    "data": {
      "template": {
        "id": "vrtpl_8e7c9a023e944f4fb2a891d6b9b3a002",
        "name": "Sales demo",
        "description": "Recorded demo with a lobby",
        "config": { "max_participants": 25, "recording_enabled": true, "recording_format": "composite", "waiting_room": true, "region": "eu" },
        "created_at": "2026-07-06T10:00:00.000Z",
        "updated_at": "2026-07-06T10:00:00.000Z",
        "created_by": "user_2ab..."
      }
    },
    "meta": { "request_id": "req_video_rt_002", "timestamp": "2026-07-06T10:00:00Z" }
  }
  ```
</ResponseExample>

**Error codes:** `400 VIDEO_ROOM_TEMPLATE_INVALID` (duplicate name or template cap reached), `422 VALIDATION_ERROR`, `403`, `500 VIDEO_ROOM_TEMPLATE_CREATE_FAILED`.

### Update a Room Template

`PATCH /api/v1/video/room-templates/{id}`

Partial update. Supply at least one of `name`, `description`, or `config`; `description: null` clears the description. `config` is a **full replacement** of the stored preset (validated in whole), not a deep-merge — send the complete config you want, not just the changed knobs. Owner/admin/developer only.

<RequestExample>
  ```bash cURL theme={null}
  curl -X PATCH "https://api.orbit.devotel.io/api/v1/video/room-templates/vrtpl_8e7c9a023e944f4fb2a891d6b9b3a002" \
    -H "X-API-Key: dv_live_sk_your_key_here" \
    -H "Content-Type: application/json" \
    -d '{ "config": { "max_participants": 50, "recording_enabled": true, "recording_format": "both" } }'
  ```
</RequestExample>

**Error codes:** `400` (invalid field or name collision), `404 NOT_FOUND`, `422 VALIDATION_ERROR` (no updatable field supplied), `403`, `500 VIDEO_ROOM_TEMPLATE_UPDATE_FAILED`.

### Delete a Room Template

`DELETE /api/v1/video/room-templates/{id}`

Remove a template. A missing id returns `404 NOT_FOUND`, so a delete never silently no-ops a mistyped id. Owner/admin/developer only.

<RequestExample>
  ```bash cURL theme={null}
  curl -X DELETE "https://api.orbit.devotel.io/api/v1/video/room-templates/vrtpl_8e7c9a023e944f4fb2a891d6b9b3a002" \
    -H "X-API-Key: dv_live_sk_your_key_here"
  ```
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": { "deleted": "vrtpl_8e7c9a023e944f4fb2a891d6b9b3a002" },
    "meta": { "request_id": "req_video_rt_003", "timestamp": "2026-07-06T11:00:00Z" }
  }
  ```
</ResponseExample>

### Create a Room from a Template

`POST /api/v1/video/room-templates/{id}/instantiate`

Create a live (or scheduled) room from a saved template. The template's stored `config` is applied to the room-create path with the same per-tier participant-cap gate, required-captions guard, media/E2EE/region knobs, waiting-room lobby, recording-consent gate and auto-start, inbox backlink, and audit trail as [Schedule a Room](#schedule-a-room). Owner/admin/developer only.

<ParamField body="name" type="string" required>
  Label for the new room (1–200 characters). Per-instance — it does not change the template.
</ParamField>

<ParamField body="scheduled_at" type="string">
  Optional ISO 8601 date-time to schedule the room for. Omit or send `null` to create it live now.
</ParamField>

<ParamField body="recording_consent_receipt_id" type="string">
  Consent-receipt id threaded into the recording-consent gate when the template auto-starts a recording in a two-party-consent jurisdiction.
</ParamField>

**Rate limit:** 60 requests/minute per tenant (`auth-write` bucket).

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X POST "https://api.orbit.devotel.io/api/v1/video/room-templates/vrtpl_8e7c9a023e944f4fb2a891d6b9b3a002/instantiate" \
      -H "X-API-Key: dv_live_sk_your_key_here" \
      -H "Content-Type: application/json" \
      -d '{ "name": "Acme demo — Tuesday" }'
    ```

    ```python Python theme={null}
    import os, requests
    headers = {"X-API-Key": os.environ["ORBIT_API_KEY"], "Content-Type": "application/json"}
    r = requests.post(
        "https://api.orbit.devotel.io/api/v1/video/room-templates/vrtpl_8e7c9a023e944f4fb2a891d6b9b3a002/instantiate",
        headers=headers, json={"name": "Acme demo — Tuesday"})
    print(r.json())
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```json 201 theme={null}
  {
    "data": {
      "room_id": "8e7c9a02-3e94-4f4f-b2a8-91d6b9b3a002",
      "room_sid": "RM_abc123",
      "host_token": "eyJhbGciOi...",
      "ws_url": "wss://media.orbit.devotel.io",
      "template_id": "vrtpl_8e7c9a023e944f4fb2a891d6b9b3a002",
      "composite_egress_id": null,
      "track_egress_id": null
    },
    "meta": { "request_id": "req_video_rt_004", "timestamp": "2026-07-06T12:00:00Z" }
  }
  ```
</ResponseExample>

**Error codes:** `400 VIDEO_PARTICIPANTS_CAP_EXCEEDED` (template `max_participants` above your plan cap), `404 NOT_FOUND`, `422 VALIDATION_ERROR` / `RECORDING_CONSENT_REQUIRED`, `503 SERVICE_UNAVAILABLE` (video service not configured) or `503 VIDEO_CAPTIONS_NOT_CONFIGURED` (template requires live captions the deployment lacks), `500 VIDEO_ROOM_TEMPLATE_INSTANTIATE_FAILED`.

***

## Room usage analytics

Operator-facing aggregate of how your organization is using video rooms: sessions over time, average and peak concurrent participants, total room-minutes, recording success rate, and join failures. Read-only, tenant-scoped. This is the room-level usage report — distinct from per-recording chapter themes and per-call connection quality.

### Get Room Usage

`GET /api/v1/video/rooms-analytics/usage`

Returns a window summary plus a daily time series aggregated from realized room sessions. One bucket per UTC day. Requires the `video:read` scope (`video:write` works as a superset).

<ParamField query="from" type="string">
  Start of the window, ISO 8601 (e.g. `2026-06-01T00:00:00Z`). Defaults to 30 days before `to`.
</ParamField>

<ParamField query="to" type="string">
  End of the window, ISO 8601. Defaults to now. Must be later than `from` or the request returns `400 INVALID_WINDOW`.
</ParamField>

<Note>
  The daily series is capped at 366 buckets (a full leap year). A longer window still returns a `summary` covering the entire range, but the `daily` array is truncated to the first 366 days. The summary window anchors on each session's start time, falling back to its creation time, so rooms that were created but never connected still appear in the totals as join failures.
</Note>

**Rate limit:** 120 requests/minute per tenant (`auth-read` bucket).

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X GET "https://api.orbit.devotel.io/api/v1/video/rooms-analytics/usage?from=2026-06-01T00:00:00Z&to=2026-06-28T00:00:00Z" \
      -H "X-API-Key: dv_live_sk_your_key_here"
    ```

    ```typescript Node.js theme={null}
    const usage = await orbit.video.roomsAnalytics.usage({
      from: '2026-06-01T00:00:00Z',
      to: '2026-06-28T00:00:00Z',
    })
    console.log(usage.data.summary, usage.data.daily)
    ```

    ```python Python theme={null}
    import os, requests
    headers = {"X-API-Key": os.environ["ORBIT_API_KEY"]}
    r = requests.get("https://api.orbit.devotel.io/api/v1/video/rooms-analytics/usage",
                     headers=headers, params={"from": "2026-06-01T00:00:00Z", "to": "2026-06-28T00:00:00Z"})
    print(r.json())
    ```

    ```go Go theme={null}
    package main

    import (
    	"net/http"
    	"os"
    )

    func main() {
    	req, _ := http.NewRequest("GET", "https://api.orbit.devotel.io/api/v1/video/rooms-analytics/usage?from=2026-06-01T00:00:00Z&to=2026-06-28T00:00:00Z", nil)
    	req.Header.Set("X-API-Key", os.Getenv("ORBIT_API_KEY"))
    	http.DefaultClient.Do(req)
    }
    ```

    ```php PHP theme={null}
    <?php
    $ch = curl_init('https://api.orbit.devotel.io/api/v1/video/rooms-analytics/usage?from=2026-06-01T00:00:00Z&to=2026-06-28T00:00:00Z');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HTTPHEADER, ['X-API-Key: ' . getenv('ORBIT_API_KEY')]);
    echo curl_exec($ch);
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "window": {
        "from": "2026-06-01T00:00:00.000Z",
        "to": "2026-06-28T00:00:00.000Z"
      },
      "summary": {
        "total_sessions": 184,
        "total_room_seconds": 712800,
        "total_room_minutes": 11880,
        "avg_peak_participants": 3.4,
        "max_peak_participants": 22,
        "total_join_events": 612,
        "recordings_started": 41,
        "recordings_succeeded": 39,
        "recording_success_rate": 0.95,
        "join_failures": 6,
        "error_sessions": 2
      },
      "daily": [
        {
          "date": "2026-06-01",
          "sessions": 7,
          "room_minutes": 432,
          "avg_peak_participants": 3.1,
          "max_peak_participants": 9
        },
        {
          "date": "2026-06-02",
          "sessions": 11,
          "room_minutes": 690,
          "avg_peak_participants": 4.0,
          "max_peak_participants": 14
        }
      ]
    },
    "meta": { "request_id": "req_video_013", "timestamp": "2026-06-28T09:00:00Z" }
  }
  ```
</ResponseExample>

**Response fields**

* `window` — the resolved `from`/`to` (ISO 8601) after defaults are applied.
* `summary.total_sessions` — realized room sessions in the window.
* `summary.total_room_seconds` / `total_room_minutes` — total room wall-clock across all sessions.
* `summary.avg_peak_participants` — average of each session's peak concurrent participants (sessions that never connected are excluded so empty rooms don't drag it down).
* `summary.max_peak_participants` — busiest single moment in the window.
* `summary.total_join_events` — count of participant join events (a rejoin counts twice).
* `summary.recordings_started` / `recordings_succeeded` — recordings attempted vs landed.
* `summary.recording_success_rate` — `recordings_succeeded / recordings_started`, `0`–`1`, or `null` when no recording was attempted.
* `summary.join_failures` — sessions where the room was created but no participant ever connected.
* `summary.error_sessions` — sessions that terminated with an error.
* `daily[]` — one bucket per UTC day with `date` (`YYYY-MM-DD`), `sessions`, `room_minutes`, `avg_peak_participants`, and `max_peak_participants`.

**Error codes:** `400 VALIDATION_ERROR` (malformed `from`/`to`), `400 INVALID_WINDOW` (`from` later than `to`), `403` (missing `video:read` scope), `500 VIDEO_ROOM_USAGE_ANALYTICS_FAILED`.

***

## Inbound webhook

Orbit Media posts room and recording lifecycle events to `POST /api/v1/video/webhook`. The endpoint is platform-managed — you don't call it directly. Events received here drive `started_at` / `ended_at` / `recording_url` updates on `video_rooms` and the matching inbox-conversation status transitions.

Orbit Media signs each delivery with a JWT minted from the API secret; Orbit validates the signature via the SFU's `WebhookReceiver` (carried over from the LiveKit OSS fork) before processing.

***

## See also

* [Webhooks → video events](/webhooks/events)
