Video API
Real-time video rooms backed by Orbit Media (Devotel’s hosted SFU; forked from LiveKit OSS under Apache-2 License — see 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 avideoconversation, 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.
/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 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 legacyDEVOTEL_LIVEKIT_URL/DEVOTEL_LIVEKIT_API_KEYas a fallback whenDEVOTEL_ORBIT_MEDIA_URL/DEVOTEL_ORBIT_MEDIA_API_KEYare 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) requireDEVOTEL_ORBIT_MEDIA_URLandDEVOTEL_ORBIT_MEDIA_API_KEYdirectly — there is noDEVOTEL_LIVEKIT_*fallback, so a cluster set up with only the legacy vars returns503on these endpoints.
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 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 bytenant_<id>.video_rooms. Each room creates a companion conversations row (channel='video') so the inbox renders it alongside other channels.
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.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.
Human-readable room title (1–200 chars). Surfaces in the inbox and join UIs.
ISO-8601 timestamp with offset (e.g.
2026-05-10T14:00:00Z). Omit or null for “start now”.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.When
true, recording starts automatically the moment the first participant joins.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).Arbitrary key-value pairs persisted on the room row. Surfaces back on
GET /:id.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.
Filter by lifecycle:
scheduled, live, ended. Omit for all statuses.Results to return (1–500).
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.
Room UUID returned from
POST /rooms-scheduled.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.
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.
Friendly name shown to other participants.
participant or host. Server-side guard: callers that are not owner / admin / developer are silently downgraded to participant.Optional UUID of an Orbit user this participant represents. Defaults to the calling user.
Optional UUID of a contact the participant represents (CRM linkage).
Token TTL in seconds (60–86400). Defaults to the service-configured value.
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.
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.
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.
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 toexpires_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.
Optional name pre-filled for the guest in the redeem flow (1–120 chars).
Invite TTL in hours (1–168). Defaults to a service-configured value (typically 24).
Cap on the number of redemptions (1–500). Omit or
null for unlimited.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.
When
true, also returns invites past expires_at.Results to return (1–500).
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.
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).
The opaque invite token returned by
POST /rooms-scheduled/{id}/invites. 24–64 characters, [A-Za-z0-9_-]+.Friendly name shown to other participants (1–120 chars).
Optional URL-safe identity (
[A-Za-z0-9_:-]+, 1–120 chars). If omitted, the service mints one.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.
The Orbit Media identity to kick (matches the
identity passed at join time).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.
audio or video. Selects which track to mute.503 SERVICE_UNAVAILABLE, 400 (validation, including kind not in ), 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, 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).
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.
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.Optional broadcast title shown to viewers (up to 200 chars).
null clears it.Playable URL (HLS
.m3u8 or MP4) of the pre-recorded asset a broadcast player dereferences client-side. Must be http(s). null clears it.Optional provenance pointer back to the unified recording behind
source_url (up to 128 chars). null clears it.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.ISO-8601 wall-clock time the playout begins.
null clears it.When
true, playout restarts from 0 instead of ending once duration_seconds elapses.Whether live chat is layered over the broadcast.
Whether live Q&A is layered over the broadcast.
auth-write bucket).
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).
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).
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.auth-read bucket).
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.
URL-safe room name (3–200 chars,
[a-zA-Z0-9_-]+). Tenant prefix is prepended internally; display_name echoes the un-prefixed name.Hard cap on concurrent participants (2–300).
Arbitrary key-value pairs forwarded to Orbit Media.
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.
Room name to resume from. Returned in the previous page’s
pagination.next_cursor.Results per page (max 200).
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.
Room name (with or without the
tenant_<id>_ prefix).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.
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.
Friendly participant name (1–200 chars). Also used as the Orbit Media identity.
Arbitrary key-value pairs attached to the Orbit Media participant.
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 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).
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.
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).
Label shown in the template picker (1–120 characters). Unique within the tenant (case-insensitive).
Optional operator description (up to 500 characters).
The reusable, non-secret room-config preset (see the knob list above). May be an empty object for an “all defaults” template.
auth-write bucket).
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.
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.
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. Owner/admin/developer only.
Label for the new room (1–200 characters). Per-instance — it does not change the template.
Optional ISO 8601 date-time to schedule the room for. Omit or send
null to create it live now.Consent-receipt id threaded into the recording-consent gate when the template auto-starts a recording in a two-party-consent jurisdiction.
auth-write bucket).
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).
Start of the window, ISO 8601 (e.g.
2026-06-01T00:00:00Z). Defaults to 30 days before to.End of the window, ISO 8601. Defaults to now. Must be later than
from or the request returns 400 INVALID_WINDOW.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.auth-read bucket).
window— the resolvedfrom/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, ornullwhen 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 withdate(YYYY-MM-DD),sessions,room_minutes,avg_peak_participants, andmax_peak_participants.
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 toPOST /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.