Gareth Wilson Gareth Wilson

Guide to Zoom Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationZoom app > Feature > Webhooks (Secret Token is per-app)
Signature headerx-zm-signature (value prefixed v0=), plus x-zm-request-timestamp
Signature schemeHMAC-SHA256 (hex) over v0:{timestamp}:{raw_body}, keyed with the Secret Token
Setup handshakeendpoint.url_validation CRC, re-sent every 72 hours (see below)
Response window3 seconds
RetriesOnly on HTTP 5xx or specific Zoom codes (5min, 20min, 60min); 3xx/4xx are not retried
SDKnpm @zoom/rivet (JavaScript only); no official pip package

Common events

A representative set of events (identified by the top-level event field):

EventFires when
endpoint.url_validationZoom validates your endpoint (the CRC handshake)
meeting.started / meeting.endedA meeting starts or ends
meeting.participant_joined / meeting.participant_leftA participant joins or leaves
recording.completedA 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_validation with {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 and x-zm-request-timestamp, and prefix v0=.

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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.