# 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.

```javascript
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:

```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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Dedupe and use the right per-app token

Make handlers idempotent, and verify with the Secret Token for the app that sent the 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Zoom webhooks reliably in minutes.