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 featureIn 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 tokenThis 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_idfor each bot launch. The sample app expectsmeeting_idas 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 Setup → Zoom
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 tokenOAuth 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 appGET /zoom/oauth/callback- upon successful authorization, Zoom will redirect the user here with the authorization code for you to trade for the refresh tokenGET /zoom/join-token- the bot will call this endpoint to retrieve a join token upon joiningGET /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_urlzoom.obf_token_url(for this tutorial; azak_urlwould 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:
-
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. -
Store tokens in a database (not a local file)
The sample stores the refresh token inoutput/zoom_oauth_refresh_token.txtfor simplicity. In production, store per-user refresh tokens securely in your database / secret store. -
Mint join tokens just-in-time
Join tokens are short-lived and should be fetched right before the bot joins. -
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.
Updated about 17 hours ago