14 KiB
RTMS Lifecycle Flow
Complete flow from meeting/webinar/session start to media streaming.
High-Level Flow
┌─────────────────────────────┐
│ Meeting/Webinar/Session │
│ Starts │
└────────────┬────────────────┘
│
▼
┌─────────────────────────────┐
│ Zoom sends webhook event │
│ meeting.rtms_started OR │
│ webinar.rtms_started OR │
│ session.rtms_started │
└────────────┬────────────────┘
│
▼
┌──────────────────┐
│ Your server │
│ receives │
│ webhook │
│ │
│ RESPOND 200 │
│ IMMEDIATELY! │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Connect to │
│ Signaling WS │
│ │
│ Send handshake │
│ (msg_type: 1) │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Receive │
│ handshake resp │
│ (msg_type: 2) │
│ │
│ Extract media │
│ server URL │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Connect to │
│ Media WS │
│ │
│ Send handshake │
│ (msg_type: 3) │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Receive media │
│ handshake resp │
│ (msg_type: 4) │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Send Client │
│ Ready to │
│ Signaling │
│ (msg_type: 7) │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Receive media │
│ data: │
│ - Audio (14) │
│ - Video (15) │
│ - Share (16) │
│ - Transcript(17)│
│ - Chat (18) │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Respond to │
│ heartbeats │
│ (12 -> 13) │
└────────┬─────────┘
│
▼
┌─────────────────────────────┐
│ Optional control-plane │
│ actions during stream │
│ - EVENT_SUBSCRIPTION │
│ - VIDEO_SUBSCRIPTION_REQ │
│ - STREAM_CLOSE_REQ │
└────────────┬────────────────┘
│
▼
┌─────────────────────────────┐
│ meeting/webinar/session │
│ .rtms_stopped │
│ │
│ Close sockets │
│ Cleanup │
└─────────────────────────────┘
Detailed Steps
Step 1: Receive Webhook
When RTMS starts, Zoom sends a webhook. The event name and payload differ by product:
Meeting RTMS:
{
"event": "meeting.rtms_started",
"payload": {
"account_id": "abc123",
"object": {
"meeting_id": "123456789",
"meeting_uuid": "AbC123...",
"host_id": "user123",
"rtms_stream_id": "stream123==",
"server_urls": "wss://rtms-sjc1.zoom.us/...",
"signature": "pre_computed_signature"
}
}
}
Webinar RTMS:
{
"event": "webinar.rtms_started",
"payload": {
"account_id": "abc123",
"object": {
"meeting_id": "123456789",
"meeting_uuid": "AbC123...",
"host_id": "user123",
"rtms_stream_id": "stream123==",
"server_urls": "wss://rtms-sjc1.zoom.us/...",
"signature": "pre_computed_signature"
}
}
}
Note
: Webinar payloads use
meeting_uuid, NOTwebinar_uuid.
Video SDK RTMS:
{
"event": "session.rtms_started",
"payload": {
"account_id": "abc123",
"object": {
"session_id": "SessionABC...",
"rtms_stream_id": "stream123==",
"server_urls": "wss://rtms-sjc1.zoom.us/...",
"signature": "pre_computed_signature"
}
}
}
Note
: Video SDK payloads use
session_idinstead ofmeeting_uuid.
Product Differences
| Aspect | Meetings | Webinars | Video SDK |
|---|---|---|---|
| Webhook event | meeting.rtms_started |
webinar.rtms_started |
session.rtms_started |
| Payload ID field | meeting_uuid |
meeting_uuid (same!) |
session_id |
| App type | General App (OAuth) | General App (OAuth) | Video SDK App (SDK Key/Secret) |
| Participants | All participants | Panelists have full streams; attendees may not | All participants |
| Protocol after connect | Identical | Identical | Identical |
CRITICAL: Respond with HTTP 200 IMMEDIATELY before any processing!
const RTMS_EVENTS = ['meeting.rtms_started', 'webinar.rtms_started', 'session.rtms_started'];
app.post('/webhook', (req, res) => {
res.status(200).send(); // FIRST!
const { event, payload } = req.body;
if (RTMS_EVENTS.includes(event)) {
handleRTMSStarted(payload);
}
});
Why? If you delay, Zoom retries the webhook. The retry creates a second connection, which kicks out your first connection.
Step 2: Connect to Signaling WebSocket
const signalingWs = new WebSocket(payload.server_urls);
// Use meeting_uuid for meetings/webinars, session_id for Video SDK
const idValue = payload.meeting_uuid || payload.session_id;
signalingWs.on('open', () => {
const signature = generateSignature(
CLIENT_ID,
idValue,
payload.rtms_stream_id,
CLIENT_SECRET
);
signalingWs.send(JSON.stringify({
msg_type: 1, // Handshake request
protocol_version: 1,
meeting_uuid: idValue,
rtms_stream_id: payload.rtms_stream_id,
signature: signature,
media_type: 9 // Audio(1) + Transcript(8)
}));
});
Step 3: Handle Signaling Response
signalingWs.on('message', (data) => {
const msg = JSON.parse(data);
switch (msg.msg_type) {
case 2: // Handshake response
if (msg.status_code === 0) {
// Extract media server URL
const mediaUrl = msg.media_server.server_urls.all;
connectToMediaServer(mediaUrl);
} else {
console.error('Handshake failed:', msg.status_code);
}
break;
case 12: // Keep alive request
signalingWs.send(JSON.stringify({
msg_type: 13,
timestamp: msg.timestamp
}));
break;
}
});
Step 4: Connect to Media WebSocket
function connectToMediaServer(mediaUrl) {
const mediaWs = new WebSocket(mediaUrl);
mediaWs.on('open', () => {
mediaWs.send(JSON.stringify({
msg_type: 3, // Media handshake request
protocol_version: 1,
meeting_uuid: idValue, // meeting_uuid or session_id
rtms_stream_id: streamId,
signature: signature,
media_type: 9, // Audio + Transcript
payload_encryption: false,
media_params: {
audio: {
content_type: 2, // RAW_AUDIO
sample_rate: 1, // 16kHz
channel: 1, // Mono
codec: 1, // L16 (PCM)
data_opt: 1, // Mixed stream
send_rate: 20 // 20ms chunks
},
transcript: {
content_type: 5, // TEXT
src_language: 9, // English
enable_lid: false // Fixed language, no auto-switch
}
}
}));
});
}
Step 5: Start Streaming
After media handshake succeeds, tell signaling you're ready:
mediaWs.on('message', (data) => {
const msg = JSON.parse(data);
if (msg.msg_type === 4 && msg.status_code === 0) {
// Media handshake success - tell signaling we're ready
signalingWs.send(JSON.stringify({
msg_type: 7, // Client ready
rtms_stream_id: streamId
}));
}
});
Step 6: Receive Media Data
mediaWs.on('message', (data) => {
const msg = JSON.parse(data);
switch (msg.msg_type) {
case 14: // Audio
const audioBuffer = Buffer.from(msg.content, 'base64');
processAudio(audioBuffer, msg.user_name, msg.timestamp);
break;
case 15: // Video
const videoBuffer = Buffer.from(msg.content, 'base64');
processVideo(videoBuffer, msg.user_name, msg.timestamp);
break;
case 16: // Screen share
const shareBuffer = Buffer.from(msg.content, 'base64');
processScreenShare(shareBuffer, msg.user_name, msg.timestamp);
break;
case 17: // Transcript
console.log(`${msg.user_name}: ${msg.content}`);
break;
case 18: // Chat
console.log(`[Chat] ${msg.user_name}: ${msg.content}`);
break;
case 12: // Keep alive
mediaWs.send(JSON.stringify({
msg_type: 13,
timestamp: msg.timestamp
}));
break;
}
});
Step 6A: Track Available Participant Video Streams
When using the new single-individual-video mode, the signaling socket tells you whose camera is currently available.
const activeVideoUsers = new Set();
function handleEventUpdate(msg) {
const eventType = msg.event?.event_type;
const participants = msg.event?.participants || [];
if (eventType === 8) { // PARTICIPANT_VIDEO_ON
for (const participant of participants) activeVideoUsers.add(participant.user_id);
}
if (eventType === 9) { // PARTICIPANT_VIDEO_OFF
for (const participant of participants) activeVideoUsers.delete(participant.user_id);
}
}
Use these events as the control-plane signal for which participant video streams are currently subscribable.
Step 6B: Select One Participant Video Stream
function subscribeToParticipantVideo(streamId, userId) {
const signalingWs = signalingConnections.get(streamId);
if (!signalingWs) return;
signalingWs.send(JSON.stringify({
msg_type: 28, // VIDEO_SUBSCRIPTION_REQ
user_id: userId,
subscribe: true,
timestamp: Date.now()
}));
}
Important constraint:
- only one participant stream can be active at a time
- the newest successful subscription replaces the previous selection
Step 7: Handle Session End
const RTMS_STOP_EVENTS = ['meeting.rtms_stopped', 'webinar.rtms_stopped', 'session.rtms_stopped'];
// Via webhook
app.post('/webhook', (req, res) => {
res.status(200).send();
const { event, payload } = req.body;
if (RTMS_STOP_EVENTS.includes(event)) {
const streamId = payload.rtms_stream_id;
// Close connections
signalingConnections.get(streamId)?.close();
mediaConnections.get(streamId)?.close();
// Cleanup
signalingConnections.delete(streamId);
mediaConnections.delete(streamId);
}
});
// Also handle WebSocket close events
signalingWs.on('close', (code, reason) => {
console.log('Signaling closed:', code, reason);
// Implement reconnection if needed
});
Optional: Client-Initiated Graceful Close
The backend can now ask RTMS to terminate the stream cleanly:
function closeStream(streamId) {
const signalingWs = signalingConnections.get(streamId);
if (!signalingWs) return;
signalingWs.send(JSON.stringify({
msg_type: 21, // STREAM_CLOSE_REQ
rtms_stream_id: streamId
}));
}
Expect a STREAM_CLOSE_RESP followed by normal socket teardown.
Session Tracking
CRITICAL: Track active sessions to prevent duplicate connections!
const activeSessions = new Map();
function handleRTMSStarted(payload) {
const streamId = payload.rtms_stream_id;
// Check for existing connection
if (activeSessions.has(streamId)) {
console.log('Already connected to this stream, ignoring');
return;
}
// Mark as active (meeting_uuid for meetings/webinars, session_id for Video SDK)
activeSessions.set(streamId, {
startTime: Date.now(),
idValue: payload.meeting_uuid || payload.session_id
});
// Connect
connectToRTMS(payload);
}
function handleRTMSStopped(payload) {
const streamId = payload.rtms_stream_id;
activeSessions.delete(streamId);
// ... cleanup
}
Error Handling
// SDK state management (from Arlo sample)
try {
client.join(payload);
} catch (error) {
if (error.message?.includes('Invalid status')) {
console.warn('SDK in invalid state, waiting to retry...');
setTimeout(() => {
handleRTMSStarted(payload);
}, 2000);
}
}
Next Steps
- SDK Quickstart - SDK handles all this automatically
- Manual WebSocket - Full implementation code
- Common Issues - Debugging connection problems