Skip to main content
Daily’s knock-to-join feature lets you gate entry to a room. Participants who arrive are held in a waiting state until an owner explicitly admits them. This guide covers room configuration, the waiting participant flow, and the admin flow.

Room configuration

Knock-to-join requires two properties on the room:
POST /v1/rooms
{
  "properties": {
    "privacy": "private",
    "enable_knocking": true
  }
}
Any participant who joins with a meeting token — no matter their permissions — bypasses the lobby and enters the call directly. Only tokenless participants are placed in the lobby.

Using Prebuilt

If you’re using Daily Prebuilt, the lobby UI is built in. Once the room is configured above, also set enable_prejoin_ui on the room, domain, or meeting token and you’re done — Daily handles the waiting room experience for you. The rest of this guide covers building a custom lobby UI with the call object API.

Waiting participant flow

1

Join the room

Call join() without a token. The participant lands in the lobby and accessState() returns { access: { level: 'lobby' } } — they are connected but hidden from other participants.
await call.join({ url: 'https://your-domain.daily.co/your-room' });
// accessState() === { access: { level: 'lobby' } }
2

Request access

Call requestAccess() with a display name. This notifies admins and adds the participant to the waiting list. The returned Promise blocks until the request is decided — you can use the return value directly:
const { granted } = await call.requestAccess({ name: 'Alice' });

if (granted) {
  showCallUI();
} else {
  // Access denied — participant has been ejected from the call
  showRejectedUI();
}
3

Alternatively, use events

If you need to react to access changes in multiple places (e.g. updating different parts of your UI), the event-based approach may be a better fit. access-state-updated fires when access is granted; a fatal error event with error.type === 'not-allowed' fires when access is denied.
call.on('access-state-updated', ({ access }) => {
  if (access.level === 'full') {
    showCallUI();
  }
});

call.on('error', ({ error }) => {
  if (error.type === 'not-allowed') {
    // Access was denied — participant has been removed from the call
    showRejectedUI();
  }
});

await call.requestAccess({ name: 'Alice' });

Admin flow

1

Join with admin permissions

Join with a meeting token that grants canAdmin: ['participants'] (or is_owner: true). Either bypasses the lobby and enters the call directly.
await call.join({
  url: 'https://your-domain.daily.co/your-room',
  token: 'ADMIN_TOKEN', // token with canAdmin: ['participants'] or is_owner: true
});
See meeting token permissions for how to create a token with canAdmin.
2

Handle participants already waiting

Call waitingParticipants() immediately after joining to catch anyone who arrived before you.
for (const [id, p] of Object.entries(call.waitingParticipants())) {
  showAdmitPrompt(p.id, p.name);
}
Each waiting participant is a DailyWaitingParticipant:
interface DailyWaitingParticipant {
  id: string;    // unique ID for this waiting session
  name: string;  // display name passed to requestAccess()
  awaitingAccess: { level: 'full' };
}
3

Listen for new arrivals

Subscribe to lobby events to keep your UI in sync:
EventFires when
waiting-participant-addedA participant calls requestAccess()
waiting-participant-updatedA waiting participant calls requestAccess() again with a new name
waiting-participant-removedA participant is admitted, denied, or leaves on their own
call.on('waiting-participant-added', ({ participant }) => {
  showAdmitPrompt(participant.id, participant.name);
});

call.on('waiting-participant-removed', ({ participant }) => {
  removeAdmitPrompt(participant.id);
});
4

Admit or deny participants

Use updateWaitingParticipant() to act on one participant, or updateWaitingParticipants() to act on many at once.
// Admit one
await call.updateWaitingParticipant(id, { grantRequestedAccess: true });

// Deny one
await call.updateWaitingParticipant(id, { grantRequestedAccess: false });

// Admit all but one currently waiting
await call.updateWaitingParticipants([
  id: { grantRequestedAccess: false },
  '*': { grantRequestedAccess: true }
]);

Complete example

Open https://localhost?t=YOUR_ADMIN_TOKEN for the admin view, or https://localhost (no token) for the waiting participant view.
const ROOM_URL = 'https://your-domain.daily.co/your-room';

// Pass an admin token via ?t=... to join as admin; omit for waiting participant
const token = new URLSearchParams(window.location.search).get('t') ?? undefined;
const isAdmin = Boolean(token);

const call = Daily.createCallObject();

if (isAdmin) {
  // --- Admin side ---
  document.getElementById('admin-view').hidden = false;
  document.getElementById('admin-status').textContent = 'Joining…';

  call.on('waiting-participant-added', ({ participant }) => {
    document.getElementById('no-waiters').hidden = true;

    const notice = document.createElement('div');
    notice.id = `waiting-${participant.id}`;
    notice.className = 'notice';
    notice.innerHTML = `
      <span>${participant.name || 'Someone'} wants to join</span>
      <button data-id="${participant.id}" data-action="admit">Admit</button>
      <button data-id="${participant.id}" data-action="deny">Deny</button>
    `;
    document.getElementById('lobby-notices').appendChild(notice);
  });

  call.on('waiting-participant-removed', ({ participant }) => {
    document.getElementById(`waiting-${participant.id}`)?.remove();
    if (Object.keys(call.waitingParticipants()).length === 0) {
      document.getElementById('no-waiters').hidden = false;
    }
  });

  document.getElementById('lobby-notices').addEventListener('click', async (e) => {
    const btn = e.target.closest('button');
    if (!btn) return;
    const { id, action } = btn.dataset;
    await call.updateWaitingParticipant(id, {
      grantRequestedAccess: action === 'admit',
    });
  });

  await call.join({ url: ROOM_URL, token });
  document.getElementById('admin-status').textContent = "You're in the call.";

  // Handle anyone already waiting when the admin joins
  const alreadyWaiting = call.waitingParticipants();
  if (Object.keys(alreadyWaiting).length === 0) {
    document.getElementById('no-waiters').hidden = false;
  }
  for (const p of Object.values(alreadyWaiting)) {
    call.emit('waiting-participant-added', { participant: p });
  }
} else {
  // --- Waiting participant side ---
  document.getElementById('waiting-view').hidden = false;
  document.getElementById('waiting-status').textContent = 'Joining…';

  call.on('access-state-updated', ({ access }) => {
    if (access.level === 'full') {
      document.getElementById('waiting-view').hidden = true;
      document.getElementById('call-view').hidden = false;
    }
  });

  call.on('error', ({ error }) => {
    if (error.type === 'not-allowed') {
      document.getElementById('waiting-view').hidden = true;
      document.getElementById('rejected-view').hidden = false;
    }
  });

  await call.join({ url: ROOM_URL });
  // accessState() === { access: { level: 'lobby' } }

  const name = prompt('Your name') ?? 'Guest';
  document.getElementById('waiting-status').textContent =
    `Waiting to be admitted, ${name}…`;

  await call.requestAccess({ name });
  // accessState() === { access: { level: 'lobby' }, awaitingAccess: { level: 'full' } }
}