# RTMS - Connection WebSocket connection protocol details. ## Connection Flow ``` 1. Receive meeting/webinar/session.rtms_started webhook ↓ 2. Extract server_urls, stream_id, and meeting_uuid or session_id ↓ 3. Generate signature (HMAC-SHA256) using meeting_uuid or session_id ↓ 4. Connect to signaling WebSocket ↓ 5. Send handshake request (msg_type 1) ↓ 6. Receive handshake response (msg_type 2) with media server URL ↓ 7. Connect to media WebSocket(s) ↓ 8. Send media handshake (msg_type 3) ↓ 9. Receive media handshake response (msg_type 4) ↓ 10. Send ready to receive (msg_type 7) ↓ 11. Receive media data (msg_type 14-18) ↓ 12. Respond to heartbeats (msg_type 12 → 13) ↓ 13. Optionally react to `PARTICIPANT_VIDEO_ON/OFF`, send `VIDEO_SUBSCRIPTION_REQ`, or gracefully terminate with `STREAM_CLOSE_REQ` ``` ## Signature Generation ```javascript const crypto = require('crypto'); // For meetings and webinars: use meeting_uuid // For Video SDK: use session_id // Webinars still use meeting_uuid (NOT webinar_uuid) function generateSignature(clientId, idValue, streamId, clientSecret) { const message = `${clientId},${idValue},${streamId}`; return crypto.createHmac('sha256', clientSecret).update(message).digest('hex'); } // Extract the correct ID from any product's webhook payload const idValue = payload.meeting_uuid || payload.session_id; ``` ## Signaling Message Types | msg_type | Name | Direction | Description | |----------|------|-----------|-------------| | 1 | Handshake Request | Client → Server | Initiate connection | | 2 | Handshake Response | Server → Client | Returns media server URL | | 3 | Media Handshake Request | Client → Server | Request specific media types | | 4 | Media Handshake Response | Server → Client | Confirms media subscription | | 7 | Ready to Receive | Client → Server | Signal ready for data | | 12 | Keep Alive Request | Server → Client | Heartbeat ping | | 13 | Keep Alive Response | Client → Server | Heartbeat pong | ## Media Message Types | msg_type | Media Type | |----------|------------| | 14 | Audio | | 15 | Video | | 16 | Screen Share | | 17 | Transcript | | 18 | Chat | ## Critical Gotchas ### 1. Only ONE Connection Per Stream! ```javascript // WRONG - Connecting twice kicks out first connection connectToRTMS(serverUrl, streamId); // Connection 1 connectToRTMS(serverUrl, streamId); // Connection 2 - kicks out Connection 1! // CORRECT - Only connect once if (!activeConnections.has(streamId)) { connectToRTMS(serverUrl, streamId); activeConnections.set(streamId, ws); } ``` ### 2. Heartbeat is MANDATORY When you receive msg_type 12, you MUST respond with msg_type 13: ```javascript ws.on('message', (data) => { const msg = JSON.parse(data); if (msg.msg_type === 12) { // Keep Alive Request ws.send(JSON.stringify({ msg_type: 13, // Keep Alive Response timestamp: msg.timestamp })); } }); ``` ### 3. Reconnection is YOUR Responsibility RTMS does NOT auto-reconnect. Implement your own retry logic: | Server Type | Timeout | |-------------|---------| | Media Server | 65 seconds keep-alive tolerance before timeout | | Signaling Server | 60 seconds to reconnect | ```javascript ws.on('close', () => { // Implement exponential backoff setTimeout(() => reconnect(), retryDelay); retryDelay = Math.min(retryDelay * 2, 30000); }); ``` ## Transcript LID Control The transcript media handshake now supports explicit Language Identification control. ```javascript mediaWs.send(JSON.stringify({ msg_type: 3, protocol_version: 1, meeting_uuid: idValue, rtms_stream_id: streamId, signature, media_type: 8, // TRANSCRIPT media_params: { transcript: { content_type: 5, // TEXT src_language: 9, // English enable_lid: false // Lock to src_language instead of auto-switching } } })); ``` Use `enable_lid: false` when: - the meeting should stay on a known language - language-switching is undesirable - you want more predictable downstream transcript processing ## Single Individual Video Subscription Flow RTMS now supports subscribing to **one participant camera stream at a time**. 1. Open a video media socket with `data_opt = VIDEO_SINGLE_INDIVIDUAL_STREAM` 2. Subscribe to `PARTICIPANT_VIDEO_ON` and `PARTICIPANT_VIDEO_OFF` 3. When an event arrives, choose the `user_id` you want 4. Send `VIDEO_SUBSCRIPTION_REQ` on the signaling socket 5. Wait for `VIDEO_SUBSCRIPTION_RESP` 6. Expect the newest successful subscription to replace the previous participant stream ```javascript // Signaling socket: subscribe to control-plane events signalingWs.send(JSON.stringify({ msg_type: 5, // EVENT_SUBSCRIPTION events: [ { event_type: 8, subscribe: true }, // PARTICIPANT_VIDEO_ON { event_type: 9, subscribe: true } // PARTICIPANT_VIDEO_OFF ] })); // Signaling socket: select a participant stream signalingWs.send(JSON.stringify({ msg_type: 28, // VIDEO_SUBSCRIPTION_REQ user_id: selectedUserId, subscribe: true, timestamp: Date.now() })); ``` The March 2026 changelog did not publish the numeric values for the new message types. Use the protocol definitions before hard-coding them. ## Graceful Stream Closure The backend can now request clean shutdown over the signaling socket: ```javascript signalingWs.send(JSON.stringify({ msg_type: 21, // STREAM_CLOSE_REQ rtms_stream_id: streamId })); ``` Expect: - `STREAM_CLOSE_RESP` - then normal connection shutdown / cleanup Use this when your app wants deterministic teardown instead of waiting for a stop webhook or socket failure. ## Split vs Unified Mode | Mode | Description | Best For | |------|-------------|----------| | **Split** | One connection per media type | Most use cases. Media server supports multiple connections with different media types | | **Unified** | One connection for all media | Real-time audio+video streaming/muxing where sync matters | ## Low-Level Connection Example ```javascript const WebSocket = require('ws'); const crypto = require('crypto'); async function connectRTMS(webhookPayload) { const { server_urls, rtms_stream_id } = webhookPayload; // meeting_uuid for meetings/webinars, session_id for Video SDK const idValue = webhookPayload.meeting_uuid || webhookPayload.session_id; // Generate signature const signature = crypto .createHmac('sha256', process.env.ZOOM_CLIENT_SECRET) .update(`${process.env.ZOOM_CLIENT_ID},${idValue},${rtms_stream_id}`) .digest('hex'); // Connect to signaling server const signalingWs = new WebSocket(server_urls, { headers: { 'X-Zoom-RTMS-Stream-Id': rtms_stream_id, 'X-Zoom-RTMS-Signature': signature } }); signalingWs.on('open', () => { // Send handshake request signalingWs.send(JSON.stringify({ msg_type: 1, protocol_version: 1, client_id: process.env.ZOOM_CLIENT_ID, meeting_uuid: idValue, // Works for both meeting_uuid and session_id stream_id: rtms_stream_id, signature: signature, media_type: 9 // AUDIO(1) | TRANSCRIPT(8) })); }); signalingWs.on('message', (data) => { const msg = JSON.parse(data); switch (msg.msg_type) { case 2: // Handshake response // Connect to media server from msg.media_server_url connectMediaServer(msg.media_server_url); break; case 12: // Keep alive request signalingWs.send(JSON.stringify({ msg_type: 13, timestamp: msg.timestamp })); break; } }); signalingWs.on('error', (error) => { console.error('Signaling error:', error); }); signalingWs.on('close', (code, reason) => { console.log('Signaling closed:', code, reason); // Implement reconnection logic }); } ``` ## Resources - **RTMS_CONNECTION_FLOW.md**: https://github.com/zoom/rtms-samples/blob/main/RTMS_CONNECTION_FLOW.md - **ARCHITECTURE.md**: https://github.com/zoom/rtms-samples/blob/main/ARCHITECTURE.md - **TROUBLESHOOTING.md**: https://github.com/zoom/rtms-samples/blob/main/TROUBLESHOOTING.md