Guide to Zoom Webhooks: Features and Best Practices
Zoom webhooks notify your application about meeting, webinar, recording, and phone activity. If you're building on Zoom, webhooks are how you react to these events without polling, but the single biggest implementation miss isn't the signature, it's the validation handshake that Zoom re-sends every 72 hours.
This guide covers how Zoom webhooks work, the events you'll handle, how to verify the x-zm-signature, how to answer the recurring CRC handshake, and the best practices for production.
What are Zoom webhooks?
Zoom webhooks are JSON POSTs delivered to a URL you configure per app. Each is signed with an x-zm-signature header: an HMAC-SHA256 (hex) with a v0= prefix on the value, alongside an x-zm-request-timestamp header. The signed string is exactly v0:{x-zm-request-timestamp}:{raw_body}, three parts joined by colons with the literal v0 first, keyed with your app's Secret Token. The legacy plaintext "Webhook verification token" scheme was sunset in June 2025, so any integration still using it is describing a dead mechanism.
Zoom webhook features
| Feature | Details |
|---|---|
| Configuration | Zoom app > Feature > Webhooks (Secret Token is per-app) |
| Signature header | x-zm-signature (value prefixed v0=), plus x-zm-request-timestamp |
| Signature scheme | HMAC-SHA256 (hex) over v0:{timestamp}:{raw_body}, keyed with the Secret Token |
| Setup handshake | endpoint.url_validation CRC, re-sent every 72 hours (see below) |
| Response window | 3 seconds |
| Retries | Only on HTTP 5xx or specific Zoom codes (5min, 20min, 60min); 3xx/4xx are not retried |
| SDK | npm @zoom/rivet (JavaScript only); no official pip package |
Common events
A representative set of events (identified by the top-level event field):
| Event | Fires when |
|---|---|
endpoint.url_validation | Zoom validates your endpoint (the CRC handshake) |
meeting.started / meeting.ended | A meeting starts or ends |
meeting.participant_joined / meeting.participant_left | A participant joins or leaves |
recording.completed | A cloud recording finishes |
The envelope carries event, event_ts, and payload{account_id, object{...}}, plus payload.operator, payload.operator_id, and payload.old_object on update events. Zoom's catalog is large, subscribe to the events your app needs.
Setting up Zoom webhooks
In your Zoom app, configure the webhook under Feature > Webhooks, set the event subscriptions, and copy the Secret Token into ZOOM_WEBHOOK_SECRET. The Secret Token is scoped per app, so one Zoom account with several apps has several different signing secrets. Your endpoint must answer the CRC handshake (below) to be enabled and to stay enabled.
Securing Zoom webhooks
Build v0:{x-zm-request-timestamp}:{raw_body}, compute an HMAC-SHA256 with your Secret Token, hex-encode it, prefix v0=, and compare against x-zm-signature. Separately, handle the endpoint.url_validation event: within 3 seconds return {plainToken, encryptedToken}, where encryptedToken is the hex HMAC-SHA256 of plainToken keyed with the Secret Token.
const crypto = require("crypto");
const SECRET = process.env.ZOOM_WEBHOOK_SECRET;
function verify(rawBody, timestamp, signature) {
if (!timestamp || !signature) return false;
const message = `v0:${timestamp}:${rawBody.toString()}`;
const expected = "v0=" + crypto.createHmac("sha256", SECRET).update(message).digest("hex");
try {
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
} catch {
return false;
}
}
app.post("/webhooks/zoom", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body, req.headers["x-zm-request-timestamp"], req.headers["x-zm-signature"])) {
return res.sendStatus(401);
}
const body = JSON.parse(req.body.toString());
// CRC handshake: answer within 3 seconds, and expect it again every 72 hours
if (body.event === "endpoint.url_validation") {
const encryptedToken = crypto
.createHmac("sha256", SECRET)
.update(body.payload.plainToken)
.digest("hex");
return res.status(200).json({ plainToken: body.payload.plainToken, encryptedToken });
}
res.sendStatus(200); // acknowledge fast
processQueue.add(body); // branch on body.event, async
});
The same logic in Python:
import hashlib
import hmac
import os
SECRET = os.environ["ZOOM_WEBHOOK_SECRET"].encode()
def verify(raw_body: bytes, timestamp: str, signature: str) -> bool:
if not timestamp or not signature:
return False
message = b"v0:" + timestamp.encode() + b":" + raw_body
expected = "v0=" + hmac.new(SECRET, message, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)
def crc_response(plain_token: str) -> dict:
encrypted = hmac.new(SECRET, plain_token.encode(), hashlib.sha256).hexdigest()
return {"plainToken": plain_token, "encryptedToken": encrypted}
Zoom webhook limitations and pain points
The CRC handshake is not one-time
The Problem: Zoom sends endpoint.url_validation at setup, but it re-sends it every 72 hours for the life of the subscription, and six consecutive failures disable the subscription. A handler that answers once and then stops (or verifies signatures but never answers the CRC) goes dark after roughly 18 days.
Why It Happens: Zoom uses the CRC as an ongoing liveness check, not just a setup step.
Workarounds:
- Always answer
endpoint.url_validationwith{plainToken, encryptedToken}within 3 seconds, every time it arrives.
How Hookdeck Can Help: Hookdeck can answer the recurring CRC challenge at the edge, so your subscription stays alive even if your app is briefly slow.
The signed string is v0:timestamp:body
The Problem: The HMAC is over v0:{timestamp}:{raw_body} and the header value is prefixed v0=. Signing the body alone, or dropping the prefix or timestamp, never matches.
Why It Happens: Zoom versions and timestamps the signed message.
Workarounds:
- Reconstruct
v0:{timestamp}:{body}from the raw body andx-zm-request-timestamp, and prefixv0=.
How Hookdeck Can Help: Hookdeck reconstructs and verifies the signed string at the edge.
The response window is 3 seconds, and retries are narrow
The Problem: You have 3 seconds to respond, and Zoom only retries on 5xx or specific internal codes (at 5, 20, and 60 minutes), 3xx and 4xx aren't retried at all. Slow processing or a 4xx means the event is gone.
Why It Happens: Zoom expects a fast response and retries narrowly.
Workarounds:
- Verify and acknowledge within 3 seconds, then process asynchronously.
How Hookdeck Can Help: Hookdeck acknowledges Zoom fast at the edge and durably queues events, so your processing time doesn't cost you deliveries.
The Secret Token is per-app
The Problem: The Secret Token is scoped per app, so one account with several apps has several signing secrets. Verifying every app's deliveries against one token fails.
Why It Happens: Zoom issues the Secret Token per app.
Workarounds:
- Track which app a delivery belongs to and verify with that app's Secret Token.
How Hookdeck Can Help: Hookdeck can verify each source with its own secret, so multiple apps don't collide.
Best practices
Answer the recurring CRC handshake
Respond to endpoint.url_validation with {plainToken, encryptedToken} within 3 seconds, every 72 hours, or the subscription is disabled after six failures.
Verify over v0:timestamp:body
Reconstruct the colon-joined string, HMAC-SHA256 with the Secret Token, prefix v0=, and compare in constant time.
Acknowledge within 3 seconds, process asynchronously
Return 200 fast and defer work to a queue. See why to process webhooks asynchronously.
Dedupe and use the right per-app token
Make handlers idempotent, and verify with the Secret Token for the app that sent the event.
Make Zoom webhooks production-ready
Hookdeck answers the CRC, verifies x-zm-signature, deduplicates, and durably queues every event
Conclusion
Zoom webhooks are verified with an x-zm-signature HMAC-SHA256 over v0:{timestamp}:{raw_body} (prefixed v0=), keyed with your per-app Secret Token. The part most implementations miss is the endpoint.url_validation CRC handshake, it recurs every 72 hours, and six failures disable the subscription. Answer it within 3 seconds every time, verify over the colon-joined string, and acknowledge fast because retries are narrow.
Hookdeck answers the CRC, verifies the signature, deduplicates, and durably queues every event at the edge, so your app only ever processes verified, unique events, and your subscription stays alive.
Get started with Hookdeck for free and handle Zoom webhooks reliably in minutes.