Files
2026-07-13 12:20:06 +08:00

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, NOT webinar_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_id instead of meeting_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