Skill 132 · Build Zoom REST API App
Subchapter 132.50
examples/webhook-server.mdMarkdown14 KBView on GitHub
Production-ready webhook server implementation for receiving Zoom webhook events with CRC (Challenge-Response Check) validation and HMAC signature verification.
For comprehensive webhook documentation, see the webhooks skill.
npm install express body-parser cryptoconst express = require('express');
const crypto = require('crypto');
const app = express();
// Zoom webhook secret token (from your app's Feature page)
const WEBHOOK_SECRET_TOKEN = process.env.ZOOM_WEBHOOK_SECRET;
// Parse JSON bodies
app.use(express.json());
// Webhook endpoint
app.post('/webhook', (req, res) => {
const { event, payload } = req.body;
// Handle CRC validation (Challenge-Response Check)
if (event === 'endpoint.url_validation') {
return handleCRC(req, res);
}
// Verify signature
if (!verifySignature(req)) {
console.error('Invalid signature');
return res.status(401).send('Unauthorized');
}
// Handle events
handleEvent(event, payload);
// Always respond with 200 within 3 seconds
res.status(200).send();
});
app.listen(3000, () => {
console.log('Webhook server running on port 3000');
});When you add a webhook URL or make changes, Zoom sends a validation request. You must respond within 3 seconds.
event: "endpoint.url_validation"plainToken using your webhook secretplainToken and encryptedTokenfunction handleCRC(req, res) {
const { plainToken } = req.body.payload;
// Hash the plainToken with HMAC-SHA256
const encryptedToken = crypto
.createHmac('sha256', WEBHOOK_SECRET_TOKEN)
.update(plainToken)
.digest('hex');
// Respond within 3 seconds
res.status(200).json({
plainToken,
encryptedToken
});
console.log('CRC validation successful');
}{
"event": "endpoint.url_validation",
"payload": {
"plainToken": "qgg8vlvZRS6UYooatFL8Aw"
},
"event_ts": 1654503849680
}{
"plainToken": "qgg8vlvZRS6UYooatFL8Aw",
"encryptedToken": "23a89b634c017e5364a1c8d9c8ea909b60dd5599e2bb04bb1558d9c3a121faa5"
}Verify that webhook requests actually come from Zoom by checking the HMAC signature.
x-zm-signature and x-zm-request-timestamp headersv0:{timestamp}:{body}v0= to the hashx-zm-signature headerfunction verifySignature(req) {
const signature = req.headers['x-zm-signature'];
const timestamp = req.headers['x-zm-request-timestamp'];
if (!signature || !timestamp) {
console.error('Missing signature headers');
return false;
}
// Construct the message
const message = `v0:${timestamp}:${JSON.stringify(req.body)}`;
// Hash the message
const hashForVerify = crypto
.createHmac('sha256', WEBHOOK_SECRET_TOKEN)
.update(message)
.digest('hex');
// Prepend v0=
const computedSignature = `v0=${hashForVerify}`;
// Compare signatures
return signature === computedSignature;
}POST /webhook HTTP/1.1
Host: example.com
x-zm-signature: v0=a05d830fa017433bc47887f835a00b9ff33d3882f22f63a2986a8es270341
x-zm-request-timestamp: 1658940994
Content-Type: application/json
{"event":"meeting.started","payload":{...}}function handleEvent(event, payload) {
switch (event) {
case 'meeting.created':
handleMeetingCreated(payload);
break;
case 'meeting.started':
handleMeetingStarted(payload);
break;
case 'meeting.ended':
handleMeetingEnded(payload);
break;
case 'meeting.participant_joined':
handleParticipantJoined(payload);
break;
case 'recording.completed':
handleRecordingCompleted(payload);
break;
default:
console.log(`Unhandled event: ${event}`);
}
}function handleMeetingStarted(payload) {
const { id, uuid, topic, start_time } = payload.object;
console.log(`Meeting started: ${topic} (ID: ${id})`);
// Your logic: Send notifications, start recording, etc.
// Example: Trigger auto-recording
// await startCloudRecording(id);
}
function handleMeetingEnded(payload) {
const { id, uuid, topic, duration } = payload.object;
console.log(`Meeting ended: ${topic} (Duration: ${duration}min)`);
// Your logic: Process analytics, trigger workflows, etc.
}
function handleRecordingCompleted(payload) {
const { id, uuid, topic, recording_files } = payload.object;
console.log(`Recording ready: ${topic}`);
// Download recordings (see recording-pipeline.md)
recording_files.forEach(file => {
console.log(`- ${file.file_type}: ${file.download_url}`);
// downloadRecording(file.download_url, file.id);
});
}
function handleParticipantJoined(payload) {
const { participant } = payload.object;
console.log(`Participant joined: ${participant.user_name}`);
// Your logic: Track attendance, send welcome message, etc.
}const express = require('express');
const crypto = require('crypto');
const app = express();
const WEBHOOK_SECRET_TOKEN = process.env.ZOOM_WEBHOOK_SECRET;
const PORT = process.env.PORT || 3000;
// Middleware
app.use(express.json());
Zoom automatically retries failed webhooks 3 times with exponential backoff:
Zoom retries for:
Zoom does NOT retry for:
// Track processed events to avoid duplicate processing
const processedEvents = new Set();
app.post('/webhook', (req, res) => {
const { event, event_ts, payload } = req.body;
// Create unique event ID
const eventId = `${event}-${event_ts}-${payload.object?.id || ''}`;
// Check if already processed (duplicate due to retry)
if (processedEvents.has(eventId)) {
console.log(`Duplicate event: ${eventId}`);
return res.status(200).send(); // Still return 200
}
// Mark as processed
processedEvents.add(eventId);
// Handle event
handleEvent(event, payload);
res.status(200).send();
// Clean up old entries after 2 hours
setTimeout(() => processedEvents.delete(eventId), 2 * 60 * 60 * 1000);
});Zoom automatically revalidates webhook endpoints every 72 hours. If revalidation fails 6 consecutive times, Zoom disables the webhook.
// Health check with monitoring
app.get('/health', (req, res) => {
// Check dependencies (database, external APIs, etc.)
const isHealthy = checkDependencies();
if (isHealthy) {
res.status(200).json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime()
});
} else {
res.status(503).json({
status: 'unhealthy',
timestamp: new Date().toISOString()
});
}
});
function checkDependencies() {
// Check database connection, external APIs, etc.
return true;
}# .env
ZOOM_WEBHOOK_SECRET=your_webhook_secret_token_here
PORT=3000
NODE_ENV=productionrequire('dotenv').config();
const WEBHOOK_SECRET_TOKEN = process.env.ZOOM_WEBHOOK_SECRET;
if (!WEBHOOK_SECRET_TOKEN) {
throw new Error('ZOOM_WEBHOOK_SECRET environment variable is required');
}git push heroku main# Install ngrok
npm install -g ngrok
# Start your server
node server.js
# In another terminal, expose to public URL
ngrok http 3000
# Use the HTTPS URL in Zoom webhook configuration
# Example: https://abc123.ngrok.io/webhookWEBHOOK_BASE_URL="http://YOUR_DEV_HOST:3000"
curl -X POST "$WEBHOOK_BASE_URL/webhook" \
-H "Content-Type: application/json" \
-d '{
"event": "endpoint.url_validation",
"payload": {
"plainToken": "test_token_123"
},
"event_ts": 1654503849680
}'Expected response:
{
"plainToken": "test_token_123",
"encryptedToken": "..."
}# Generate valid signature
TIMESTAMP=$(date +%s)
MESSAGE="v0:${TIMESTAMP}:{\"event\":\"meeting.started\",\"payload\":{\"object\":{\"id\":\"123\",\"topic\":\"Test\"}}}"
SIGNATURE="v0=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "YOUR_SECRET" -binary | xxd -p)"
WEBHOOK_BASE_URL="http://YOUR_DEV_HOST:3000"
curl -X POST "$WEBHOOK_BASE_URL/webhook" \
-H "Content-Type: application/json" \
-H "x-zm-signature: $SIGNATURE" \
-H "x-zm-request-timestamp: $TIMESTAMP" \
-d '{"event":"meeting.started","payload":{"object":{"id":"123","topic":"Test Meeting"}}}'| Event | Description |
|---|---|
meeting.created | Meeting created |
meeting.updated | Meeting details changed |
meeting.deleted | Meeting deleted |
meeting.started | Meeting begins |
meeting.ended | Meeting ends |
meeting.participant_joined | Participant joins |
meeting.participant_left | Participant leaves |
recording.completed | Cloud recording ready |
recording.transcript_completed | Transcript ready |
user.created | User created |
user.updated | User updated |
user.deleted | User deleted |
See complete event catalog: webhooks skill