Skip to main content

Troubleshooting: video guest chat refusals — VIDEO_CHAT_GUEST_FORBIDDEN, VIDEO_CHAT_LOBBY_HELD, and VIDEO_CHAT_LOBBY_CHECK_UNAVAILABLE

A guest chat refusal is different from a chat moderation block: the message never reached the room at all. The guest posted from the join page — no signed-in user, no tenant context — and the platform refused the send with a 403 or 503 before broadcasting. Every refusal on this route is deliberate and fail-closed: the platform rejects rather than deliver a message it cannot scope to a tenant, attribute to a sender, or screen against the room’s content policy. Nothing is silently re-queued — a refused message is gone, and the recovery is always an explicit re-send after the gate that fired is cleared. This page covers the three refusal surfaces on POST /api/v1/video/rooms/:id/chat for anonymous guests. A 422 VIDEO_CHAT_BLOCKED_BY_POLICY block — the message was scoped, screened, and rejected by the room’s content rules — is a different failure family covered on Video room features: chat, polls, Q&A, invites, RTMP.

What “send a chat message as a guest” means

A guest is a participant with no Orbit account session — they clicked an invite join link, landed on the guest-join page, and got a LiveKit session token for the room. When they post a chat message, the join page calls POST /api/v1/video/rooms/:id/chat with two tokens on the body:
  • join_token — the invite token from the join link. This is how the platform proves the caller was invited to this room (the room UUID alone is guessable, so a send without it is rejected outright).
  • livekit_token — the LiveKit access token the guest connected with. This is how the platform proves this specific guest is present in the room and has been admitted past the waiting room.
The route first tries to resolve a tenant from the caller’s session context (a signed-in sender carries one). For an anonymous guest there is no session, so it falls back to an invite-token lookup — and from that point forward the send is gated three times, each gate fail-closed.

The three gates, and why each fails closed

Gate 1 — tenant resolution: 403 VIDEO_CHAT_GUEST_FORBIDDEN

What fired: the caller carried no session tenant, and the invite-token lookup matched the send to no room record — or no join_token was sent at all. Why the gate exists: without a resolved tenant the platform cannot scope the message to your organization’s data, cannot attribute a sender, and cannot read the room’s content policy. Delivering anyway would post an unscreened, unscoped message into a live room — so the platform rejects instead, every time. Common causes:
  • The guest is chatting with a stale or hand-edited join link — the invite token on the page no longer matches a live invite for the room.
  • The invite was revoked or expired after the guest’s page loaded.
  • A custom integration posts the chat route directly but only sends the LiveKit token, not the join_token from the invite link — the route cannot scope a send from the room UUID alone.
Fix:
  1. Have the guest re-join from a fresh join link — reissue the invite (or ask the host to) so the page loads with a current invite token.
  2. If you call the route directly, send both join_token and livekit_token; the join token is not optional for anonymous sends.
  3. Do not retry the unchanged send — a token the lookup cannot match will never match on retry.

Gate 2 — lobby admission: 403 VIDEO_CHAT_LOBBY_HELD

What fired: the room has the waiting room armed, and this guest is still in the lobby — or isn’t connected to the room at all. The platform verified the guest’s livekit_token, looked up their live participant state on the media layer, and found them not yet admitted. Why the gate exists: a guest still in the waiting room has not been admitted into the meeting; letting them post into the admitted room’s chat would punch a hole in the lobby the host armed. The live permission on the media layer is the source of truth — an admit flips it in place without issuing a new token, so the guest’s token is checked but their current state is what counts. Common causes:
  • The guest joined while the lobby was armed and the host hasn’t admitted them yet — the expected state, not an error.
  • The guest connected, disconnected, and is re-posting from a cached page without a live session in the room.
Fix:
  1. Ask the host to admit the guest from the waiting room (POST /api/v1/video/rooms-scheduled/:id/waiting-room/admit/:identity, or the admit control in the dashboard lobby panel).
  2. The guest then re-sends the message — after admission the same livekit_token passes the gate, because the live permission moved, not the token.
  3. If the guest is no longer connected at all, have them re-join from the invite link first.
A stale-token variant of this refusal returns 403 VIDEO_CHAT_GUEST_FORBIDDEN at the same gate: the token no longer verifies, is scoped to a different room, or the request carried no livekit_token. The response message tells the guest to refresh and rejoin from the invite link — that is the fix, not a retry of the same tokens.

Gate 3 — the lobby check itself degraded: 503 VIDEO_CHAT_LOBBY_CHECK_UNAVAILABLE

What fired: the room has the waiting room armed and the platform could not run the lobby assertion — the media-layer lookup or tenancy resolution errored. The guest’s admission state is unknown. Why the gate fails closed: an unknown lobby state is treated as not-admitted, not as admitted. Confirming a guest is in the room is a security check; when the check cannot complete, the message is not delivered. This is deliberate — the safe side of an outage is a refused chat, not an unscreened one. Fix: retry the send with backoff — this refusal is transient-frequent (a media-node blip or a connection storm). If 503s persist for more than a few minutes, the media layer is degraded; escalate with the bundle below rather than continuing to loop.

The policy-read sibling: 503 VIDEO_CHAT_POLICY_UNAVAILABLE

One more fail-closed path rides the same route: after the sender clears both gates above, the platform reads the room’s chat content policy before broadcasting. If that policy read throws, the send is refused with 503 VIDEO_CHAT_POLICY_UNAVAILABLE — same never-deliver guarantee, same retry-with-backoff handling. A policy the platform cannot read cannot be honored, so the message is rejected rather than posted unscreened.

Worked example — a guest send refused, then recovered

The guest posts from the join page; the request the page issues is:
The room has the waiting room armed and the host has not admitted this guest yet. The response:
Recovery sequence:
  1. The host admits the guest (POST /api/v1/video/rooms-scheduled/9b1d2c3e-…/waiting-room/admit/guest-7f2a or the dashboard Admit button).
  2. The guest re-sends the same message — no new tokens needed; the admit changed their live permission.
  3. The second POST returns 200 and the message broadcasts into the room.
If the first refusal had instead been 403 VIDEO_CHAT_GUEST_FORBIDDEN on tenant resolution, step 1 is different: fetch a fresh join link (a new invite), rejoin from it, and only then re-post.

When to escalate — the support bundle

Work the sections first — most refusals are a stale invite or an un-admitted guest. When a 503 persists past a few minutes, or a guest refusal survives a fresh join link and a confirmed host admit, open a ticket with:
  1. request_id — from the meta.request_id on the refused response.
  2. Room id — the UUID in the route path.
  3. Error code verbatim — VIDEO_CHAT_GUEST_FORBIDDEN, VIDEO_CHAT_LOBBY_HELD, VIDEO_CHAT_LOBBY_CHECK_UNAVAILABLE, or VIDEO_CHAT_POLICY_UNAVAILABLE.
  4. Which resolution path the guest was on — an anonymous invite-token send (no signed-in account), or a signed-in sender. The gate behavior differs, and support triages them separately.
  5. Timestamp in UTC of the refusal, and whether 503s repeated with backoff.
  6. Lobby state — whether the room had the waiting room armed, and whether auto_promote was set (with auto_promote: true the first host entry lifts everyone; if no host had joined yet, a LOBBY_HELD refusal is the lobby working as configured).

See also