Horizon

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.

  1. Endpoint URL validation. When you validate the URL, Zoom sends an endpoint.url_validation event with a plainToken. Reply with 200 and JSON holding the plainToken and an encryptedToken. The encryptedToken is the HMAC SHA256 of plainToken, keyed with your secret token, as a hex digest. Zoom wants the reply within three seconds.
  2. Signature check. Every other event carries x-zm-signature and x-zm-request-timestamp. Build the string v0:<timestamp>:<raw body>, compute its HMAC SHA256 with the secret token, and prefix the hex digest with v0=.

Read the body with request.text() before you parse it.

app/api/webhooks/zoom/route.ts
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.

.env.local
ZOOM_WEBHOOK_SECRET_TOKEN=your-webhook-secret-token

Start the app on port 3000.

npm run dev

Start 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-app

Horizon prints HORIZON: Tunnel connected. Your public URL is https://my-zoom-app.hrzn.run. Keep this terminal open.

Add the endpoint in Zoom

  1. Open your app in the Zoom App Marketplace.
  2. Go to Features, then Access.
  3. Turn on Event Subscriptions and select Add New Event Subscription.
  4. On the Event Types screen, select the events you need, for example meeting.started.
  5. Set Event notification endpoint URL to https://my-zoom-app.hrzn.run/api/webhooks/zoom.
  6. Select Validate.
  7. 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:

Output
  POST    200  /api/webhooks/zoom

Start a meeting. Zoom sends meeting.started, and your app terminal prints:

Output
Received Zoom event: meeting.started

Troubleshooting

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_TOKEN is the secret token of the same app. A wrong token gives a wrong encryptedToken.
  • Reply within three seconds with a 200 status and both plainToken and encryptedToken in the JSON.

The signature doesn't match

  • Sign v0:<timestamp>:<body> with the raw body. Don't run JSON.parse and JSON.stringify first.
  • Prefix the hex digest with v0= before you compare.
  • Restart npm run dev after 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

On this page