Skip to content

Video

How Zoom signs webhooks

Zoom signs webhooks with v0= HMAC-SHA256 of "v0:{timestamp}:{body}" using the app's secret token, validates the URL with a challenge, and retries three times.

Checked against Zoom's documentation on October 31, 2026. Zoom webhook docs

How the signature works

Headerx-zm-signature: v0={signature}
Signed contentv0:{x-zm-request-timestamp}:{raw body}
AlgorithmHMAC-SHA256
Encodinghex
KeyThe app's secret token, used as text
Where to find itThe Secret Token of your Zoom app (Marketplace → your app → Features → Access).
Replay protectionZoom signs the timestamp but publishes no tolerance; 5 minutes is a common choice.
  • endpoint.url_validation: answer {"plainToken": …, "encryptedToken": hex HMAC-SHA256(secret token, plainToken)}.

verified Source: developers.zoom.us/docs/api/webhooks/

Verification code

Generated from the scheme above and tested against a signed example request, a changed body and a wrong secret. Pass the raw request body, before any JSON or form parsing.

const crypto = require('node:crypto');

const TOLERANCE_SECONDS = 300;

/**
 * Verify a Zoom webhook. rawBody: the request body as a Buffer, exactly as received
 * (before any JSON parsing); headers: lower-case names, as in req.headers.
 */
function verifyWebhook(rawBody, headers, secret, url = '', method = 'POST') {
  rawBody = Buffer.from(rawBody);
  const header = headers['x-zm-signature'] || '';
  if (!header.startsWith('v0=')) return false;
  const signatures = [header.slice(3)];
  const ts = headers['x-zm-request-timestamp'] || '';
  const signed = Buffer.concat([Buffer.from('v0:' + ts + ':'), rawBody]);
  const key = secret;
  const expected = crypto.createHmac('sha256', key).update(signed).digest('hex');
  const valid = signatures.some((sig) => {
    const a = Buffer.from(sig.toLowerCase());
    const b = Buffer.from(expected);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
  if (!valid) return false;
  const signedAt = Number(ts);
  return Number.isFinite(signedAt) && Math.abs(Date.now() / 1000 - signedAt) <= TOLERANCE_SECONDS;
}

// Express: keep the raw body for this route
// app.post('/webhooks/zoom', express.raw({ type: '*/*' }), (req, res) => {
//   if (!verifyWebhook(req.body, req.headers, process.env.ZOOM_WEBHOOK_SECRET)) return res.sendStatus(401);
//   res.sendStatus(200); // then process the event asynchronously
// });
import base64
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify_webhook(raw_body: bytes, headers, secret: str, url: str = '', method: str = 'POST') -> bool:
    """Verify a Zoom webhook. raw_body: the request body exactly as received (bytes);
    headers: a mapping with lower-case names (Flask and Django headers are case-insensitive)."""
    header = headers.get('x-zm-signature', '')
    if not header.startswith('v0='):
        return False
    signatures = [header[3:]]
    ts = headers.get('x-zm-request-timestamp', '')
    signed = ('v0:' + ts + ':').encode() + raw_body
    key = secret.encode()
    digest = hmac.new(key, signed, hashlib.sha256).digest()
    expected = digest.hex()
    if not any(hmac.compare_digest(sig.lower(), expected) for sig in signatures):
        return False
    try:
        signed_at = int(ts)
    except ValueError:
        return False
    return abs(time.time() - signed_at) <= TOLERANCE_SECONDS


# Flask: verify_webhook(request.get_data(), request.headers, SECRET)
<?php

const TOLERANCE_SECONDS = 300;

/**
 * Verify a Zoom webhook. $rawBody: file_get_contents('php://input'), before any decoding;
 * $headers: lower-case names, e.g. array_change_key_case(getallheaders()).
 */
function verify_webhook(string $rawBody, array $headers, string $secret, string $url = '', string $method = 'POST'): bool
{
    $header = (string) ($headers['x-zm-signature'] ?? '');
    if (! str_starts_with($header, 'v0=')) {
        return false;
    }
    $signatures = [substr($header, 3)];
    $ts = (string) ($headers['x-zm-request-timestamp'] ?? '');
    $signed = 'v0:' . $ts . ':' . $rawBody;
    $key = $secret;
    $expected = hash_hmac('sha256', $signed, $key);
    $valid = false;
    foreach ($signatures as $sig) {
        $valid = $valid || hash_equals($expected, strtolower($sig));
    }
    if (! $valid) {
        return false;
    }
    $signedAt = (int) $ts;

    return $signedAt > 0 && abs(time() - $signedAt) <= TOLERANCE_SECONDS;
}
package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"math"
	"net/http"
	"strconv"
	"strings"
	"time"
)

const toleranceSeconds = 300

// VerifyWebhook checks a Zoom webhook. rawBody is the request body exactly as received
// (io.ReadAll(r.Body) before any decoding).
func VerifyWebhook(rawBody []byte, h http.Header, secret, url, method string) bool {
	header := h.Get("x-zm-signature")
	if !strings.HasPrefix(header, "v0=") {
		return false
	}
	signatures := []string{strings.TrimPrefix(header, "v0=")}
	ts := h.Get("x-zm-request-timestamp")
	signed := []byte{}
	signed = append(signed, "v0:"...)
	signed = append(signed, ts...)
	signed = append(signed, ":"...)
	signed = append(signed, rawBody...)
	key := []byte(secret)
	mac := hmac.New(sha256.New, key)
	mac.Write(signed)
	expected := hex.EncodeToString(mac.Sum(nil))
	valid := false
	for _, sig := range signatures {
		if hmac.Equal([]byte(strings.ToLower(sig)), []byte(expected)) {
			valid = true
		}
	}
	if !valid {
		return false
	}
	n, err := strconv.ParseFloat(ts, 64)
	if err != nil {
		return false
	}
	signedAt := n
	return math.Abs(float64(time.Now().Unix())-signedAt) <= toleranceSeconds
}

Headers Zoom sends

HeaderMeaning
x-zm-signaturev0= HMAC of v0:timestamp:body
x-zm-request-timestampUnix time the request was signed
authorizationOptional Basic or Bearer credentials you configured
User-AgentZoom Marketplace/1.0a

Timeouts and retries

Response timeoutRespond within 3 seconds.
Counts as success200 or 204 (any 2xx counts).
RetriesUp to 3 retries for 5xx and some connection errors: 5 minutes after the first attempt, 20 minutes after retry 1, 60 minutes after retry 2. 3xx and 4xx are not retried.
OrderingNot documented.
DuplicatesPossible on retries.
DisablingAfter all attempts fail, that event is dropped.

Retry schedule

AttemptWhen
Retry 15 minutes after delivery
Retry 220 minutes after retry 1
Retry 360 minutes after retry 2

verified Source: developers.zoom.us/docs/api/webhooks/

Event types

The event is in event (in the JSON body). Examples:

endpoint.url_validationmeeting.startedmeeting.endedmeeting.participant_joinedrecording.completed

verified Source: developers.zoom.us/docs/api/webhooks/

Source IP addresses

Zoom publishes its source addresses, but we don't copy them here.

Zoom publishes its ranges in a support article and recommends verifying signatures instead, because the ranges change. Not copied here.

An allow-list only filters traffic: proxies, CDNs and shared cloud addresses mean the signature is still the real proof of origin.

verified Source: support.zoom.com/hc/en/article?id=zm_kb&sysparm_article=KB0060548

Common mistakes

  • Not answering endpoint.url_validation: Zoom won't enable the endpoint until it gets the encryptedToken.
  • Answering the challenge with the plainToken hashed with SHA-256 alone instead of HMAC with the secret token.
  • Returning 4xx for a temporary problem: Zoom only retries 5xx.
  • Answering after 3 seconds.

Stuck? Point the webhook at a free webhook inspector bin: it detects Zoom, checks the signature with your secret in the browser and shows the expected value next to the received one.

Not confirmed in Zoom's documentation

  • The timestamp tolerance (Zoom doesn't publish one).

FAQ

How do I verify a Zoom webhook signature?

Compute HMAC-SHA256 over v0:{x-zm-request-timestamp}:{raw body} using secret token, encode it as hex and compare it in constant time with the value in x-zm-signature (format: v0={signature}). Then reject requests whose timestamp is more than 5 minutes away from your clock.

How long does Zoom wait for a webhook response?

Respond within 3 seconds. Success means: 200 or 204 (any 2xx counts).

Does Zoom retry failed webhooks?

Up to 3 retries for 5xx and some connection errors: 5 minutes after the first attempt, 20 minutes after retry 1, 60 minutes after retry 2. 3xx and 4xx are not retried.

What IP addresses does Zoom send webhooks from?

Zoom publishes its source ranges at https://support.zoom.com/hc/en/article?id=zm_kb&sysparm_article=KB0060548; we don't copy them here. Zoom publishes its ranges in a support article and recommends verifying signatures instead, because the ranges change. Not copied here.

Why does my Zoom signature check fail?

Not answering endpoint.url_validation: Zoom won't enable the endpoint until it gets the encryptedToken. Answering the challenge with the plainToken hashed with SHA-256 alone instead of HMAC with the secret token.

Other webhook providers

Stripe GitHub GitLab Bitbucket Cloud Shopify Slack Twilio Paddle Billing Linear Square PayPal SendGrid Mailgun Discord Svix Standard Webhooks Twitch EventSub Intercom HubSpot Jira Cloud Calendly Typeform