Waiting Lobby concept
An optional knock-and-admit flow. When a token carries joinPolicy.mode: 'ask', the joiner is held in a lobby and a moderator (any participant with canModerate) admits or denies the request. The default policy is 'direct' β valid token β in the room β and most tokens use that.
The flow is built on the same request/consent primitive as media moderation (requestPublish): a request object fires on the moderator, who calls .admit() or .deny(). The joiner side stays a single await VideoSDK.join() β the promise resolves on admit, rejects on deny / timeout / abort.
joinPolicy.mode: 'direct' and let your backend vet them at token issuance. A human can't review hundreds of requests.The joinPolicy claim
A token claim, sibling of grant. Quick reference here; full claim context lives on Authentication β Token claims.
| Field | Type | Description |
|---|---|---|
mode | 'direct' | 'ask' | 'direct' (default) bypasses the lobby. 'ask' requires a moderator to admit before the joiner enters. |
ttl | number (seconds) | Only valid with mode: 'ask'. How long the joiner waits before the server times them out. Default 60; clamped to 10 β€ ttl β€ 600. Setting ttl with mode: 'direct' is rejected as INVALID_ENTRY_CLAIM. |
mode: 'ask' + canModerate is rejected. A moderator can't knock on their own door β and they're the one who'd answer it. The token-mint helper throws INVALID_ENTRY_CLAIM before signing the JWT, so the bad token never reaches the client. The SFU revalidates as a safety net for hand-rolled JWTs.The joiner flow
A single await VideoSDK.join() covers both policies. With 'direct' it resolves on connect; with 'ask' it stays pending until a moderator admits, then resolves the same way. The two opt-in extras are the onWaiting callback (to render a lobby screen) and an AbortSignal (to power an in-app Cancel button).
| Option | Type | Behavior |
|---|---|---|
onWaiting | () => void | Fired exactly once when the server places this joiner in the lobby. Use it to render the waiting UI. Never fires for mode: 'direct' tokens (or 'ask' tokens that get admitted instantly). |
signal | AbortSignal | If aborted while waiting, the server cancels the entry and join() rejects with AbortError. For an in-app "Cancel" button β not needed for tab close (the SDK's socket disconnect handles that automatically). |
Resolution β connected Room. Rejection (under ErrorKind.Auth):
| Code | Cause |
|---|---|
ENTRY_DENIED | A moderator clicked Deny. |
ENTRY_TIMEOUT | No moderator responded within ttl seconds. |
ENTRY_RATE_LIMITED | Same token tried to knock too many times (default: 3 attempts per 5 minutes per token). |
AbortError | The provided signal was aborted. |
The moderator flow
A participant with canModerate is already in the room when they handle entry requests. They consume the same EntryRequest object two ways: via the entry-requested event (push) and via room.entryRequests (pull / snapshot for list rendering).
Event β entry-requested
Fires once for every request a moderator can act on, including requests that pre-dated their join. A late-joining moderator gets the backlog replayed on subscribe β one handler covers both fresh and pending requests. Only delivered to participants with canModerate; without it, the listener never fires.
Event β entry-resolved
A request was acted on. Use this to drop a row from your "pending" list β useful with multiple moderators (first responder wins; the other moderator's UI cleans up). by is the participantId of the moderator who admitted / denied.
Event β entry-canceled
A request went away without a moderator decision. reason is one of:
'aborted'β joiner calledsignal.abort()(e.g., Cancel button).'disconnected'β joiner's socket dropped (tab closed, crashed, network loss past the grace window).'timeout'β the server'sttlfired before any moderator decided.
Getter β room.entryRequests
Current snapshot of pending requests. The event covers most flows; the snapshot is for declarative renders (e.g., a React list bound to length / contents). The same EntryRequest objects appear both here and in the event β .admit() / .deny() work either way.
EntryRequest interface
The single object that represents one lobby entry β read its fields to render, call its methods to act.
Calling .admit() / .deny() without canModerate rejects with INVALID_PERMISSIONS. Acting on a request that has already been resolved (by another moderator) is a no-op β the second call resolves without effect.
When to use the lobby β and when not
The lobby scales with the number of moderators, not the number of joiners. Pick the right tool by scenario:
Use the lobby ('ask') for | Skip the lobby ('direct') for |
|---|---|
|
|
Examples
1 Β· Telehealth β doctor admits a patient
The canonical flow. The doctor is in the room with canModerate and 'direct' entry; the patient knocks.
// Doctor β moderator, walks in directly
const doctorToken = generateToken({
roomId: 'consult-42',
participantId: 'dr-smith',
grant: {
canPublish: true, canSubscribe: true,
canPublishData: true, canSubscribeData: true,
canModerate: true,
},
joinPolicy: { mode: 'direct' },
});
// Patient β must knock; the doctor may still be in the prior consult
const patientToken = generateToken({
roomId: 'consult-42',
participantId: 'patient-9',
grant: {
canPublish: true, canSubscribe: true,
canPublishData: true, canSubscribeData: true,
},
joinPolicy: { mode: 'ask', ttl: 120 },
});
try {
const room = await VideoSDK.join({
token: patientToken,
onWaiting: () => showLobbyScreen('Waiting for the doctorβ¦'),
});
showCallScreen(room);
} catch (err) {
if (err.code === 'ENTRY_DENIED') showDeniedScreen();
if (err.code === 'ENTRY_TIMEOUT') showTimeoutScreen();
}
const room = await VideoSDK.join({ token: doctorToken });
room.on('entry-requested', (req) => {
// req: { participantId, name, metadata, requestedAt, expiresAt }
ui.showAdmitRow(req); // your UI renders "Patient 9 wants to join β Admit / Deny"
});
ui.onAdmitClick = (req) => req.admit();
ui.onDenyClick = (req) => req.deny();
2 Β· Webinar green room β mixed policies per token
Three different tokens for one room. Panelists knock to get on stage; the 1,000-person audience walks in directly. The audience token drops participantId so one mint covers every viewer.
// Host β direct + canModerate
const hostToken = generateToken({
roomId: 'webinar-2026-05',
participantId: 'host-1',
grant: { /* β¦full grantβ¦ */ canModerate: true },
joinPolicy: { mode: 'direct' },
});
// Panelist β knocks; host admits to the stage
const panelistToken = generateToken({
roomId: 'webinar-2026-05',
participantId: 'panelist-jane',
grant: {
canPublish: true, canSubscribe: true,
canPublishData: true, canSubscribeData: true,
},
joinPolicy: { mode: 'ask', ttl: 300 }, // 5 min β host might be greeting
});
// Audience β single reusable token; no participantId, SDK generates one per joiner
const audienceToken = generateToken({
roomId: 'webinar-2026-05',
isViewer: true,
grant: { canSubscribe: true, canSubscribeData: true },
joinPolicy: { mode: 'direct' },
});
// One mint. Distribute to all 1,000 viewers. Each session gets a fresh participantId.
'direct'".3 Β· Joiner cancels β an in-app Cancel button
An AbortSignal for an explicit "Cancel" button in the lobby UI. The doctor's queue stays clean.
const controller = new AbortController();
ui.onCancelClick = () => controller.abort();
try {
const room = await VideoSDK.join({
token: patientToken,
onWaiting: showLobbyScreen,
signal: controller.signal,
});
} catch (err) {
if (err.name === 'AbortError') { /* user clicked Cancel β show home screen */ }
}
entry-canceled with reason: 'disconnected'. You only write the AbortSignal path for an explicit Cancel button.4 Β· Late-joining moderator catches the backlog
Three patients knocked while no doctor was online. The doctor joins; the entry-requested event replays for each pending request β one handler covers backlog and live.
const room = await VideoSDK.join({ token: doctorToken });
room.on('entry-requested', (req) => ui.showAdmitRow(req));
// β fires 3 times immediately (3 patients already waiting)
// β keeps firing as new patients knock
// Optional: read the queue snapshot directly β useful for a count
console.log(`${room.entryRequests.length} waiting`);
5 Β· Mint-time validation β the primary error path
The backend accidentally sets 'ask' on a moderator token. The helper throws before signing; no JWT is ever created.
try {
const token = generateToken({
roomId: 'consult-42',
grant: { canModerate: true },
joinPolicy: { mode: 'ask', ttl: 60 }, // β conflict
});
} catch (err) {
// err.code === 'INVALID_ENTRY_CLAIM'
// err.message: "joinPolicy.mode='ask' is incompatible with grant.canModerate=true
// β moderators can't knock on their own door."
}
Other combinations rejected with the same code:
{ mode: 'direct', ttl: 60 }β no wait to time out.{ mode: 'ask', ttl: 5 }β below the 10s floor.{ mode: 'ask', ttl: 1200 }β above the 600s ceiling.
jsonwebtoken directly with the secret) and bypasses the helper, the server rejects on VideoSDK.join() with INVALID_ENTRY_CLAIM. Mint-time is the primary path; server-time is the safety net.6 Β· Retry after timeout β rate-limited
If the patient times out, they may try again β but the server rate-limits to 3 attempts per 5 minutes per token. The 4th attempt rejects with ENTRY_RATE_LIMITED.
async function joinWithRetry(token) {
for (let attempt = 0; attempt < 5; attempt++) {
try {
return await VideoSDK.join({ token, onWaiting: showLobbyScreen });
} catch (err) {
if (err.code === 'ENTRY_TIMEOUT') continue; // try again
if (err.code === 'ENTRY_RATE_LIMITED') {
showMessage('Too many attempts. Please contact the host.');
break;
}
throw err; // ENTRY_DENIED, AbortError, etc.
}
}
}
Errors
All lobby errors surface on VideoSDK.join() as a rejected Promise with kind === ErrorKind.Auth. Catch the class with err.kind === ErrorKind.Auth; branch on err.code only if you need to render distinct UI per cause.
| Code | When |
|---|---|
ENTRY_DENIED | Moderator clicked Deny on the request. |
ENTRY_TIMEOUT | Server ttl fired before any moderator decided. |
ENTRY_RATE_LIMITED | Too many retries on the same token (default 3 / 5 min). |
INVALID_ENTRY_CLAIM | Token has a nonsensical joinPolicy combination β 'ask' + canModerate, ttl without 'ask', or ttl outside the 10..600 range. Thrown at mint time by the backend helper; server re-validates as a safety net. |
AbortError | The AbortSignal passed to join() was aborted by your code. |
See also: Authentication β Token claims VideoSDK.join() JoinOptions Room β Events Errors β VideoSDK.join()