Subchapter 134.6
references/webhooks.mdMarkdown7 KBView on GitHub
The #1 cause of random disconnects:
If your webhook handler takes too long to respond, Zoom assumes failure and retries. The retry creates a second connection, which kicks out your first connection (only 1 connection allowed per stream).
// CORRECT: Respond first, process async
app.post('/webhook', (req, res) => {
res.status(200).send(); // IMMEDIATELY!
// Then process asynchronously
handleRTMSEvent(req.body);
});
// WRONG: Processing before responding
app.post('/webhook', async (req, res) => {
await heavyProcessing(req.body); // Zoom may retry while waiting!
res.status(200).send();
});When configuring your webhook URL, Zoom sends a validation challenge:
app.post('/webhook', (req, res) => {
const { event, payload } = req.body;
// Handle URL validation
if (event === 'endpoint.url_validation') {
const hash = crypto
.createHmac('sha256', process.env.ZOOM_SECRET_TOKEN)
.update(payload.plainToken)
.digest('hex');
return res.json({
plainToken: payload.plainToken,
encryptedToken: hash
});
}
res.status(200).send();
// ... handle other events
});Sent when RTMS stream is ready for a meeting.
{
"event": "meeting.rtms_started",
"payload": {
"account_id": "account_id",
"object": {
"meeting_id": "meeting_id",
"meeting_uuid": "meeting_uuid",
"host_id": "host_user_id",
"rtms_stream_id": "stream_id",
"server_urls": "wss://rtms-sjc1.zoom.us/...",
"signature": "auth_signature"
}
}
}Sent when RTMS stream ends.
{
"event": "meeting.rtms_stopped",
"payload": {
"account_id": "account_id",
"object": {
"meeting_id": "meeting_id",
"rtms_stream_id": "stream_id"
}
}
}Sent when RTMS stream is ready for a webinar.
{
"event": "webinar.rtms_started",
"payload": {
"account_id": "account_id",
"object": {
"meeting_id": "meeting_id",
"meeting_uuid": "meeting_uuid",
"host_id": "host_user_id",
"rtms_stream_id": "stream_id",
"server_urls": "wss://rtms-sjc1.zoom.us/...",
"signature": "auth_signature"
}
}
}Important: Webinar payloads use
meeting_uuid, NOTwebinar_uuid. The signature and connection flow are identical to meetings.
Webinar-specific considerations:
Sent when RTMS stream ends for a webinar.
{
"event": "webinar.rtms_stopped",
"payload": {
"account_id": "account_id",
"object": {
"meeting_id": "meeting_id",
"rtms_stream_id": "stream_id"
}
}
}Sent when RTMS stream is ready for a Video SDK session.
{
"event": "session.rtms_started",
"payload": {
"account_id": "account_id",
"object": {
"session_id": "session_id",
"rtms_stream_id": "stream_id",
"server_urls": "wss://rtms-sjc1.zoom.us/...",
"signature": "auth_signature"
}
}
}Important: Video SDK payloads use
session_idinstead ofmeeting_uuid. The HMAC signature must usesession_idin place ofmeeting_uuid.
Video SDK-specific considerations:
Sent when RTMS stream ends for a Video SDK session.
{
"event": "session.rtms_stopped",
"payload": {
"account_id": "account_id",
"object": {
"session_id": "session_id",
"rtms_stream_id": "stream_id"
}
}
}Subscribe to receive SHARING_START and SHARING_STOP events when participants start/stop screen sharing.
| Field | Description |
|---|---|
rtms_stream_id | Unique stream identifier |
server_urls | WebSocket signaling server URL |
meeting_uuid | Meeting unique identifier (needed for signature) |
signature | Pre-computed auth signature (alternative to self-generating) |
Server URLs contain airport/region codes:
| Code | Location |
|---|---|
sjc | San Jose, California |
iad | Washington DC |
sin | Singapore |
fra | Frankfurt, Germany |
syd | Sydney, Australia |
// Extract region from server URL
const hostname = new URL(serverUrl).hostname; // rtms-sjc1.zoom.us
const region = hostname.split('-')[1].replace(/[0-9]/g, ''); // sjcTip: For production, route webhooks to workers in the same region as the Zoom server.
meeting.rtms_startedmeeting.rtms_stoppedwebinar.rtms_started (if using webinars)webinar.rtms_stopped (if using webinars)session.rtms_startedsession.rtms_stoppedFor Meetings (Features → Scopes → Add Scopes → search “rtms”):
| Scope | Purpose |
|---|---|
meeting:read:meeting_audio | Access meeting audio |
meeting:read:meeting_video | Access meeting video |
meeting:read:meeting_transcript | Access transcripts |
meeting:read:meeting_chat | Access chat messages |
For Webinars (add these in addition to meeting scopes):
| Scope | Purpose |
|---|---|
webinar:read:webinar_audio | Access webinar audio |
webinar:read:webinar_video | Access webinar video |
webinar:read:webinar_transcript | Access webinar transcripts |
webinar:read:webinar_chat | Access webinar chat messages |
For Video SDK: Uses SDK Key/Secret credentials instead of OAuth scopes.
| Product | Start Event | Stop Event | Payload ID | App Type |
|---|---|---|---|---|
| Zoom Meetings | meeting.rtms_started | meeting.rtms_stopped | meeting_uuid | General App |
| Zoom Webinars | webinar.rtms_started | webinar.rtms_stopped | meeting_uuid (not webinar_uuid!) | General App |
| Zoom Video SDK | session.rtms_started | session.rtms_stopped | session_id | Video SDK App |
| Zoom Contact Center | contactcenter.rtms_* | contactcenter.rtms_* | See Zoom docs | Contact Center App |
| Zoom Phone | phone.rtms_* | phone.rtms_* | See Zoom docs | General App |
Key differences: Meetings and webinars use a General App with OAuth credentials. Video SDK uses a Video SDK App with SDK Key/Secret. Once connected, the WebSocket protocol is identical across all products.