Skip to navigation

Zoom Authenticated Bots (ZAK and OBF)

View as Markdown

Host one HTTPS token URL, pass it on create_bot, and your Zoom bot joins signed in (ZAK) or on behalf of one of your users (OBF).

MeetStream does not store your users’ Zoom OAuth tokens. You run Zoom OAuth on your server, keep the refresh tokens, and expose a URL that returns a fresh token. At join time, the bot calls that URL and joins with the token it gets back.

Endpoint: POST /api/v1/bots/create_bot Auth: Authorization: Token <your MeetStream API key>

Before you start, save your Meeting SDK app’s Client ID and Client Secret in the dashboard (Zoom Marketplace App Setup). Those credentials are the app the bot runs as. The ZAK or OBF token only says which Zoom user the join is attributed to.

1. Pick a join mode

zoom on create_botWhat happens
Omitted or {}Guest join. No ZAK, no OBF
{"zak_url": "https://..."}Signed in: the bot joins as the Zoom user whose ZAK you return
{"obf_url": "https://..."}On behalf of a Zoom user who authorized your app and is already in the meeting
Both URLsHTTP 400 Pass only one of zoom.zak_url or zoom.obf_url, not both

Since March 2, 2026, Zoom requires Meeting SDK apps that join meetings outside the app’s own Zoom account to be reviewed by Zoom and to use a ZAK or OBF token (Zoom: Meeting SDK authorization, Zoom: OBF FAQ). Meetings hosted by your app’s own Zoom account can still use a guest join.

2. ZAK or OBF?

ZAK (zak_url)OBF (obf_url)
What the token representsA Zoom user. The bot joins signed in as that userYour app, attributed to a user who authorized it
Zoom API your server callsGET https://api.zoom.us/v2/users/me/token?type=zakGET https://api.zoom.us/v2/users/me/token?type=onbehalf&meeting_id=<meeting number>
OAuth scopeuser:read:zakuser:read:token
Authorizing user must already be in the meetingNoYes
If that user leavesThe bot can stayZoom ends the bot’s session
Meeting numberNot neededMeetStream adds meeting_number to the request. Do not put it on your URL
Good forMeetings that only allow signed-in users; a dedicated service accountNotetakers that join the meetings your users attend

OBF tokens are short-lived and single-use (Zoom: Transitioning to OBF tokens). Mint one every time MeetStream calls you; never cache or reuse it.

3. How the token fetch works

4. What you host

Two jobs, both on your HTTPS server:

  1. OAuth, once per Zoom user. Redirect the user to Zoom’s authorize page, receive the code at your callback, exchange it with POST https://zoom.us/oauth/token (grant_type=authorization_code) and store the refresh token. Never send refresh tokens to MeetStream.
  2. The token URL, called on every join. Refresh the user’s access token when needed (grant_type=refresh_token, and save the new refresh token if Zoom returns one), call Zoom’s user token API, and return the token.

Encode which user in the URL (a user_id query parameter, a path segment, or a signed ID) and protect it: MeetStream sends no authentication header and does not sign token requests, so put a secret in the URL (a shared auth query parameter, an HMAC signature, or a short-lived signed URL) and treat the whole URL as a credential.

Minimal token server (Python)

# Minimal example. Replace load_refresh_token/save_refresh_token with your own storage,
# and cache access tokens until they expire in production.
import hmac
import os
import requests
from flask import Flask, abort, jsonify, request
app = Flask(__name__)
CLIENT_ID = os.environ["ZOOM_CLIENT_ID"]
CLIENT_SECRET = os.environ["ZOOM_CLIENT_SECRET"]
URL_SECRET = os.environ["TOKEN_URL_SECRET"]
def require_secret():
if not hmac.compare_digest(request.args.get("auth", ""), URL_SECRET):
abort(401)
def zoom_access_token(user_id):
resp = requests.post(
"https://zoom.us/oauth/token",
auth=(CLIENT_ID, CLIENT_SECRET),
data={"grant_type": "refresh_token", "refresh_token": load_refresh_token(user_id)},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
save_refresh_token(user_id, data["refresh_token"])
return data["access_token"]
def zoom_user_token(user_id, params):
resp = requests.get(
"https://api.zoom.us/v2/users/me/token",
params=params,
headers={"Authorization": f"Bearer {zoom_access_token(user_id)}"},
timeout=10,
)
resp.raise_for_status()
return resp.json()["token"]
@app.get("/zoom/zak")
def zak():
require_secret()
return jsonify(token=zoom_user_token(request.args["user_id"], {"type": "zak"}))
@app.get("/zoom/obf")
def obf():
require_secret()
meeting_number = request.args["meeting_number"] # added by MeetStream
return jsonify(
token=zoom_user_token(
request.args["user_id"], {"type": "onbehalf", "meeting_id": meeting_number}
)
)

jsonify sets Content-Type: application/json, which MeetStream needs to read a JSON response (see Response formats).

5. create_bot examples

Signed in (ZAK)

{
"meeting_link": "https://zoom.us/j/81234567890?pwd=AbCdEf123",
"bot_name": "Notetaker",
"video_required": false,
"zoom": {
"zak_url": "https://api.example.com/zoom/zak?user_id=alice&auth=YOUR_URL_SECRET"
}
}

On behalf of (OBF)

Make sure the authorizing user has joined the Zoom meeting before you create the bot (or before join_at for a scheduled bot). Do not add meeting_number to obf_url.

{
"meeting_link": "https://zoom.us/j/81234567890?pwd=AbCdEf123",
"bot_name": "Notetaker",
"video_required": false,
"zoom": {
"obf_url": "https://api.example.com/zoom/obf?user_id=alice&auth=YOUR_URL_SECRET"
}
}

URL rules checked by create_bot: https only, must include a host, at most 4096 characters, and only on Zoom meetings. The bot calls the URL from the internet, so localhost and private addresses do not work. For local development, expose your server through a public HTTPS tunnel (local webhook server guide).

6. The request MeetStream sends to your URL

When: once per join attempt, right before the bot joins the meeting. For a scheduled bot, that is shortly before join_at. If a join attempt is retried, your URL can be called again for the same bot, so always mint a fresh token.

First request: GET

PartZAKOBF
MethodGETGET
URLYour zak_url, unchangedYour obf_url with meeting_number=<number> appended to the query string
HeadersX-Bot-Id: <MeetStream bot ID>X-Bot-Id: <MeetStream bot ID>
Timeout15 seconds15 seconds

What the bot does with your GET response

Your responseWhat happens
2xx with a usable tokenThe bot joins with it
405 Method Not AllowedThe bot retries once with POST
2xx without a usable token (empty body, plain text of 50 characters or fewer, or JSON without token, zak or obf)The bot retries once with POST
Any other status (for example 400, 401, 404, 500)The join fails. No POST retry
Timeout or connection errorThe join fails. No POST retry

Fallback request: POST

PartZAKOBF
HeadersX-Bot-Id, Content-Type: application/jsonX-Bot-Id, Content-Type: application/json
JSON body{"bot_id": "<bot ID>", "webhook_secret": null}{"bot_id": "<bot ID>", "meeting_number": 81234567890}

meeting_number in the POST body is a JSON number. Ignore any body fields you do not use. The POST must return 2xx with a usable token, or the join fails.

Response formats

Response Content-TypeHow the token is read
application/jsonA JSON object with a string field token, zak or obf (checked in that order)
Anything else, for example text/plainThe whole body, trimmed, is the token. It must be longer than 50 characters

If you return JSON with a text/plain content type, the bot treats the entire JSON text as the token and Zoom rejects the join. Set Content-Type: application/json whenever you return JSON.

When the fetch fails

The bot does not fall back to a guest join. It ends with status Error and one of these messages, sent as bot.failed with status_code: 500:

  • Error: Failed to join meeting: ZAK token is required but could not be fetched
  • Error: Failed to join meeting: OBF token is required but could not be fetched

MeetStream fetches the token once per join attempt and does not retry the fetch on its own.

7. Test your URL without a bot

Call your own server the way MeetStream will (replace the host, secret and meeting number):

# ZAK: expect 200 and a long token
curl -sS -D - -H "X-Bot-Id: test-bot" \
"https://api.example.com/zoom/zak?user_id=alice&auth=YOUR_URL_SECRET"
# OBF: the authorizing user must be in meeting 81234567890 right now
curl -sS -D - -H "X-Bot-Id: test-bot" \
"https://api.example.com/zoom/obf?user_id=alice&auth=YOUR_URL_SECRET&meeting_number=81234567890"
# POST fallback, if your endpoint only accepts POST
curl -sS -D - -X POST -H "X-Bot-Id: test-bot" -H "Content-Type: application/json" \
-d '{"bot_id": "test-bot", "meeting_number": 81234567890}' \
"https://api.example.com/zoom/obf?user_id=alice&auth=YOUR_URL_SECRET"

Check the Content-Type header in the output. Then create a bot with the same URL, without meeting_number on the OBF URL.

8. Troubleshooting

Errors from create_bot (HTTP 400)

MessageFix
Pass only one of zoom.zak_url or zoom.obf_url, not bothSend exactly one URL
zoom.zak_url must use https / zoom.obf_url must use httpsUse an https:// URL
zoom.zak_url must include a host (or zoom.obf_url ...)Use a full URL with a hostname
zoom.zak_url must be a non-empty HTTPS URL (or zoom.obf_url ...)The field is present but empty
zoom.zak_url exceeds maximum length of 4096 (or zoom.obf_url ...)Shorten the URL
zoom.zak_url and zoom.obf_url are only supported for Zoom meetingsRemove the zoom object for Google Meet and Teams bots
zoom must be an objectSend zoom as a JSON object
zoom.use_zoom_obf is no longer supported. ... or zoom.zoom_oauth_connection_user_id is no longer supported. ...See Migrating from hosted OBF

Problems at join time

SymptomWhat to check
Error: Failed to join meeting: OBF token is required but could not be fetchedYour URL returned a non-2xx status, timed out after 15 s, or returned no usable token. Common causes: meeting_number added to obf_url (it then appears twice and many frameworks read it as a list), a wrong auth secret (401), a stopped tunnel, or Zoom refusing to mint because the user’s OAuth grant was revoked
Error: Failed to join meeting: ZAK token is required but could not be fetchedSame checks, for zak_url
OBF bot fails when the authorizing user has not joined yetZoom only issues OBF tokens for users who are in the meeting, and rejects OBF joins otherwise, so you see either the fetch error above or a failed join. The bot does not retry, and a longer waiting_room_timeout does not help because the token is fetched before the bot waits. Have the user join first
The bot left after the authorizing user leftExpected for OBF: Zoom ends the bot’s session when that user leaves. Use ZAK if the bot must stay
The bot joined as a guest when you expected ZAK or OBFThe zoom object was missing from the request. It must be a top-level field on create_bot
Stopped with Unable to join external meetingYour app has not passed Zoom’s review, or no token was sent. See Zoom App Production Submission

All Zoom statuses and messages are listed in Zoom Meeting Bots and in Bot status and error codes.

Migrating from hosted OBF

MeetStream used to mint ZAK and OBF tokens for you. That flow is retired:

  • create_bot rejects zoom.use_zoom_obf and zoom.zoom_oauth_connection_user_id with HTTP 400.
  • The hosted token endpoints /api/v1/zoom/zak-token and /api/v1/zoom/obf-token return HTTP 410 Gone with a message telling you to pass zoom.zak_url or zoom.obf_url and mint the token on your server.

To migrate: register your own OAuth redirect URL in your Zoom app (Zoom Marketplace App Setup), have your users authorize your app again through your server, deploy a token URL like the example above, and send zoom.obf_url (or zoom.zak_url) on create_bot.