Subchapter 118.4
meeting-sdk-bot.mdMarkdown30 KBView on GitHub
Build resilient meeting bots that join Zoom meetings as visible participants using the Meeting SDK.
Meeting SDK bots join as real participants (visible in participant list) and can access raw audio/video data for recording, transcription, or AI processing.
Use this approach when:
Alternative: See rtms/examples/rtms-bot.md (opens in a new tab) for invisible, read-only access via RTMS.
┌─────────────────────────────────────────────────────────────────────┐
│ MEETING SDK BOT FLOW │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ 1. Pre-Join: REST API │
│ └── Get meeting schedule (number, password, start time) │
│ └── Get OBF token for user (bot joins "on behalf of" user) │
└────────────────────────────┬────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 2. Join with Retry (OBF requires owner present) │
│ └── Retry with configurable interval until owner joins │
│ └── Circuit breaker: Stop after N attempts │
└────────────────────────────┬────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 3. Start Raw Recording (Meeting SDK singleton) │
│ └── IMeetingRecordingController::StartRawRecording() │
│ └── Subscribe to raw audio/video │
└────────────────────────────┬────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 4. Process Media Streams │
│ └── Audio: PCM data via IZoomSDKAudioRawDataDelegate │
│ └── Video: YUV420 frames via IZoomSDKVideoRawDataDelegate │
└────────────────────────────┬────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 5. Mid-Meeting: Connection Monitoring │
│ └── Detect disconnections → Exponential backoff retry │
│ └── Stop after N reconnection attempts │
└─────────────────────────────────────────────────────────────────────┘| Skill | Purpose |
|---|---|
| zoom-rest-api | Get meeting schedule, retrieve OBF token |
| zoom-meeting-sdk (Linux) | Join meeting, control recording, access raw media |
| Goal | Primary path | Skills |
|---|---|---|
| Bot writes its own audio/video files | StartRawRecording() + raw audio/video delegates | zoom-meeting-sdk (Linux) |
| Zoom-hosted MP4/M4A/transcript assets after meeting end | Meeting/account cloud recording settings + recording.completed webhook + recordings download API | zoom-rest-api + zoom-webhooks |
Use raw recording when the bot must process or persist media itself. Use the cloud-recording path when the requirement is post-meeting retrieval of Zoom-managed recording assets.
zoom-rest-api
-> get meeting metadata
-> get OBF or ZAK token
Meeting SDK Linux bot
-> join with retry
-> CanStartRawRecording()
-> StartRawRecording()
-> subscribe raw audio/video
-> write PCM/YUV or forward to AI pipeline
Optional post-meeting cloud path
-> zoom-webhooks recording.completed
-> zoom-rest-api recordings downloadmeeting:read, user:read, optionally meeting:write if triggering meetings// config.h or environment variables
struct BotConfig {
// Join retry (waiting for owner to be present)
int join_retry_attempts = 5; // Max join attempts (default: 5)
int join_retry_interval_ms = 60000; // Constant interval: 60s (default: 1min)
// Mid-meeting reconnection (network failures)
int reconnect_max_attempts = 3; // Max reconnection attempts (default: 3)
int reconnect_base_delay_ms = 2000; // Initial delay: 2s (default: 2s)
// Exponential backoff: 2s, 4s, 8s...
// Meeting schedule polling (if webhook unavailable)
int schedule_poll_interval_ms = 30000; // Poll every 30s (default: 30s)
// Timeout settings
int auth_timeout_ms = 10000; // SDK auth timeout (default: 10s)
int join_timeout_ms = 30000; // Single join attempt timeout (default: 30s)
};
// Load from environment variables (recommended for production)
BotConfig loadConfig() {
BotConfig cfg;
// Override defaults from env vars if present
const char* env;
if ((env = getenv("BOT_JOIN_RETRY_ATTEMPTS"))) {
cfg.join_retry_attempts = atoi(env);
}
if ((env = getenv("BOT_JOIN_RETRY_INTERVAL_MS"))) {
cfg.join_retry_interval_ms = atoi(env);
}
if ((env = getenv("BOT_RECONNECT_MAX_ATTEMPTS"))) {
cfg.reconnect_max_attempts = atoi(env);
}
if ((env = getenv("BOT_RECONNECT_BASE_DELAY_MS"))) {
cfg.reconnect_base_delay_ms = atoi(env);
}
return cfg;
}| Parameter | Default | When to Increase | When to Decrease |
|---|---|---|---|
join_retry_attempts | 5 | High-priority meetings, owner often late | Testing, short-lived meetings |
join_retry_interval_ms | 60000 (1min) | Meetings with long pre-join buffer | Need faster failure detection |
reconnect_max_attempts | 3 | Unstable networks, critical meetings | Batch processing, cost-sensitive |
reconnect_base_delay_ms | 2000 (2s) | Network latency high (international) | Local network, low latency |
Recommended Ranges:
Examples:
# High-priority production bot (aggressive retries)
export BOT_JOIN_RETRY_ATTEMPTS=10
export BOT_JOIN_RETRY_INTERVAL_MS=30000 # 30s
export BOT_RECONNECT_MAX_ATTEMPTS=5
export BOT_RECONNECT_BASE_DELAY_MS=1000 # 1s
# Cost-sensitive batch processing (conservative)
export BOT_JOIN_RETRY_ATTEMPTS=3
export BOT_JOIN_RETRY_INTERVAL_MS=120000 # 2min
export BOT_RECONNECT_MAX_ATTEMPTS=2
export BOT_RECONNECT_BASE_DELAY_MS=5000 # 5s
# Development/testing (fail fast)
export BOT_JOIN_RETRY_ATTEMPTS=2
export BOT_JOIN_RETRY_INTERVAL_MS=10000 # 10s
export BOT_RECONNECT_MAX_ATTEMPTS=1
export BOT_RECONNECT_BASE_DELAY_MS=1000 # 1s# Get meeting details
curl "https://api.zoom.us/v2/meetings/{meetingId}" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"Response:
{
"id": 1234567890,
"topic": "Team Standup",
"start_time": "2026-02-09T15:00:00Z",
"password": "abc123",
"pmi": false
}Store: meeting number, password, start time.
The bot joins “on behalf of” a Zoom user. Get the user’s OBF token:
# Get OBF token for user
curl -X POST "https://api.zoom.us/v2/users/{userId}/token?type=obf&ttl=7200" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"Response:
{
"token": "eyJhbGc...",
"expire_in": 7200
}CRITICAL: The bot cannot join until the owner (the user whose OBF token you’re using) is present in the meeting. This is why retry logic is essential.
Error if REST API fails: ABORT. No meeting info = cannot proceed.
#include <chrono>
#include <thread>
class MeetingBot {
private:
BotConfig config;
IMeetingService* meetingService;
bool joinSuccessful = false;
bool ownerNotPresentError = false;
public:
// Attempt to join with retry logic
bool joinMeetingWithRetry(
uint64_t meetingNumber,
const string
| Error Code | Meaning | Action |
|---|---|---|
MEETING_FAIL_OBF_OWNER_NOT_IN_MEETING | OBF token owner not in meeting yet | RETRY (owner might join soon) |
MEETING_FAIL_MEETING_NOT_EXIST | Meeting not started | RETRY if before end time |
MEETING_FAIL_INCORRECT_MEETING_NUMBER | Wrong meeting ID | ABORT (config error) |
MEETING_FAIL_MEETING_NOT_START | Meeting hasn’t started | RETRY until start time |
MEETING_FAIL_INVALID_TOKEN | OBF token invalid/expired | ABORT (need new token) |
Once joined, request permission to access raw audio/video:
void onMeetingStatusChanged(MeetingStatus status, int iResult) {
if (status == MEETING_STATUS_INMEETING) {
cout << "[BOT] Joined successfully, starting raw recording..." << endl;
// Get recording controller
IMeetingRecordingController* recordCtrl =
meetingService->GetMeetingRecordingController();
if (!recordCtrl) {
cerr << "[BOT] Failed to get recording controller" << endl;
return;
}
// Check permission
SDKError canRecord = recordCtrl->CanStartRawRecording();
if (canRecord != SDKERR_SUCCESS) {
cerr << "[BOT] Cannot start raw recording: " << canRecord << endl;
cerr << "[BOT] Check: Raw Data entitlement enabled in Admin settings?" << endl;
return;
}
// Start raw recording (enables raw data flow)
SDKError err = recordCtrl->StartRawRecording();
if (err != SDKERR_SUCCESS) {
cerr << "[BOT] StartRawRecording failed: " << err << endl;
return;
}
cout << "[BOT] Raw recording started, subscribing to media..." << endl;
// Subscribe to audio/video
subscribeToRawMedia();
}
}IMPORTANT: StartRawRecording() does NOT create a file. It enables access to raw audio/video data streams.
The bot should treat raw recording as a capability switch plus media subscriptions:
class RecordingSession {
public:
void start(IMeetingService* meetingService) {
auto* recordCtrl = meetingService->GetMeetingRecordingController();
if (!recordCtrl) {
throw std::runtime_error("recording_controller_unavailable");
}
SDKError canRecord = recordCtrl->CanStartRawRecording();
if (canRecord != SDKERR_SUCCESS) {
throw std::runtime_error("raw_recording_not_permitted");
}
SDKError err = recordCtrl->StartRawRecording();
if (err != SDKERR_SUCCESS) {
throw std::runtime_error("start_raw_recording_failed");
}
audioHelper = GetAudioRawdataHelper();
audioHelper->subscribe(&audioDelegate, true);
createRenderer(&videoRenderer, &videoDelegate);
videoRenderer->setRawDataResolution(ZoomSDKResolution_720P);
videoRenderer->subscribe(activeUserId, RAW_DATA_TYPE_VIDEO);
}
void stop(IMeetingService* meetingService) {
if (audioHelper) {
audioHelper->unSubscribe();
}
if (videoRenderer) {
videoRenderer->unSubscribe();
}
auto* recordCtrl = meetingService->GetMeetingRecordingController();
if (recordCtrl) {
recordCtrl->StopRawRecording();
}
}
private:
IZoomSDKAudioRawDataHelper* audioHelper = nullptr;
IZoomSDKRenderer* videoRenderer = nullptr;
MyAudioDelegate audioDelegate;
MyVideoDelegate videoDelegate;
uint32_t activeUserId = 0;
};Persisting a recording is your job after raw data arrives. Typical outputs are:
void subscribeToRawMedia() {
// Subscribe to raw audio
IZoomSDKAudioRawDataDelegate* audioDelegate = new MyAudioDelegate();
SDKError audioErr = meetingService->GetMeetingAudioController()
->GetMeetingAudioHelper()
->subscribe(audioDelegate, true); // true = mixed audio
if (audioErr != SDKERR_SUCCESS) {
cerr << "[AUDIO] Subscribe failed: " << audioErr << endl;
} else {
cout << "[AUDIO] Subscribed to mixed audio" << endl;
}
// Subscribe to raw video
IZoomSDKVideoRawDataDelegate* videoDelegate = new MyVideoDelegate();
SDKError videoErr = meetingService->GetMeetingVideoController()
->GetMeetingVideoHelper()
->subscribe(videoDelegate);
if (videoErr != SDKERR_SUCCESS) {
cerr << "[VIDEO] Subscribe failed: " << videoErr << endl;
} else {
cout << "[VIDEO] Subscribed to video streams" << endl;
}
}If the requirement is Zoom-managed cloud recording instead of raw media capture, use Meeting SDK only for the joining bot and use API/webhook skills for the recording workflow:
curl -X POST "https://api.zoom.us/v2/users/{userId}/meetings" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"topic": "Bot Recorded Meeting",
"type": 2,
"start_time": "2026-03-06T18:00:00Z",
"settings": {
"auto_recording": "cloud"
}
}'Then subscribe to recording.completed and download assets through the recordings APIs:
zoom-webhooks -> receive recording.completedzoom-rest-api -> GET /meetings/{meetingId}/recordings or GET /users/{userId}/recordingsUse this path when the desired output is Zoom-hosted MP4/M4A/transcript files rather than bot-owned raw PCM/YUV.
class MeetingBot {
private:
BotConfig config;
int reconnectionAttempt = 0;
public:
void onMeetingStatusChanged(MeetingStatus status, int iResult) {
switch (status) {
case MEETING_STATUS_RECONNECTING:
cout << "[BOT] Connection lost, SDK is reconnecting..." << endl;
break;
case MEETING_STATUS_FAILED:
case MEETING_STATUS_DISCONNECTING:
handleDisconnection(iResult);
break;
case MEETING_STATUS_INMEETING:
// Reconnection successful
if (reconnectionAttempt > 0) {
cout << "[BOT] Reconnected successfully!" << endl;
reconnectionAttempt = 0; // Reset counter
}
break;
}
}
private:
void handleDisconnection(int errorCode) {
reconnectionAttempt++;
cout << "[RECONNECT] Disconnected (error: " << errorCode
<< "), attempt " << reconnectionAttempt << "/"
<< config.reconnect_max_attempts << endl;
if (reconnectionAttempt >= config.reconnect_max_attempts) {
cerr << "[RECONNECT] Giving up after "
<< reconnectionAttempt << " attempts" << endl;
cleanup();
notifyFailure("Max reconnection attempts exceeded");
return;
}
// Exponential backoff: 2s, 4s, 8s...
int delay_ms = config.reconnect_base_delay_ms
* (1 << (reconnectionAttempt - 1));
cout << "[RECONNECT] Retrying in " << (delay_ms / 1000) << "s..." << endl;
// Schedule reconnection
std::thread([this, delay_ms]() {
std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms));
attemptRejoin();
}).detach();
}
void attemptRejoin() {
// Re-use same meeting number, password, OBF token
// If OBF token expired, fetch new one from REST API first
if (isOBFTokenExpired()) {
cout << "[RECONNECT] OBF token expired, fetching new one..." << endl;
// TODO: Call REST API to get fresh OBF token
}
// Call join again
bool success = joinMeetingWithRetry(
cachedMeetingNumber,
cachedPassword,
cachedOBFToken,
cachedBotName
);
if (!success) {
cerr << "[RECONNECT] Rejoin failed" << endl;
cleanup();
notifyFailure("Reconnection failed");
}
}
};// Example: Linear backoff instead of exponential
int delay_ms = config.reconnect_base_delay_ms * reconnectionAttempt;
// Example: Capped exponential backoff (max 30s)
int delay_ms = std::min(
config.reconnect_base_delay_ms * (1 << (reconnectionAttempt - 1)),
30000 // Cap at 30s
);
// Example: Jittered backoff (avoid thundering herd)
int base_delay = config.reconnect_base_delay_ms * (1 << (reconnectionAttempt - 1));
int jitter = rand() % 1000; // Random 0-1000ms
int delay_ms = base_delay + jitter;If raw recording fails, fall back to local or cloud recording:
void startRecordingWithFallback() {
IMeetingRecordingController* ctrl =
meetingService->GetMeetingRecordingController();
// Try raw recording first
if (ctrl->CanStartRawRecording() == SDKERR_SUCCESS) {
SDKError err = ctrl->StartRawRecording();
if (err == SDKERR_SUCCESS) {
cout << "[RECORDING] Using raw recording" << endl;
return;
}
}
// Fallback: Local recording
if (ctrl->CanStartRecording(true) == SDKERR_SUCCESS) {
SDKError err = ctrl->StartRecording(
chrono::system_clock::now(),
"/tmp/bot_recording.mp4"
);
if (err == SDKERR_SUCCESS) {
cout << "[RECORDING] Using local recording" << endl;
return;
}
}
// Fallback: Cloud recording
if (ctrl->CanStartCloudRecording() == SDKERR_SUCCESS) {
SDKError err = ctrl->StartCloudRecording();
if (err == SDKERR_SUCCESS) {
cout << "[RECORDING] Using cloud recording" << endl;
return;
}
}
cerr << "[RECORDING] All recording methods failed" << endl;
}int main() {
// 1. Load configuration
BotConfig config = loadConfig();
// 2. Initialize Meeting SDK
InitParam initParam;
initParam.strWebDomain = "https://zoom.us";
initParam.emLanguageID = LANGUAGE_English;
initParam.enableLogByDefault = true;
SDKError err = InitSDK(initParam);
if (err != SDKERR_SUCCESS) {
cerr << "InitSDK failed: " << err << endl;
return 1;
}
// 3. Authenticate SDK with JWT
AuthContext authCtx;
authCtx.jwt_token = generateJWT(SDK_KEY, SDK_SECRET);
IAuthService* authService = CreateAuthService();
authService->SDKAuth(authCtx);
// Wait for auth callback...
// 4. Fetch meeting info + OBF token from REST API
MeetingInfo meetingInfo = fetchMeetingInfoFromAPI(MEETING_ID);
string obfToken = fetchOBFTokenFromAPI(USER_ID);
if (meetingInfo.empty() || obfToken.empty()) {
cerr << "ABORT: Failed to get meeting info or OBF token" << endl;
return 1;
}
// 5. Join meeting with retry
MeetingBot bot(config);
bool joined = bot.joinMeetingWithRetry(
meetingInfo.number,
meetingInfo.password,
obfToken,
"Transcription Bot"
);
if (!joined) {
cerr << "ABORT: Failed to join meeting" << endl;
return 1;
}
// 6. SDK callbacks handle: StartRawRecording, subscribe, reconnection
// 7. Keep running until meeting ends
bot.runEventLoop();
// 8. Cleanup
bot.cleanup();
CleanUPSDK();
return 0;
}| Aspect | Meeting SDK Bot | RTMS Bot |
|---|---|---|
| Visibility | Visible participant | Invisible (read-only service) |
| Authentication | JWT + OBF token | REST API trigger + webhook |
| Join Dependency | Owner must be present | No dependency on participants |
| Retry Logic | Required (owner presence) | Not applicable (webhook-based) |
| Media Access | Raw audio/video/share via SDK | Audio/video/text/share/chat via WebSocket |
| Recording Control | Full (local, cloud, raw) | None (read-only) |
| Interaction | Can send chat, reactions | Cannot interact |
| Resource Usage | Higher (full SDK) | Lower (WebSocket only) |
| Use Case | Interactive bots, recording, moderation | Passive transcription, analytics |
Choose Meeting SDK Bot when:
Choose RTMS Bot when:
Symptom: All join attempts fail with “owner not in meeting”
Solution:
join_retry_attempts or join_retry_interval_msmeeting.participant_joined event for ownerSymptom: CanStartRawRecording() returns error
Solution:
Symptom: Bot reconnects multiple times, then gives up
Solution:
reconnect_max_attempts (e.g., 5 instead of 3)reconnect_base_delay_ms if network is slowSymptom: Reconnection fails with “invalid token”
Solution:
attemptRejoin() before rejoiningexpire_in from API response)