Zoom Skip Waiting Room

Allow a bot to skip the waiting room by having the host pre-authorize the bot using a Zoom Join Token for Local Recording

📘

Contact support to use this feature

In order to use this feature, please contact support in order to enable using custom SDK credentials for your workspace.

📘

You can use this sample app to see how to create a bot that skips the Zoom waiting room.

Zoom lets a bot skip the waiting room without the host having to manually approve it each time through a join token for local recording (referred to as a "join token"). This token allows the host to grant the bot permission to skip the waiting room ahead of time for all of that host's meetings.

🚧

This flow requires the bot to join a call using an OBF or ZAK token

This flow uses the Zoom Meeting SDK, so you still need a Zoom OBF token or a Zoom ZAK token in addition to the join token callback.

It also requires you to (re)submit a Zoom app and go through the Zoom app review process.

A couple of details about join tokens upfront:

  • Join tokens are short-lived and single-use, so you should mint them just-in-time when Recall requests them (not ahead of time). The sample app does this in GET /zoom/join-token.
  • Join tokens are meeting-scoped, so your callback needs the correct meeting_id for each bot launch. The sample app expects meeting_id as a query parameter.
  • The join token must be generated using the host or host organization's OAuth credentials. Your app needs to maintain the mapping between meeting IDs and the corresponding host/organization OAuth credentials to determine which credentials to use when generating the join token.

How to create a join token?

Step 1: Setup Zoom App

First things first, you'll need to create a Zoom app to get SDK credentials from Zoom.

You'll want to create a "General app"

After that, you'll want to navigate over the "Scopes" page and add the following scopes (in addition to the scopes you need for the OBF or ZAK token):

meeting:read:local_recording_token 

Please note down the Zoom SDK Client ID and Secret.

Also, head down to the "Embed" section and be sure to enable the "Meeting SDK" feature

Step 2: Add Zoom SDK credentials to Recall

In the Recall dashboard, go to:

  • Meeting Bot SetupZoom

Paste your Zoom app’s Client ID and Client Secret there (same as the OBF flow). The sample README calls this out as part of setup.

Step 3: Setup a callback app

This next part is a bit more custom to your setup. Recall fetches your join token by calling a public HTTP endpoint you host. The details of exactly how your app are hosted are entirely up to you, but essentially the goal is to have a single API endpoint that returns a plaintext OBF token.

We have an example repo that you can setup here. This app will need to be publicly accessible in order for Recall's API to fetch the tokens it needs. For development purposes, ngrok can be helpful for making the locally running server accessible from the internet.

You'll want to store a mapping between each user of your app (or session) and the OAuth tokens you’ll use to mint join tokens. This is to ensure that the bot is associated with the right user when launching for a particular meeting. In this example, we're saving the refresh token to a local file but you should store the following mappings in your database in production:

  • OAuth user <> refresh token
  • OAuth user <> meeting ID

Let's build our own app that generates join tokens for use in bots. To start, we'll need a simple server with the following endpoints:

  • GET /zoom/oauth - generates a Zoom OAuth URL and redirects the user to that URL to authorize your Zoom app
  • GET /zoom/oauth/callback - upon successful authorization, Zoom will redirect the user here with the authorization code for you to trade for the refresh token
  • GET /zoom/join-token - the bot will call this endpoint to retrieve a join token upon joining
  • GET /zoom/obf-token - the bot requires an OBF (or ZAK) token to join the call so the bot will query this endpoint to retrieve an OBF token upon joining
import http from "http";
import dotenv from "dotenv";
import z from "zod";
import { zoom_join_token } from "./api/zoom_join_token";
import { zoom_oauth } from "./api/zoom_oauth";
import { zoom_oauth_callback } from "./api/zoom_oauth_callback";
import { zoom_obf } from "./api/zoom_obf";
import { env } from "./config/env";

dotenv.config();

const server = http.createServer();

/**
 * HTTP server for handling HTTP requests from Recall.ai
 */
server.on("request", async (req, res) => {
    try {
        // Parse the request
        const url = new URL(`https://${req.headers.host?.replace("https://", "")}${req?.url}`);
        const pathname = url.pathname.at(-1) === "/" ? url.pathname.slice(0, -1) : url.pathname;
        const search_params = Object.fromEntries(url.searchParams.entries()) as any;
        let body: any | null = null;
        try {
            if (["POST", "PUT", "PATCH", "DELETE"].includes(req.method!)) {
                const body_chunks: Buffer[] = [];
                for await (const chunk of req) {
                    body_chunks.push(chunk);
                }
                const raw_body = Buffer.concat(body_chunks).toString("utf-8");
                if (raw_body.trim()) body = JSON.parse(raw_body);
            }
        } catch (error) {
            console.log("Error parsing body", error);
        }

        console.log(`
Incoming HTTP request: ${req.method} ${pathname} 
search_params=${JSON.stringify(search_params)} 
body=${JSON.stringify(body)}
        `);

        switch (pathname) {
            /**
             * Zoom OAuth endpoints for generating a Zoom OAuth access token and refresh token.
             */
            case "/zoom/oauth": {
                if (req.method !== "GET") throw new Error(`Method not allowed: ${req.method}`);

                const zoom_oauth_url = zoom_oauth();
                console.log(`Created Zoom OAuth URL: ${zoom_oauth_url}`);

                // redirect to the Zoom OAuth URL
                res.writeHead(302, { Location: zoom_oauth_url });
                res.end();
                return;
            }
            case "/zoom/oauth/callback": {
                if (req.method !== "GET") throw new Error(`Method not allowed: ${req.method}`);

                const { code: authorization_code } = z.object({ code: z.string() })
                    .parse(Object.fromEntries(url.searchParams.entries()));
                const { access_token, refresh_token } = await zoom_oauth_callback({ authorization_code });
                console.log(`Zoom OAuth callback called with authorization code access_token: ${access_token} and refresh_token: ${refresh_token}`);

                res.writeHead(200, { "Content-Type": "application/json" });
                res.end(JSON.stringify({
                    message: "Zoom OAuth callback received",
                }));
                return;
            }
            /**
             * Generate a Zoom join token for local recording which includes permission to bypass the waiting room.
             */
            case "/zoom/join-token": {
                if (req.method !== "GET") throw new Error(`Method not allowed: ${req.method}`);

                const { join_token } = await zoom_join_token(search_params);
                console.log(`Generated Zoom join token: ${join_token}`);

                res.writeHead(200, { "Content-Type": "text/plain" });
                res.end(join_token);
                return;
            }
            /**
             * Generate a Zoom OBF token which is required to authenticate a participant in a Zoom meeting.
             */
            case "/zoom/obf-token": {
                if (req.method !== "GET") throw new Error(`Method not allowed: ${req.method}`);

                const { obf_token } = await zoom_obf(search_params);
                console.log(`Generated Zoom OBF token: ${obf_token}`);

                res.writeHead(200, { "Content-Type": "text/plain" });
                res.end(obf_token);
                return;
            }
            default: {
                if (url.pathname === "/favicon.ico") {
                    res.writeHead(200, { "Content-Type": "image/x-icon" });
                    res.end(Buffer.from(""));
                    return;
                }

                throw new Error(`Endpoint not found: ${req.method} ${url.pathname}`);
            }
        }
    } catch (error) {
        console.error(`${req.method} ${req.url}`, error);
        res.writeHead(400, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
    }
});

/**
 * Start the server
 */
server.listen(env.PORT, "0.0.0.0", () => {
    console.log(`
Server is running on port ${env.PORT}

To get started, open the following URL in your browser: https://${process.env.NGROK_DOMAIN ?? "NGROK_DOMAIN"}/zoom/oauth
        
After you complete the OAuth flow, you can then create a bot using the \`run.sh\` script. See the README for more details.
    `);
});

Step 3.1: Creating the OAuth URL

The OAuth URL generated is the one the user will need to visit in browser

import { z } from "zod";
import { env } from "../config/env";

/**
 * Generate a Zoom OAuth authorization URL.
 * This URL will redirect the user to the Zoom OAuth authorization page where they can authorize your Zoom OAuth app.
 * Once the user has authorized the app, they will be redirected to your Zoom OAuth app's redirect URI with an authorization code.
 * You can then use this authorization code to generate an access token and refresh token.
 */
export function zoom_oauth(): string {
    const authorization_url = generate_zoom_oauth_authorization_url({
        zoom_oauth_app_client_id: env.ZOOM_OAUTH_APP_CLIENT_ID,
        zoom_oauth_app_redirect_uri: env.ZOOM_OAUTH_APP_REDIRECT_URI,
    });
    return authorization_url;
}

/**
 * Generates a Zoom OAuth authorization URL.
 */
function generate_zoom_oauth_authorization_url(args: { zoom_oauth_app_client_id: string, zoom_oauth_app_redirect_uri: string }): string {
    const { zoom_oauth_app_client_id, zoom_oauth_app_redirect_uri } = z.object({
        zoom_oauth_app_client_id: z.string(),
        zoom_oauth_app_redirect_uri: z.string(),
    }).parse(args);

    const url = new URL("https://zoom.us/oauth/authorize");
    url.searchParams.set("response_type", "code"); // Telling Zoom to return a code which you can exchange for the user's Zoom OAuth access/refresh tokens
    url.searchParams.set("client_id", zoom_oauth_app_client_id);
    url.searchParams.set("redirect_uri", zoom_oauth_app_redirect_uri);

    return url.toString();
}

Step 3.2: Creating the OAuth callback redirect URL

Zoom will redirect the user to this endpoint with the authorization code. You can exchange the authorization code once for a valid refresh token.

📘

Save the refresh token and the associated user.

Upon retrieving the refresh token, you should save it along with the person the refresh token belongs to. You will need to be able to map the meeting ID to the refresh token in the join token callback.

import fs from "fs";
import path from "path";
import { cwd } from "process";
import { z } from "zod";
import { env } from "../config/env";

/**
 * After the user has authorized the app, they will be redirected to your Zoom OAuth app's redirect URI with an authorization code.
 * You can then use this authorization code to generate an access token and refresh token.
 * You can save the refresh token to a file to be used later to generate a new access token once the access token has expired.
 */
export async function zoom_oauth_callback(args: { authorization_code: string }): Promise<{
    access_token: string,
    refresh_token: string
}> {
    const { authorization_code } = z.object({ authorization_code: z.string() }).parse(args);
    const { access_token, refresh_token } = await generate_oauth_tokens_from_authorization_code({
        authorization_code,
        zoom_oauth_app_client_id: env.ZOOM_OAUTH_APP_CLIENT_ID,
        zoom_oauth_app_client_secret: env.ZOOM_OAUTH_APP_CLIENT_SECRET,
        zoom_oauth_app_redirect_uri: env.ZOOM_OAUTH_APP_REDIRECT_URI,
    });

    // Save this refresh token to be used later
    const file_path = path.join(cwd(), "output/zoom_oauth_refresh_token.txt");
    fs.mkdirSync(path.dirname(file_path), { recursive: true });
    fs.writeFileSync(file_path, refresh_token);

    return { access_token, refresh_token };
}

/**
 * Generate Zoom OAuth access and refresh tokens from an authorization code.
 */
async function generate_oauth_tokens_from_authorization_code(args: {
    authorization_code: string,
    zoom_oauth_app_client_id: string,
    zoom_oauth_app_client_secret: string,
    zoom_oauth_app_redirect_uri: string,
}): Promise<{ access_token: string, refresh_token: string }> {
    const { authorization_code,
        zoom_oauth_app_client_id,
        zoom_oauth_app_client_secret,
        zoom_oauth_app_redirect_uri,
    } = z.object({
        authorization_code: z.string(),
        zoom_oauth_app_client_id: z.string(),
        zoom_oauth_app_client_secret: z.string(),
        zoom_oauth_app_redirect_uri: z.string(),
    }).parse(args);

    const url = new URL("https://zoom.us/oauth/token");
    url.searchParams.set("grant_type", "authorization_code");
    url.searchParams.set("code", authorization_code);
    url.searchParams.set("redirect_uri", zoom_oauth_app_redirect_uri);

    const auth_token = Buffer
        .from(`${zoom_oauth_app_client_id}:${zoom_oauth_app_client_secret}`)
        .toString("base64");
    const response = await fetch(url.toString(), {
        method: "POST",
        headers: {
            "Authorization": `Basic ${auth_token}`,
            "Content-Type": "application/x-www-form-urlencoded",
        },
    });
    if (!response.ok) throw new Error(await response.text());

    const data = z.object({
        access_token: z.string(),
        refresh_token: z.string(),
    }).parse(await response.json());
    return { access_token: data.access_token, refresh_token: data.refresh_token };
}

Step 3.3: Creating the join token callback

This is the callback endpoint the bot will call when joining the meeting. These tokens are short-lived so they must be generated/used just-in-time.

import fs from "fs";
import path from "path";
import { cwd } from "process";
import { z } from "zod";
import { env } from "../config/env";

/**
 * Generate a Zoom join token for local recording.
 * This token is meeting-scoped and can be used by the bot to bypass the waiting room
 * and start local recording when configured in the Zoom Meeting SDK flow.
 */
export async function zoom_join_token(args: { meeting_id: string }): Promise<{ join_token: string }> {
    const { meeting_id } = z.object({ meeting_id: z.string() }).parse(args);
    const { access_token } = await get_zoom_oauth_access_token();
    return generate_zoom_join_token({ access_token, meeting_id });
}

/**
 * Get the Zoom OAuth access token.
 * This is the token that is used to authenticate requests to the Zoom API.
 * You can generate a new access token as long as you have a valid refresh token.
 */
export async function get_zoom_oauth_access_token(): Promise<{ access_token: string, refresh_token: string }> {
    // Get the refresh token from storage
    const file_path = path.join(cwd(), "output/zoom_oauth_refresh_token.txt");
    const refresh_token = fs.readFileSync(file_path, "utf8").trim();
    if (!refresh_token) throw new Error("No refresh token found. Generate a new one by calling the /zoom/oauth endpoint.");

    // Refresh the access token
    const token = Buffer
        .from(`${env.ZOOM_OAUTH_APP_CLIENT_ID}:${env.ZOOM_OAUTH_APP_CLIENT_SECRET}`)
        .toString("base64");
    const response = await fetch("https://zoom.us/oauth/token", {
        method: "POST",
        headers: {
            "Authorization": `Basic ${token}`,
            "Content-Type": "application/x-www-form-urlencoded",
        },
        body: new URLSearchParams({ grant_type: "refresh_token", refresh_token }).toString(),
    });
    if (!response.ok) throw new Error(await response.text());

    return z.object({
        access_token: z.string(),
        refresh_token: z.string(),
    }).parse(await response.json());
}

/**
 * Generates a Zoom join token for local recording.
 * Sets bypass_waiting_room=true so the token can skip waiting-room admission.
 */
async function generate_zoom_join_token(args: { access_token: string, meeting_id: string }): Promise<{ join_token: string }> {
    const { access_token, meeting_id } = z.object({ access_token: z.string(), meeting_id: z.string() }).parse(args);
    const response = await fetch(
        `https://api.zoom.us/v2/meetings/${meeting_id}/jointoken/local_recording?bypass_waiting_room=true`,
        { headers: { Authorization: `Bearer ${access_token}` } },
    );
    if (!response.ok) throw new Error(await response.text());

    const data = z.object({ token: z.string() }).parse(await response.json());
    return { join_token: data.token };
}

Step 3.4: Creating the OBF token callback

📘

If you already have OBF/ZAK tokens implemented, you can skip this step

Zoom requires all bots using meeting SDK features to authorize using a ZAK token or an OBF token. For this tutorial, we'll use an OBF token to authorize the bot. Note that the user associated with the refresh token must be in the meeting for the OBF token to work.

import { z } from "zod";
import { get_zoom_oauth_access_token } from "./zoom_join_token";

/**
 * Generate a Zoom OBF token given a meeting ID.
 */
export async function zoom_obf(args: { meeting_id: string }): Promise<{ obf_token: string }> {
    const { meeting_id } = z.object({ meeting_id: z.string() }).parse(args);
    const { access_token } = await get_zoom_oauth_access_token();
    return generate_zoom_obf({ access_token, meeting_id });
}

/**
 * Generates a Zoom OBF token.
 * This is the token that is used to join a Zoom meeting on behalf of an OAuth user.
 */
async function generate_zoom_obf(args: { access_token: string, meeting_id: string }): Promise<{ obf_token: string }> {
    const { access_token, meeting_id } = z.object({ access_token: z.string(), meeting_id: z.string() }).parse(args);
    const response = await fetch(
        `https://api.zoom.us/v2/users/me/token?type=onbehalf&meeting_id=${meeting_id}`,
        { headers: { Authorization: `Bearer ${access_token}` } },
    );
    if (!response.ok) throw new Error(await response.text());

    const data = z.object({ token: z.string() }).parse(await response.json());
    return { obf_token: data.token };
}

Step 4: Configuring bots to use the join token

When creating the Recall bot, pass both of the following:

  • zoom.join_token_url
  • zoom.obf_token_url (for this tutorial; a zak_url would also work if you already configured the ZAK flow)
curl -X POST "https://RECALL_REGION.recall.ai/api/v1/bot/" \
  -H "Authorization: YOUR_RECALL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "meeting_url": "YOUR_ZOOM_MEETING_URL",
    "zoom": {
      "join_token_url": "https://YOUR_NGROK_DOMAIN/zoom/join-token?meeting_id=ZOOM_MEETING_ID",
      "obf_token_url": "https://YOUR_NGROK_DOMAIN/zoom/obf-token?meeting_id=ZOOM_MEETING_ID"
    }
  }'

Now when the bot attempts to join the call, the bot will query these URLs to get the join token and the OBF token. Note that if using join tokens, the participant associated with the refresh token must be in the meeting for the bot to join.

Production checklist

The sample app is intentionally minimal. Before using this in production, you should:

  1. Verify requests are actually from Recall
    The sample endpoints are simple and do not include request authentication. Add request verification (for example, your existing Recall request verification flow) before minting tokens.

  2. Store tokens in a database (not a local file)
    The sample stores the refresh token in output/zoom_oauth_refresh_token.txt for simplicity. In production, store per-user refresh tokens securely in your database / secret store.

  3. Mint join tokens just-in-time
    Join tokens are short-lived and should be fetched right before the bot joins.

  4. Make sure local recording is allowed on the Zoom side
    Join tokens pre-authorize the bot, but the host/org still needs local-recording settings enabled appropriately in Zoom.

FAQ

Do I need an OBF or ZAK token for the bot to skip the waiting room?

Yes, your bot will need to join with either an OBF and a ZAK token. If both of these tokens are missing, the bot will not be able to join the call?

Why didn't the bot skip the waiting room?

Note that the participant who generated the refresh token must be in the meeting before the bot can join. This is a platform-level limitation imposed by Zoom.

How can I verify the request is from Recall?

See Verifying webhooks, websockets and callback requests from Recall.ai for details on how to verify callbacks from Recall.