You paste your Event Notification Endpoint URL into your Zoom app, click Validate, and get failed to validate. Zoom's URL validation is a challenge-response check (CRC) — and unlike Slack's, you can't pass it by just echoing a value back. You have to hash it. Here's exactly what Zoom expects, the handful of ways it goes wrong, and how to pass it with zero code while you inspect the real traffic.
When you click Validate (and again automatically every 72 hours — more on that below), Zoom POSTs this to your endpoint:
{
"event": "endpoint.url_validation",
"payload": { "plainToken": "qgg8vlvZRS6UYooatFL8Aw" },
"event_ts": 1658940994914
}
Your endpoint must respond within 3 seconds, with status 200
(or 204), and a JSON body containing the plain token and its
HMAC-SHA256, keyed by your app's webhook secret token, hex-encoded:
{
"plainToken": "qgg8vlvZRS6UYooatFL8Aw",
"encryptedToken": "23a89b634c017e5364a1c8d9c8ea909b60dd5599e2bb04bb1558d9c3a121faa5"
}
encryptedToken must be lowercase
hex (.digest("hex") in Node). Base64 fails validation.payload.plainToken —
not the whole body, not event_ts, no separators.localhost, private IPs, or a tunnel that just expired all fail before your code
even runs. (See webhooks to localhost.)Chicken-and-egg: you want to see Zoom's events before you build the endpoint,
but Zoom won't send events until an endpoint passes validation. Most capture tools can't
help — an echo template can return plainToken, but not its HMAC. Hookden's
response templates can compute the hash:
200, Content-Type
application/json, Body:{"plainToken":"{{body.payload.plainToken}}","encryptedToken":"{{hmac_sha256 body.payload.plainToken YOUR_SECRET_TOKEN}}"}
YOUR_SECRET_TOKEN with the webhook secret token from your app's
Feature page. Leave the response delay at 0 (the 3-second deadline is real).https://hookden.pages.dev/h/… URL into the Event Notification Endpoint URL
field and click Validate.The {{hmac_sha256 var secret}} template helper computes a hex HMAC-SHA256 of
any request field at response time (template docs) — so the CRC
passes, and every Zoom event after it lands in your dashboard as inspectable JSON.
The secret sits in your bin's response settings, so treat it as a development secret and rotate it in Zoom when you move to production.
Passing once isn't enough: Zoom automatically revalidates your endpoint every 72 hours, and emails the account owner when revalidation fails. Two practical consequences:
Once validated, every Zoom webhook carries x-zm-request-timestamp and
x-zm-signature: v0=<hex>. The signature is HMAC-SHA256 (same secret token)
over the string v0:{timestamp}:{raw body}:
const msg = `v0:${req.headers["x-zm-request-timestamp"]}:${rawBody}`;
const sig = "v0=" + crypto.createHmac("sha256", SECRET_TOKEN).update(msg).digest("hex");
// compare to req.headers["x-zm-signature"]
Classic bug: hashing JSON.stringify(req.body) after your framework re-parsed
the body. If key order or whitespace changed, the signature won't match — HMAC the
raw bytes. Capture a real delivery in your bin first and you can see the
exact body and headers Zoom sends, byte for byte.
ReadTimeOut(-1) and ConnectionRefused(-2).Related handshakes: Slack URL verification (echo-style — no HMAC needed) and Discord's Ed25519 check (signature-style — stricter still).
No signup needed. Or from your terminal: curl https://hookden.pages.dev/new