Skip to main content

Troubleshooting: video room lifecycle failures — joins, capacity, lobby, agent dispatch, and RTMP egress

A lifecycle failure is different from a bad room: the guest never got a token, the API refused the create with a 4xx, a scheduled room failed to open on first join, the lobby would not toggle or admit, the agent dispatch was rejected, or the RTMP push never started. These are refusals with an error code on the response — read the code, work the section for it, and the room either opens or tells you exactly what to change. Quality symptoms after everyone is in — frozen video, echo, a degraded recording verdict, a failed PSTN dial-out — live on Troubleshooting: video room and call quality. This page covers the operational lifecycle only: mint, open, capacity, lobby, dispatch, and egress.

Where the failure surfaced

The same refusal looks different depending on which surface it hit — find your surface first, then match the error code.

Join-token failures

Joining is a two-step wire: your back end mints a per-participant token on POST /api/v1/video/rooms-scheduled/:id/join, and the browser trades the token for the SFU websocket. Lifecycle failures live in step one — the mint — and four codes cover them.

VIDEO_ROOM_JOIN_FAILED (500)

Identity, grant, and tier checks all passed, but the mint itself failed downstream when the platform signed the access JWT or reached the media layer (Orbit Media). The request did not complete, so no token was issued and nothing joined. VIDEO_ROOM_JOIN_FAILED is transient-frequent: retry with backoff. Because every mint writes a fresh, bounded token — never a partially valid one — a retry leaves no state to clean up. Read uses before you loop: a default lifetime is 1 hour (override with ttl_seconds on the body), so a mint-and-cache client that retries a stale cached token is debugging its own cache, not the mint — mint fresh when expires_at approaches, replace the token in the client, then retry. If the 500 repeats within ten minutes of retries, stop looping and escalate with the request_id from the response meta — a persistent mint failure is platform-side.

IMPERSONATION_NOT_PERMITTED (403)

You sent an identity that is not your own user id, and the caller’s tenant role is not owner or admin. The identity gate exists because any caller who could mint under a teammate’s identity could join-as-them and publish under their name. The retry-safe shape is one of two, never a role workaround:
  1. Drop identity from the body — the mint defaults it to the authenticated caller, and the common self-join flow ships unchanged.
  2. Use an owner or admin key when you genuinely must mint-as-another (e.g. minting guest tokens server-side for your own users), and keep the impersonation_reason (below) — denied attempts write an audit row, so a loop of 403s from a developer-role key is an alertable abuse signal, not just a refusal.

IMPERSONATION_REASON_REQUIRED (400)

An owner or admin minted under another user’s identity but sent an empty or missing impersonation_reason. Supply a non-empty, non-whitespace reason string on the same request — it lands on the mint audit row so your compliance team can answer “who minted a token under Alice at 14:32” without correlating logs. Retrying with the reason is the fix; no room state was touched by the refusal.

PARTICIPANT_TIER_NOT_PERMITTED (403)

You requested participant_tier of host or hidden_supervisor with a caller role below owner/admin. Those tiers carry publish and (for host) room-admin grants, so a viewer or developer session cannot bootstrap them by posting the field. Fix by dropping the requested tier (let the service derive panelist/viewer from the room), or mint from an owner/admin key. A refused mint and a lobby downgrade are different things: when an armed waiting room holds a join, the response reports lobby_downgraded: true with receive-only permissions — that is the lobby doing its job, not a tier refusal. Always read the returned participant_tier and permissions, not the tier you asked for.

Capacity and open failures

VIDEO_PARTICIPANTS_CAP_EXCEEDED (400)

max_participants on create (or on a later patch) exceeded your tenant’s resolved cap — currently the 300-participant ceiling on the self-serve plan, before any operator override set on your own organization’s settings. The response error.details carries three fields that name the fix directly: requested (what you sent), cap (the resolved ceiling), and tier. Two tenant-owned paths, in order:
  1. Lower the room — send max_participants ≤ the returned cap, or omit it to inherit the resolved cap. Every default already inherits correctly, so this refusal only follows an explicit request that overshot the ceiling.
  2. Raise the ceiling — when a genuinely larger room is needed, open a support ticket (see below) with the requested/cap/tier triple from details; that triple is exactly what support needs to evaluate an override on your organization.
Don’t retry the unchanged body: the request is validated before any room state is written, so a fourth-hundred loop creates nothing.

VIDEO_ROOM_CREATE_FAILED / VIDEO_ROOM_OPEN_FAILED

Both mean a reachability or configuration failure on the media layer at the moment Orbit tried to stand the room up, with different timing:
  • VIDEO_ROOM_CREATE_FAILED — thrown during room creation, before any room record settles. A retry is a fresh create; the failed attempt left nothing behind.
  • VIDEO_ROOM_OPEN_FAILED — thrown at lazy-open: a scheduled room with a future scheduled_at does not open until the first host or participant actually joins. The failure then surfaces on the join request, not on the create. The room record is still valid — retry the join rather than recreating the scheduled room; a re-created room duplicates the record and inherits the same media condition.
Retry both with backoff; a refusal that survives ten minutes of retries is platform-side — escalate with request_id.

Waiting-room failures

Two codes cover the lobby, both transient-frequent and both safe to retry:
  • VIDEO_WAITING_ROOM_FAILED — thrown when toggling the lobby on POST /api/v1/video/rooms-scheduled/:id/waiting-room. Retry the toggle; the lobby state is left either on or off, never half-armed, so a retry never double-arms.
  • VIDEO_WAITING_ROOM_ADMIT_FAILED — thrown when admitting a pending participant on POST /:id/waiting-room/admit/:identity. Retry the admit. There is a per-participant subtlety: if the identity you sent never landed in the lobby (typoed identity, or the guest re-joined through the lobby again), clean the roster with a fresh fetch of the room rather than re-admitting.
If your flow holds participants open with auto_promote: true, remember the hold still applies to the join — only the first host entry lifts everyone. An admit error while no host has entered is the lobby working as configured; dispatch or join a host first.

AGENT_NOT_DEPLOYABLE on dispatch-agent

POST /api/v1/video/rooms/:id/dispatch-agent refuses with a 409 when the referenced agent is not in the active state — the platform will not put an agent that is not running into a live room as a participant. The refusal fires before any media call: no join was attempted, the room is untouched, and the agent was never a participant. A draft, paused, or archived agent all refuse the same way. Two checks in order:
  1. Fetch the agent and read its status. If it is anything but active, resume or activate it from the dashboard or the agents API, then dispatch once.
  2. AGENT_NOT_FOUND (404) is the sibling refusal — the agent id does not resolve in your tenant schema. Re-list your agents and take a fresh id; a tenant-id guess against another tenant’s agent is the common cause.
Dispatch is owner/admin plus video:write only — a 401/403 there is a credentials problem, not the 409 gate.

The agent state map

The agent’s status field moves through four states, and the 409 names which leg you are on: The common 409 sequence is: created the agent, never deployed it, then dispatched straight from a job or dialer flow. The dispatch endpoint never promotes the agent for you — activation is an explicit tenant step, so the workflow that dispatches must also own the deploy when the status read comes back draft. These surface from the same room while an assignment is live, but they are distinct from the dispatch gate:
  • VIDEO_PARTICIPANT_KICK_FAILED — the removeParticipant media call failed while kicking a participant. Transient-frequent; retry once after fetching fresh room state (the participant may already be out).
  • VIDEO_PARTICIPANT_MUTE_FAILED — the mutePublishedTrack media call failed while muting a participant’s track. Same handling.
  • VIDEO_RECORDING_STOP_BACKEND_FAILED — the stopEgress call rejected while stopping a room recording. Retry the stop once; a recording that keeps running bills room time, so escalate with the room id if a second stop fails.
  • RECORDING_SIGN_FAILED (503) — minting the signed playback URL for a recording failed at the storage layer. A retry after a brief backoff resolves most occurrences; a persistent one is platform-side.
VIDEO_WEBHOOK_VERIFICATION_FAILED is not tenant-actionable: it means an Orbit Media webhook reached Orbit with a JWT signature that could not be verified (clock skew, a mis-rotated secret, or forged traffic). Orbit monitors it platform-side — do not chase it in your own webhook handler; report it if it correlates with missing recording/lifecycle event deliveries.

Dispatch escalation bundle

When the refusal or the follow-on failures survive the section above, open a ticket with:
  1. Room id and, when you have it, the room_sid from the join response.
  2. Agent id and the agent’s status at the time of dispatch (GET the agent alongside the refusal).
  3. Error code verbatimAGENT_NOT_DEPLOYABLE, or the participant/recording code that followed it.
  4. request_id — the meta.request_id on the response envelope.
  5. Timestamp in UTC of the refusal.

RTMP egress errors

POST /api/v1/video/rooms-scheduled/:id/egress/rtmp surfaces two failure shapes, both actionable from the body you sent:
  • SERVICE_UNAVAILABLE (503) — live streaming was requested when Orbit Media is not configured on this deployment. No retry changes it; the egress control plane is absent, not failing. Escalate if you expected it to be configured.
  • Destination refusals — the response echoes URLs without keys (egress_id, rtmp_url, additional_rtmp_urls), so compare the outset of your rtmp_url/stream_key pair against the platform the stream targets. The most common refusal is a malformed destination — swap the URL and the key (rtmp:// scheme on the URL, secret-bearing stream key as the separate field), or a backup/additional destination carrying a primary’s URL.
Fan-out limits: additional destinations render once and fan out, so one simulcast to YouTube plus Twitch costs a single composite — but you still need to stop the fan-out with DELETE /:id/egress/rtmp/:egressId when the stream ends. A leaked egress keeps rendering an empty room to a live destination, which is a publish failure shape of its own.

When to escalate — the support bundle

Work the section first; when the same error code survives ten minutes of retries, open a ticket with:
  1. Room id — from the create/join response or any video.* webhook, plus the room_sid off the join response when you have one.
  2. Error code verbatimerror.code from the response body.
  3. request_id — the meta.request_id on the response envelope.
  4. Tenant id (your organization id — Settings → Organization).
  5. Timestamp in UTC — when the refusal hit, so support can pull the exact window.
  6. For dispatch failures: the agent id and the agent’s status at the time of dispatch (GET the agent alongside the refusal).

See also