To test Zoom webhooks on localhost, expose your local POST route with npx portpreview PORT, enter that public HTTPS route as the event notification endpoint, and implement Zoom's endpoint.url_validation challenge before clicking Validate. For normal events, verify x-zm-signature against the untouched request body and timestamp, persist the event idempotently, and return 2xx within three seconds.
How Zoom event subscriptions reach localhost
Zoom webhooks are JSON HTTP POST notifications for subscribed events across products such as Meetings, Webinars, Phone, Team Chat, Rooms, and other services available to your app. The exact event catalog and fields depend on app type, enabled products, account entitlements, scopes, and the current Zoom platform. Select only events your handler understands and use the current event schema shown in the app build flow.
Your endpoint must be publicly accessible HTTPS with a fully qualified domain name, a valid CA-issued certificate chain, TLS 1.2 or later, and support for JSON POST requests. A loopback URL such as http://localhost:3000 cannot satisfy those requirements. PortPreview supplies the public HTTPS edge while forwarding requests to your local process.
The official Zoom webhook documentation is the source of truth for endpoint requirements, challenge-response validation, event signatures, delivery behavior, and current configuration steps.
Create an Express route that preserves the raw body
Zoom's request signature covers the exact body text. Capture bytes before any JSON middleware parses and serializes them. The example below handles validation and normal event verification in one route:
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post('/webhooks/zoom', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
const rawBody = req.body.toString('utf8');
let event;
try {
event = JSON.parse(rawBody);
} catch {
return res.status(400).json({ error: 'Invalid JSON' });
}
const secret = process.env.ZOOM_WEBHOOK_SECRET_TOKEN;
if (!secret) return res.sendStatus(500);
if (event.event === 'endpoint.url_validation') {
const plainToken = event.payload?.plainToken;
if (typeof plainToken !== 'string') return res.sendStatus(400);
const encryptedToken = crypto
.createHmac('sha256', secret)
.update(plainToken)
.digest('hex');
return res.status(200).json({ plainToken, encryptedToken });
}
const timestamp = req.get('x-zm-request-timestamp') ?? '';
const received = req.get('x-zm-signature') ?? '';
const message = `v0:${timestamp}:${rawBody}`;
const expected = `v0=${crypto
.createHmac('sha256', secret)
.update(message)
.digest('hex')}`;
const a = Buffer.from(received);
const b = Buffer.from(expected);
const valid = a.length === b.length && crypto.timingSafeEqual(a, b);
if (!valid) return res.sendStatus(401);
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return res.sendStatus(401);
const requestId = req.get('x-zm-request-id');
const deliveryKey = requestId || crypto.createHash('sha256').update(rawBody).digest('hex');
await saveWebhookOnce({ provider: 'zoom', deliveryKey, event });
return res.sendStatus(200);
});
app.listen(3000);
The five-minute freshness window is an application security policy in this example, not a substitute for HMAC verification. Choose a tolerance that fits your clock synchronization and delivery expectations. Log only the reason category for failures, never the secret token or complete request body.
Start the local tunnel
- Run the application and confirm the route accepts a local POST on port 3000.
- Open another terminal and run
npx portpreview 3000. Replace 3000 with the port your app actually uses. - Append the route to the generated origin, such as
https://YOUR-TUNNEL.portpreview.dev/webhooks/zoom. - Keep the app and tunnel processes running during validation and event tests.
When a new tunnel has a different hostname, Zoom sees it as a different endpoint. Update and validate the new URL before expecting events. The URL should resolve directly to the POST handler; redirects are unsuitable for reliable webhook delivery and Zoom does not retry 3xx responses.
Add the event subscription in Zoom
In the Zoom App Marketplace, open your created app and navigate to its Features or Access area as shown by the current build flow. Enable Event Subscriptions, add a subscription, choose its event types and receiver, then paste the complete HTTPS endpoint URL. Available receiver choices and events vary by app type and account configuration. Published apps may require review again when subscriptions change.
Copy the webhook secret token associated with the app into a local ignored environment variable such as ZOOM_WEBHOOK_SECRET_TOKEN. This is not an OAuth client secret, access token, or deprecated verification token. Restart the local server after changing its environment.
Implement endpoint URL validation correctly
When you click Validate, Zoom sends a POST whose event is endpoint.url_validation. The payload contains plainToken. Compute HMAC SHA-256 using the webhook secret token as the key and that plain token as the message, encode the digest as lowercase hexadecimal, and respond with JSON containing both the unchanged plainToken and the resulting encryptedToken.
const encryptedToken = createHmac('sha256', webhookSecret)
.update(event.payload.plainToken)
.digest('hex');
return {
plainToken: event.payload.plainToken,
encryptedToken
};
Respond with HTTP 200 and the JSON body within three seconds. Do not hash the entire validation request, use the OAuth client secret, Base64-encode the digest, or prefix the validation digest with v0=. Those belong to different flows. The endpoint cannot be saved until initial validation succeeds.
Zoom's current documentation also describes automatic revalidation every 72 hours. After repeated failed revalidations, notifications are sent to the app owner; after six consecutive failures, Zoom disables the event subscription and stops events. A development tunnel that has been closed will therefore fail later validation. Remove temporary subscriptions after testing, and keep production challenge handling permanently available.
Verify normal Zoom webhook requests
URL validation proves that the endpoint knows the secret at challenge time. Normal event verification separately proves that a received body matches the HMAC sent by Zoom. Read x-zm-request-timestamp and construct this exact message:
v0:{x-zm-request-timestamp}:{raw request body}
Hash that message with HMAC SHA-256 keyed by the webhook secret token, encode the digest as hexadecimal, prepend v0=, and compare it with x-zm-signature using a constant-time comparison. The body must be the original request body. Parsing JSON and then calling JSON.stringify can change whitespace or property formatting and break a valid signature.
Reject missing, malformed, invalid, or unreasonably stale signatures before applying business logic. Keep system time synchronized. The old webhook verification token was deprecated and scheduled to sunset in June 2025; new code should use the secret-token HMAC flow documented by Zoom, not an Authorization equality check copied from an older tutorial. See the webhook signature verification guide for raw-body and timing-safe comparison details.
Dispatch provider-specific event types
A verified payload still needs schema validation and authorization checks. For meeting events, identifiers such as meeting ID and UUID have different purposes; repeated or recurring meetings make UUID-level correlation important. Treat payload fields as event-specific and follow the current reference for each subscription.
async function processZoomEvent(event) {
switch (event.event) {
case 'meeting.started':
await markMeetingStarted({
uuid: event.payload.object.uuid,
startedAt: event.payload.object.start_time
});
break;
case 'meeting.ended':
await markMeetingEnded({
uuid: event.payload.object.uuid,
endedAt: event.payload.object.end_time
});
break;
default:
await recordUnhandledZoomEvent(event.event);
}
}
Do not assume event order is a transaction log. Network delay, retries, and parallel processing can produce surprising arrival order. Store the provider event timestamp and apply monotonic state rules where appropriate. Unknown event types should be observable and acknowledged after safe persistence, rather than repeatedly failing the endpoint.
Meet the three-second delivery deadline
Zoom expects HTTP 200 or 204 within three seconds for successful delivery. Verify the request, validate the minimal envelope, write to a durable inbox or queue, and return. Video processing, CRM updates, calendar calls, email, and analytics belong in workers.
According to Zoom's current notification-delivery documentation, eligible server and connection failures are retried three times: approximately five minutes after the initial attempt, then 20 minutes after that retry, then 60 minutes after the second retry. Zoom treats 2xx as success; it does not retry 3xx redirects or 4xx client errors. Because policies can change, recheck the official page before building operational alerts around exact intervals.
Make every event idempotent
A retry may follow an ambiguous timeout after your first attempt committed. Deduplicate before side effects. The x-zm-request-id header appears in Zoom's documented request structure, but code should tolerate its absence where products or versions differ. Use it when present; otherwise derive a stable key from verified immutable event data or a cryptographic digest of the verified raw body. Enforce uniqueness in storage, not only with an in-memory cache.
await db.transaction(async (tx) => {
const claimed = await tx.webhookInbox.insertOnce({
provider: 'zoom',
deliveryKey,
eventType: event.event,
payload: event
});
if (!claimed) return;
await tx.jobs.enqueue({ type: 'process-zoom-event', deliveryKey });
});
Keep the inbox claim and job creation atomic. If a worker fails, retry the job without asking Zoom to redeliver. The retry and idempotency guide covers inbox tables, unique keys, and side-effect boundaries.
Troubleshoot validation and delivery
Validate reports failure
Check that the URL is public HTTPS, includes the exact route, has no redirect, and reaches the live local port. Confirm the response is HTTP 200 JSON with the original plain token and the lowercase hexadecimal HMAC of only that token. Measure total response time; validation must complete within three seconds.
Every normal event fails signature verification
Verify that you copied the webhook secret token, not an OAuth client secret or legacy verification token. Capture the body as raw bytes before JSON middleware, use the exact timestamp header, include both colons in the v0:timestamp:body message, and prefix only the final event signature with v0=.
Validation works but events do not arrive
Ensure the subscription is enabled and saved, the intended event types and receiver are selected, and your account or users produce those events. Check revalidation status and app publication requirements. Confirm the tunnel URL has not changed since validation.
The handler succeeds but Zoom retries
Inspect public response latency and status, not only local logs. Slow work may exceed three seconds even if it eventually finishes. Persist quickly, return 2xx, and process asynchronously. A duplicate-safe inbox prevents a retry from repeating the side effect.
A captured event fails when replayed later
A freshness check should reject an old timestamp, and altering JSON invalidates the HMAC. For end-to-end tests, obtain a fresh provider event. For business-logic tests, store a sanitized parsed fixture and call the dispatcher after bypassing ingress verification in the test harness only. The webhook replay guide explains this separation.
Security checklist for Zoom webhook testing
- Keep webhook secret tokens in ignored environment files and rotate exposed credentials.
- Verify HMAC against the raw body before trusting any payload field.
- Enforce a timestamp tolerance and synchronize the server clock to reduce replay risk.
- Validate event type, account context, object identifiers, content type, and body size.
- Redact participant names, email addresses, meeting topics, chat content, and recording data from logs.
- Use separate development and production endpoints or secrets where your app configuration permits.
- Remove temporary public URLs and subscriptions when the local session ends.
The production-ready pattern is the same one proven locally: stable HTTPS ingress, permanent challenge handling, raw-body HMAC verification, durable idempotency, sub-three-second acknowledgement, and isolated workers. For general request tracing and route checks, follow the local webhook debugging guide.
