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 onPOST /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:
- Drop
identityfrom the body — the mint defaults it to the authenticated caller, and the common self-join flow ships unchanged. - 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:
- Lower the room — send
max_participants≤ the returnedcap, 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. - Raise the ceiling — when a genuinely larger room is needed, open a
support ticket (see below) with the
requested/cap/tiertriple fromdetails; that triple is exactly what support needs to evaluate an override on your organization.
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 futurescheduled_atdoes 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.
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 onPOST /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 onPOST /:id/waiting-room/admit/:identity. Retry the admit. There is a per-participant subtlety: if the identity you sent never landed in the lobby (typoedidentity, or the guest re-joined through the lobby again), clean the roster with a fresh fetch of the room rather than re-admitting.
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:
- Fetch the agent and read its
status. If it is anything butactive, resume or activate it from the dashboard or the agents API, then dispatch once. 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.
video:write only — a 401/403 there is a
credentials problem, not the 409 gate.
The agent state map
The agent’sstatus 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.
Related errors on the same pipeline
These surface from the same room while an assignment is live, but they are distinct from the dispatch gate:VIDEO_PARTICIPANT_KICK_FAILED— theremoveParticipantmedia 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— themutePublishedTrackmedia call failed while muting a participant’s track. Same handling.VIDEO_RECORDING_STOP_BACKEND_FAILED— thestopEgresscall 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:- Room id and, when you have it, the
room_sidfrom the join response. - Agent id and the agent’s
statusat the time of dispatch (GETthe agent alongside the refusal). - Error code verbatim —
AGENT_NOT_DEPLOYABLE, or the participant/recording code that followed it. request_id— themeta.request_idon the response envelope.- 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 yourrtmp_url/stream_keypair 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.
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:- Room id — from the create/join response or any
video.*webhook, plus theroom_sidoff the join response when you have one. - Error code verbatim —
error.codefrom the response body. request_id— themeta.request_idon the response envelope.- Tenant id (your organization id — Settings → Organization).
- Timestamp in UTC — when the refusal hit, so support can pull the exact window.
- For dispatch failures: the agent id and the agent’s status at
the time of dispatch (
GETthe agent alongside the refusal).
See also
- Video channel: rooms, embeds, recording, and broadcast — join-token grants and tiers, the lobby, capacity limits, egress, and the errors table this page works from.
- Error codes reference — the full video-room and waiting-room code families.
- Agents API reference — Deploy Agent and Undeploy Agent, the re-arm endpoints the state map above works.
- Video API reference — the request/response contract for rooms, join, waiting-room, dispatch-agent, and egress.
- Troubleshooting: video room and call quality — the quality sibling for ICE, frozen tiles, echo, degraded-recording QC, and PSTN dial-out after a successful join.
- Glossary — grant tier, lobby, and SFU terms this page uses.