A telehealth waiting room looks like a UI problem. The patient clicks the link a few minutes early, sees “Your provider will be with you shortly,” and waits. When the clinician is ready, the patient is let in.

Underneath, it is an access control problem. The question that matters is not what the patient sees while waiting. It is what the patient’s browser is holding while waiting. If it already has a valid credential for the media room, the waiting room is a curtain, not a door.

This post walks through how to build a virtual waiting room that is a door: the patient gets no media credentials until a clinician admits them. We’ll use the join flow in CoveKit as the worked example, but the pattern applies to any WebRTC stack.

Two ways to build a telehealth waiting room

Most tutorials take one of two approaches.

Presence-based. Everyone gets a token for the same room up front. The client checks who else is connected and shows a waiting screen until the provider appears. Twilio’s telemedicine waiting room tutorial works this way: the patient’s browser fetches an access token for the appointment room, connects, and hides the waiting screen once a participant with the provider’s identity shows up. The tutorial is upfront that it simplifies things for teaching, and it’s a fine way to learn the SDK.

Lobby-and-move. Everyone joins a shared session, and the host moves people into a private sub-room. Zoom’s Video SDK waiting room guide uses subsessions this way: the main session is the lobby, and the host creates a subsession, assigns the doctor and patient, and opens it.

Both are valid. Both also mean the patient is already authenticated to the media layer before anyone has said yes. In the presence-based version, a modified client can simply ignore the waiting screen and render whatever arrives.

A third approach puts the gate on the server: don’t issue the media token until the host admits the participant. The waiting state lives in your control plane’s database, not in the client.

The token-gated flow

Here is the shape of it:

  1. Your backend creates a meeting with host approval turned on.
  2. The patient calls a join endpoint. The server records a session in a waiting state and returns a session ID. No join token, no ICE servers.
  3. The patient’s client polls a status endpoint with that session ID.
  4. The clinician sees the waiting list and admits (or denies) the patient.
  5. The next poll returns a short-lived join token and the ICE server list. The client connects to the media server.

Until step 5, the patient holds nothing that the media server will accept. There is no room to sneak into, and there are no TURN credentials to borrow.

Step 1: Create the meeting from your backend

In CoveKit, host approval is a property of the meeting. Create it server-side with your project API key, so the key never reaches a browser:

const res = await fetch(`${COVEKIT_API_URL}/v1/meetings`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-API-Key": process.env.COVEKIT_API_KEY!,
  },
  body: JSON.stringify({
    title: "Follow-up visit",
    join_mode: "host_approval",
    max_participants: 3, // clinician, patient, one caregiver
    starts_at: "2026-11-03T15:00:00Z",
    ends_at: "2026-11-03T15:45:00Z",
    metadata: { appointment_id: "apt_8841" },
  }),
});

const meeting = await res.json();
// meeting.public_id  -> "mtg_..."; give this (or join_url) to the patient
// meeting.host_url   -> contains a host token; give this only to the clinician

A few details are worth knowing:

  • join_mode accepts open or host_approval. With host_approval, every participant who joins without a host token starts in the waiting room.
  • For a non-reusable meeting with a starts_at, the join endpoint rejects anyone who arrives more than 15 minutes early, with a message that says how long to wait. That keeps the waiting room from filling up hours ahead.
  • max_participants is enforced at join time against currently connected participants (the API allows up to 50).
  • Set ends_at. The host link’s token expires at ends_at. If you leave it out, the host link stays valid for a year.

That last point matters more than it looks. The host token travels in a URL query string, and URLs end up in browser history, proxy logs, and referrer headers. OWASP lists this as information exposure through query strings. Treat host_url like a password, deliver it only to the clinician’s authenticated view, and keep its lifetime close to the appointment.

Step 2 and 3: Join, then wait

On the patient side, the SDK’s joinPublicMeeting returns either a token or a waiting status. If the patient is waiting, poll pollSessionStatus until the answer changes:

import { CoveKitClient, CoveKitRoom } from "covekit";

const client = new CoveKitClient();

async function enterVisit(publicId: string, name: string) {
  let session = await client.joinPublicMeeting(publicId, name);

  while (session.status === "waiting") {
    showWaitingScreen();
    await new Promise((r) => setTimeout(r, 3000));
    // Throws if the clinician denied entry or the session ended
    session = await client.pollSessionStatus(publicId, session.session_id);
  }

  const room = new CoveKitRoom(client, {
    onConnected: () => room.publishLocalMedia(true, true),
    onRemoteTrack: (track, peerId, kind) => attachTrack(track, peerId, kind),
    onError: (err) => showError(err.message),
  });

  await room.join(session.token, session.ice_servers);
}

While the status is waiting, the response has no token and no ice_servers field at all. The server only signs a join token once the session row says joined. If the clinician denies the patient, the status endpoint returns an error instead of a token, so the loop exits through the catch path in your UI.

The CoveKit meeting app polls every 3 seconds. That’s a reasonable default for a visit: fast enough that admission feels immediate, slow enough that a room full of waiting patients doesn’t hammer your API. Polling also survives the patient’s laptop sleeping or their tab being backgrounded better than a long-lived socket does, because each request stands on its own.

Step 4: Admit from the clinician’s side

The clinician needs a list and two buttons. If the clinician uses the CoveKit meeting app via host_url, that UI is built in. If you are building your own provider dashboard, you have two options.

From a browser that holds the host token:

const waiting = await client.getWaitingRoom(publicId, hostToken);
// [{ session_id, display_name, joined_at }, ...] oldest first

await client.admitParticipant(publicId, waiting[0].session_id, hostToken);
// or: await client.denyParticipant(publicId, sessionId, hostToken);

Or from your backend with the project API key, using the meeting’s internal id rather than its public ID:

GET  /v1/meetings/{id}/waiting-room
POST /v1/meetings/{id}/waiting-room/{session_id}/admit
POST /v1/meetings/{id}/waiting-room/{session_id}/deny
X-API-Key: <your project key>

The backend route is the better fit when “admit” is tied to your own logic: the clinician clicked “Start visit” in your EHR integration, or intake forms are complete. Your scheduling system stays the source of truth, and the clinician never handles a host link at all.

Why short-lived join tokens are enough

Once admitted, the patient receives a join token that expires 15 minutes after it is issued. It is a signed JWT whose exp claim (defined in RFC 7519) the media server checks during the WebSocket handshake.

Fifteen minutes sounds short for a 45-minute visit, but the token is only needed to open the signaling connection. After the handshake, the session is tied to that connection. If the patient drops and rejoins later, they go back through the join endpoint, which checks the meeting state again.

The same token carries the participant’s role. Host-only actions on the media server, such as removing a participant with kickParticipant(peerId) or granting screen share, are checked against the role in that token, not against anything the client claims.

Handling the patient who drops

Telehealth calls have a specific failure mode: the patient is on a phone, walks out of Wi-Fi range, and the connection dies without a clean close. You don’t want that person shown as “in the visit” for the next 40 minutes.

CoveKit handles this in two layers. The SDK sends a ping every 15 seconds, and the media server removes a peer it hasn’t heard from in 30 seconds, so the clinician’s view updates within about half a minute. Separately, the control plane runs a reaper that marks sessions as left when the media server has stopped reporting them for more than 90 seconds. That keeps session records and billed minutes accurate even if the media server itself goes down.

What this pattern doesn’t do

A token-gated waiting room controls who can join. It doesn’t make your product compliant with HIPAA or any other regulation on its own. Compliance depends on your agreements with vendors, where recordings and transcripts are stored, your audit logging, and how you handle patient identity before they ever reach the join page. Treat the waiting room as one control among several.

It also isn’t a queue. If one clinician sees patients back to back, you will want each appointment to be its own meeting, created by your scheduling system, rather than one long-running room with a line in front of it.

Checklist

  • Gate on the server. A waiting participant should hold no media token and no TURN credentials.
  • Create meetings from your backend with the API key. Use join_mode: "host_approval".
  • Always set ends_at so host links expire with the appointment.
  • Keep host tokens out of anything a patient can see, and out of logs where you can.
  • Poll at a few seconds, and handle “denied” as a normal outcome with a clear message.
  • One appointment, one meeting.

If you’re building a telemedicine product and would rather not build the waiting room, token signing, and TURN setup yourself, read the CoveKit docs to see how the join flow fits into your app.