14 KiB
14 KiB
Webhook Server - Express.js with CRC Validation and Signature Verification
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.
Quick Start
1. Install Dependencies
npm install express body-parser crypto
2. Basic Webhook Server
const 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');
});
CRC (Challenge-Response Check) Validation
When you add a webhook URL or make changes, Zoom sends a validation request. You must respond within 3 seconds.
CRC Flow
- Zoom sends POST with
event: "endpoint.url_validation" - Your server hashes the
plainTokenusing your webhook secret - Respond with JSON containing both
plainTokenandencryptedToken
Implementation
function 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');
}
CRC Request Example
{
"event": "endpoint.url_validation",
"payload": {
"plainToken": "qgg8vlvZRS6UYooatFL8Aw"
},
"event_ts": 1654503849680
}
CRC Response Example
{
"plainToken": "qgg8vlvZRS6UYooatFL8Aw",
"encryptedToken": "23a89b634c017e5364a1c8d9c8ea909b60dd5599e2bb04bb1558d9c3a121faa5"
}
Signature Verification
Verify that webhook requests actually come from Zoom by checking the HMAC signature.
Signature Verification Flow
- Extract
x-zm-signatureandx-zm-request-timestampheaders - Construct message:
v0:{timestamp}:{body} - Hash message with HMAC-SHA256 using your webhook secret
- Prepend
v0=to the hash - Compare with
x-zm-signatureheader
Implementation
function 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;
}
Signature Headers Example
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":{...}}
Event Handling
Event Router
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}`);
}
}
Event Handlers
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.
}
Complete Production Server
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());
// Request logging
app.use((req, res, next) => {
console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`);
next();
});
// Webhook endpoint
app.post('/webhook', async (req, res) => {
try {
const { event, payload, event_ts } = req.body;
// CRC validation
if (event === 'endpoint.url_validation') {
return handleCRC(req, res);
}
// Verify signature
if (!verifySignature(req)) {
console.error('Signature verification failed');
return res.status(401).send('Unauthorized');
}
// Log event
console.log(`Event received: ${event} at ${new Date(event_ts)}`);
// Handle event asynchronously
setImmediate(() => {
handleEvent(event, payload).catch(error => {
console.error('Error handling event:', error);
});
});
// Respond immediately (within 3 seconds)
res.status(200).send();
} catch (error) {
console.error('Webhook error:', error);
res.status(500).send('Internal Server Error');
}
});
// CRC validation
function handleCRC(req, res) {
const { plainToken } = req.body.payload;
const encryptedToken = crypto
.createHmac('sha256', WEBHOOK_SECRET_TOKEN)
.update(plainToken)
.digest('hex');
res.status(200).json({ plainToken, encryptedToken });
console.log('CRC validation successful');
}
// Signature verification
function verifySignature(req) {
const signature = req.headers['x-zm-signature'];
const timestamp = req.headers['x-zm-request-timestamp'];
if (!signature || !timestamp) {
return false;
}
const message = `v0:${timestamp}:${JSON.stringify(req.body)}`;
const hashForVerify = crypto
.createHmac('sha256', WEBHOOK_SECRET_TOKEN)
.update(message)
.digest('hex');
const computedSignature = `v0=${hashForVerify}`;
return signature === computedSignature;
}
// Event handler
async function handleEvent(event, payload) {
switch (event) {
case 'meeting.started':
await handleMeetingStarted(payload);
break;
case 'meeting.ended':
await handleMeetingEnded(payload);
break;
case 'recording.completed':
await handleRecordingCompleted(payload);
break;
// Add more event handlers as needed
default:
console.log(`Unhandled event: ${event}`);
}
}
async function handleMeetingStarted(payload) {
console.log(`Meeting started: ${payload.object.topic}`);
// Your business logic
}
async function handleMeetingEnded(payload) {
console.log(`Meeting ended: ${payload.object.topic}`);
// Your business logic
}
async function handleRecordingCompleted(payload) {
console.log(`Recording completed: ${payload.object.topic}`);
// Download logic (see recording-pipeline.md)
}
// Health check
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Start server
app.listen(PORT, () => {
console.log(`Webhook server running on port ${PORT}`);
console.log(`Webhook endpoint: ${process.env.PUBLIC_BASE_URL || 'https://YOUR_PUBLIC_BASE_URL'}/webhook`);
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully');
process.exit(0);
});
Webhook Retry Policy
Zoom automatically retries failed webhooks 3 times with exponential backoff:
- First retry: 5 minutes after initial failure
- Second retry: 20 minutes after first retry
- Third retry: 60 minutes after second retry
Retry Conditions
Zoom retries for:
- HTTP status codes ≥ 500
- Network errors (connection refused, timeout, etc.)
Zoom does NOT retry for:
- HTTP status codes 200-299 (success)
- HTTP status codes 300-399 (redirects)
- HTTP status codes 400-499 (client errors)
Handling Retries
// 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);
});
Webhook Revalidation
Zoom automatically revalidates webhook endpoints every 72 hours. If revalidation fails 6 consecutive times, Zoom disables the webhook.
Revalidation Notifications
- 2 failures: First email notification
- 4 failures: Second email notification
- 6 failures: Webhook disabled
Ensure Uptime
// 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;
}
Environment Variables
# .env
ZOOM_WEBHOOK_SECRET=your_webhook_secret_token_here
PORT=3000
NODE_ENV=production
Loading Environment Variables
require('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');
}
Deployment
Requirements
- HTTPS required - Zoom only sends to HTTPS endpoints
- Public URL - Endpoint must be publicly accessible
- TLS 1.2+ - Valid certificate from a Certificate Authority (CA)
- FQDN - Fully qualified domain name (not IP address)
- Response time - Respond within 3 seconds
Deployment Options
- Heroku:
git push heroku main - AWS Lambda: Use API Gateway + Lambda function
- Vercel/Netlify: Serverless functions
- Self-hosted: Nginx + Node.js + Let's Encrypt
ngrok for Local Development
# 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/webhook
Testing
Test CRC Validation
WEBHOOK_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": "..."
}
Test Event Handling
# 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"}}}'
Common Event Types
| 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
Related Documentation
- webhooks skill - Comprehensive webhook documentation
- Recording Pipeline - Download recordings from webhook events
- Meeting Lifecycle - Create/update/delete meetings
- Common Issues - Webhook troubleshooting