Test Zoom webhooks locally
Receive Zoom webhook events on localhost with a Horizon tunnel, answer the endpoint URL validation challenge, and verify x-zm-signature.
Receive Zoom events on your laptop while you build, with a URL Zoom can reach.
Before you begin
- Node.js 18 or later
- A Horizon account and the CLI (see Getting started)
- A reserved subdomain for
-s. Reserve one on the Subdomains page. - A Zoom app on the Zoom App Marketplace, with its webhook secret token at hand
Start your app
Zoom sends two checks. The handler covers both.
- Endpoint URL validation. When you validate the URL, Zoom sends an
endpoint.url_validationevent with aplainToken. Reply with200and JSON holding theplainTokenand anencryptedToken. TheencryptedTokenis the HMAC SHA256 ofplainToken, keyed with your secret token, as a hex digest. Zoom wants the reply within three seconds. - Signature check. Every other event carries
x-zm-signatureandx-zm-request-timestamp. Build the stringv0:<timestamp>:<raw body>, compute its HMAC SHA256 with the secret token, and prefix the hex digest withv0=.
Read the body with request.text() before you parse it.
import { createHmac, timingSafeEqual } from "node:crypto";
export async function POST(request: Request) {
const secretToken = process.env.ZOOM_WEBHOOK_SECRET_TOKEN;
if (!secretToken) {
return new Response("Missing ZOOM_WEBHOOK_SECRET_TOKEN", { status: 500 });
}
const body = await request.text();
const payload = JSON.parse(body);
if (payload.event === "endpoint.url_validation") {
const encryptedToken = createHmac("sha256", secretToken)
.update(payload.payload.plainToken)
.digest("hex");
return Response.json({
plainToken: payload.payload.plainToken,
encryptedToken,
});
}
const timestamp = request.headers.get("x-zm-request-timestamp") ?? "";
const received = request.headers.get("x-zm-signature") ?? "";
const expected = `v0=${createHmac("sha256", secretToken)
.update(`v0:${timestamp}:${body}`)
.digest("hex")}`;
const receivedBuffer = Buffer.from(received);
const expectedBuffer = Buffer.from(expected);
const isValid =
receivedBuffer.length === expectedBuffer.length &&
timingSafeEqual(receivedBuffer, expectedBuffer);
if (!isValid) {
return new Response("Invalid signature", { status: 401 });
}
console.log(`Received Zoom event: ${payload.event}`);
return new Response("ok", { status: 200 });
}Store the secret token in an environment variable.
ZOOM_WEBHOOK_SECRET_TOKEN=your-webhook-secret-tokenStart the app on port 3000.
npm run devStart a tunnel
Use -s with a subdomain you reserved. Without it, the subdomain is random and changes every run, so Zoom would point at a dead URL after a restart. Reserve it first on the Subdomains page. Reserved subdomains are a paid feature, see Pricing.
hrzn tunnel http://localhost:3000 -s my-zoom-appHorizon prints HORIZON: Tunnel connected. Your public URL is https://my-zoom-app.hrzn.run. Keep this terminal open.
Add the endpoint in Zoom
- Open your app in the Zoom App Marketplace.
- Go to Features, then Access.
- Turn on Event Subscriptions and select Add New Event Subscription.
- On the Event Types screen, select the events you need, for example
meeting.started. - Set Event notification endpoint URL to
https://my-zoom-app.hrzn.run/api/webhooks/zoom. - Select Validate.
- After validation succeeds, select Save.
Zoom shows your webhook secret token on the Add Feature page for your app. Copy it into ZOOM_WEBHOOK_SECRET_TOKEN and restart npm run dev.
Trigger an event
The Zoom docs describe no resend or event simulator in the Marketplace. Trigger a real event instead. With meeting.started selected, start a meeting on the Zoom account that owns the app.
For past deliveries, Zoom offers a Get webhook logs API. It lists webhooks sent to your app.
Check it works
Select Validate. The Horizon terminal prints one line:
POST 200 /api/webhooks/zoomStart a meeting. Zoom sends meeting.started, and your app terminal prints:
Received Zoom event: meeting.startedTroubleshooting
Validate fails
- Check that the Horizon terminal is running and the Event notification endpoint URL ends with
/api/webhooks/zoom. - Check that
ZOOM_WEBHOOK_SECRET_TOKENis the secret token of the same app. A wrong token gives a wrongencryptedToken. - Reply within three seconds with a
200status and bothplainTokenandencryptedTokenin the JSON.
The signature doesn't match
- Sign
v0:<timestamp>:<body>with the raw body. Don't runJSON.parseandJSON.stringifyfirst. - Prefix the hex digest with
v0=before you compare. - Restart
npm run devafter you edit.env.local.
Zoom retries events
Zoom expects a 200 or 204 within three seconds. For 5xx responses it retries three times, after 5, 20 and 60 minutes. It doesn't retry 3xx or 4xx. Return fast, then do the slow work.
The URL changed after a restart
You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-zoom-app and validate the new URL in Zoom. -s needs a subdomain you reserved, see Pricing.
Next steps
- Read Zoom's webhook documentation.
Test Cisco Webex webhooks locally
Receive Cisco Webex webhook events on localhost with a Horizon tunnel, create the webhook through the API, and verify X-Spark-Signature.
Test Facebook webhooks locally
Receive Facebook Page webhook events from the Meta Graph API on localhost with a Horizon tunnel, and verify the X-Hub-Signature-256 signature.