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.

Sized for human trickle, not crowds. The lobby is for telehealth, 1:1 consults, small classes, and webinar green rooms β€” anywhere a moderator clicks a button per joiner. Large audiences (webinars, ILS) should use 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.

type JoinPolicy = | { mode: 'direct' } // default β€” valid token β†’ straight in | { mode: 'ask'; ttl?: number }; // lobby β€” moderator admits; ttl in seconds (10..600, default 60)
FieldTypeDescription
mode'direct' | 'ask''direct' (default) bypasses the lobby. 'ask' requires a moderator to admit before the joiner enters.
ttlnumber (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).

interface JoinOptions { token: string; roomId?: string; name?: string; // === Waiting-lobby-specific (both optional) === onWaiting?: () => void; // fires once if the joiner enters the lobby signal?: AbortSignal; // abort() β†’ the join promise rejects with AbortError }
OptionTypeBehavior
onWaiting() => voidFired 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).
signalAbortSignalIf 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):

CodeCause
ENTRY_DENIEDA moderator clicked Deny.
ENTRY_TIMEOUTNo moderator responded within ttl seconds.
ENTRY_RATE_LIMITEDSame token tried to knock too many times (default: 3 attempts per 5 minutes per token).
AbortErrorThe 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

room.on('entry-requested', (req: EntryRequest) => void)

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

room.on('entry-resolved', ({ participantId, by }) => void)

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

room.on('entry-canceled', ({ participantId, reason }) => void)

A request went away without a moderator decision. reason is one of:

Getter β€” room.entryRequests

room.entryRequests: EntryRequest[]

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.

interface EntryRequest { // === Identity === participantId: string; // stable id from the joiner's token (or SDK-generated) name?: string; // from JoinOptions.name on the joiner metadata?: unknown; // optional app-defined payload (TBD shape) // === Lifecycle === requestedAt: Date; // when the knock arrived at the server expiresAt: Date; // when the server will auto-timeout (requestedAt + ttl) // === Actions β€” gated by canModerate === admit(): Promise<void>; // resolves on server ack; joiner's join() resolves to the Room deny(): Promise<void>; // resolves on server ack; joiner's join() rejects with ENTRY_DENIED }

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') forSkip the lobby ('direct') for
  • Telehealth β€” sequential 1:1, doctor admits each patient.
  • Interviews / consults / sales / tutoring.
  • Small classrooms.
  • Internal meetings with external guests (team direct, guests knock).
  • Webinar green rooms β€” the panelists knock, not the audience.
  • Large webinars / town halls / ILS audiences.
  • Any scenario where joiners outnumber moderators by more than a handful.
  • Anywhere your backend already vets the user before minting (the token is the approval).

Examples

1 Β· Telehealth β€” doctor admits a patient

The canonical flow. The doctor is in the room with canModerate and 'direct' entry; the patient knocks.

Example β€” backend: mint the two tokens
// 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 },
});
Example β€” client: the patient joins
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();
}
Example β€” client: the doctor admits
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.

Example β€” three tokens, three entry profiles
// 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.
The audience never floods the lobby. The whole point of this split: the host handles a trickle of panelist knocks, and the thousand-strong audience walks in unimpeded. If everyone had to knock, the moderator UI would drown β€” and that's why "large room β†’ '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.

Example β€” joiner-initiated cancel via AbortSignal
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 */ }
}
Tab close needs no code. If the user closes the tab, navigates away, or loses network past the grace window, the SDK's socket disconnects, the server cancels the entry automatically, and moderators see 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.

Example β€” single handler covers pending + 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.

Example β€” INVALID_ENTRY_CLAIM at mint time
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:

The SFU re-validates on join. If a customer hand-rolls a JWT (using 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.

Example β€” bounded retry loop
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.

CodeWhen
ENTRY_DENIEDModerator clicked Deny on the request.
ENTRY_TIMEOUTServer ttl fired before any moderator decided.
ENTRY_RATE_LIMITEDToo many retries on the same token (default 3 / 5 min).
INVALID_ENTRY_CLAIMToken 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.
AbortErrorThe AbortSignal passed to join() was aborted by your code.

See also: Authentication β†’ Token claims VideoSDK.join() JoinOptions Room β†’ Events Errors β†’ VideoSDK.join()