# Meeting SDK Bot (Linux) Build resilient meeting bots that join Zoom meetings as visible participants using the Meeting SDK. ## Overview 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:** - You need to be visible in the participant list - You want to interact with meeting features (chat, reactions, etc.) - You need local recording control - You're processing your own meetings or have user consent **Alternative:** See [rtms/examples/rtms-bot.md](../../rtms/examples/rtms-bot.md) for invisible, read-only access via RTMS. ## Architecture ``` ┌─────────────────────────────────────────────────────────────────────┐ │ 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 │ └─────────────────────────────────────────────────────────────────────┘ ``` ## Skills Required | Skill | Purpose | |-------|---------| | **zoom-rest-api** | Get meeting schedule, retrieve OBF token | | **zoom-meeting-sdk** (Linux) | Join meeting, control recording, access raw media | ## Choose the Recording Mode | 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. ## Automatic Join + Recording Flow ```text 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 download ``` ## Prerequisites - Zoom Meeting SDK v5.15+ (Linux) - Meeting SDK JWT credentials (SDK Key + Secret) - Server-to-Server OAuth app or OAuth app for REST API access - REST API scopes: `meeting:read`, `user:read`, optionally `meeting:write` if triggering meetings - Raw Data entitlement enabled (Admin → Account Settings → Meeting → "Allow access to raw data") ## Configuration ### Retry Parameters (Customizable) ```cpp // 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; } ``` ### Customization Guide | 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:** - Join retry attempts: 3-10 - Join retry interval: 30s-5min - Reconnect attempts: 2-5 - Reconnect base delay: 1s-5s **Examples:** ```bash # 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 ``` ## Step 1: Get Meeting Info + OBF Token (REST API) ### Get Meeting Schedule ```bash # Get meeting details curl "https://api.zoom.us/v2/meetings/{meetingId}" \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" ``` **Response:** ```json { "id": 1234567890, "topic": "Team Standup", "start_time": "2026-02-09T15:00:00Z", "password": "abc123", "pmi": false } ``` **Store:** meeting number, password, start time. ### Get OBF Token The bot joins "on behalf of" a Zoom user. Get the user's OBF token: ```bash # 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:** ```json { "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. ## Step 2: Join Meeting with Retry Logic ### Join Implementation ```cpp #include #include 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& password, const string& obfToken, const string& botDisplayName ) { for (int attempt = 1; attempt <= config.join_retry_attempts; attempt++) { cout << "[JOIN] Attempt " << attempt << "/" << config.join_retry_attempts << endl; // Attempt join JoinParam joinParam; joinParam.userType = SDK_UT_WITHOUT_LOGIN; JoinParam4WithoutLogin& param = joinParam.param.withoutloginuserJoin; param.meetingNumber = meetingNumber; param.userName = botDisplayName.c_str(); param.psw = password.c_str(); param.isVideoOff = true; // Bots typically don't send video param.isAudioOff = false; // Need audio for transcription param.app_privilege_token = obfToken.c_str(); // OBF token SDKError err = meetingService->Join(joinParam); if (err != SDKERR_SUCCESS) { cerr << "[JOIN] Join() call failed: " << err << endl; // Check if it's a retriable error if (shouldRetryJoin(err)) { if (attempt < config.join_retry_attempts) { cout << "[JOIN] Retrying in " << (config.join_retry_interval_ms / 1000) << "s..." << endl; this_thread::sleep_for( chrono::milliseconds(config.join_retry_interval_ms) ); continue; } } // Non-retriable error or out of attempts cerr << "[JOIN] Giving up after " << attempt << " attempts" << endl; return false; } // Join() call succeeded, wait for callback if (waitForJoinCallback()) { cout << "[JOIN] Successfully joined meeting!" << endl; return true; } else { cerr << "[JOIN] Join callback indicated failure" << endl; // If owner not present, retry if (ownerNotPresentError && attempt < config.join_retry_attempts) { cout << "[JOIN] Owner not present. Retrying in " << (config.join_retry_interval_ms / 1000) << "s..." << endl; this_thread::sleep_for( chrono::milliseconds(config.join_retry_interval_ms) ); continue; } } } cerr << "[JOIN] Failed to join after " << config.join_retry_attempts << " attempts" << endl; return false; } private: bool shouldRetryJoin(SDKError err) { switch (err) { case SDKERR_WRONG_USAGE: case SDKERR_INVALID_PARAMETER: case SDKERR_MODULE_LOAD_FAILED: return false; // Configuration errors, don't retry case SDKERR_NETWORK_ERROR: case SDKERR_SERVICE_FAILED: return true; // Network/server issues, retry default: return true; // Unknown error, retry to be safe } } bool waitForJoinCallback() { // Wait for onMeetingStatusChanged callback // Implementation depends on your callback handling // Typical: use condition variable with timeout std::unique_lock lock(callbackMutex); bool success = callbackCV.wait_for( lock, std::chrono::milliseconds(config.join_timeout_ms), [this]{ return joinSuccessful || ownerNotPresentError; } ); return success && joinSuccessful; } public: // Callback from SDK void onMeetingStatusChanged(MeetingStatus status, int iResult) { std::lock_guard lock(callbackMutex); if (status == MEETING_STATUS_INMEETING) { joinSuccessful = true; callbackCV.notify_one(); } else if (status == MEETING_STATUS_FAILED) { // Check error code for "owner not present" if (iResult == MEETING_FAIL_OBF_OWNER_NOT_IN_MEETING) { ownerNotPresentError = true; } joinSuccessful = false; callbackCV.notify_one(); } } private: std::mutex callbackMutex; std::condition_variable callbackCV; }; ``` ### Common Join Errors | 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) | ## Step 3: Start Raw Recording Once joined, request permission to access raw audio/video: ```cpp 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. ### Manage Recording Lifecycle Explicitly The bot should treat raw recording as a capability switch plus media subscriptions: ```cpp 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: - PCM audio -> WAV/FLAC encoder or streaming transcription pipeline - YUV420 video -> FFmpeg transcode to MP4 or frame-by-frame CV pipeline - Mixed bot pipeline -> raw capture first, then post-process after leave ## Step 4: Subscribe to Raw Audio/Video ```cpp 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; } } ``` ### Cloud Recording Alternative 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: ```bash 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.completed` - `zoom-rest-api` -> `GET /meetings/{meetingId}/recordings` or `GET /users/{userId}/recordings` Use this path when the desired output is Zoom-hosted MP4/M4A/transcript files rather than bot-owned raw PCM/YUV. ## Step 5: Mid-Meeting Reconnection ### Connection Monitoring ```cpp 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"); } } }; ``` ### Customizing Reconnection Behavior ```cpp // 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; ``` ## Recording Fallback: Local vs Cloud If raw recording fails, fall back to local or cloud recording: ```cpp 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; } ``` ## Complete Resilient Bot Example ```cpp 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; } ``` ## Comparison: Meeting SDK Bot vs RTMS Bot | 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:** - You need to interact with the meeting (chat, reactions) - You need local recording control - You want to be visible in participant list - You're processing your own meetings **Choose RTMS Bot when:** - You only need to observe/transcribe - You want minimal resource usage - You prefer invisible operation - You're processing external meetings ## Troubleshooting ### Bot Never Joins (OBF Owner Not Present) **Symptom:** All join attempts fail with "owner not in meeting" **Solution:** 1. Verify owner (OBF token holder) has joined the meeting 2. Increase `join_retry_attempts` or `join_retry_interval_ms` 3. Check meeting start time - don't attempt join before meeting starts 4. Consider webhook: Listen for `meeting.participant_joined` event for owner ### Raw Recording Permission Denied **Symptom:** `CanStartRawRecording()` returns error **Solution:** 1. Admin → Account Settings → Meeting → In Meeting (Advanced) 2. Enable "Allow access to raw data (audio, video, sharing) for Meeting SDK" 3. Requires "Raw Data" entitlement from Zoom support ### Frequent Disconnections During Meeting **Symptom:** Bot reconnects multiple times, then gives up **Solution:** 1. Increase `reconnect_max_attempts` (e.g., 5 instead of 3) 2. Increase `reconnect_base_delay_ms` if network is slow 3. Check server network stability 4. Monitor CPU/memory usage (insufficient resources can cause disconnects) ### OBF Token Expires During Meeting **Symptom:** Reconnection fails with "invalid token" **Solution:** 1. Fetch fresh OBF token in `attemptRejoin()` before rejoining 2. Monitor token expiration (`expire_in` from API response) 3. Request longer-lived tokens (max TTL: 7200s = 2 hours) ## Resources - **Meeting SDK Linux**: https://developers.zoom.us/docs/meeting-sdk/linux/ - **Raw Data Guide**: https://developers.zoom.us/docs/meeting-sdk/linux/add-features/raw-data/ - **OBF FAQ**: https://developers.zoom.us/docs/meeting-sdk/obf-faq/ - **REST API - Get Meeting**: https://developers.zoom.us/docs/api/rest/reference/zoom-api/methods/#operation/meeting - **REST API - Get OBF Token**: https://developers.zoom.us/docs/api/rest/reference/zoom-api/methods/#operation/userToken - **RTMS Bot Alternative**: [../../rtms/examples/rtms-bot.md](../../rtms/examples/rtms-bot.md)