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
| Header | x-zm-signature: v0={signature} |
|---|---|
| Signed content | v0:{x-zm-request-timestamp}:{raw body} |
| Algorithm | HMAC-SHA256 |
| Encoding | hex |
| Key | The app's secret token, used as text |
| Where to find it | The Secret Token of your Zoom app (Marketplace → your app → Features → Access). |
| Replay protection | Zoom 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
| Header | Meaning |
|---|---|
x-zm-signature | v0= HMAC of v0:timestamp:body |
x-zm-request-timestamp | Unix time the request was signed |
authorization | Optional Basic or Bearer credentials you configured |
User-Agent | Zoom Marketplace/1.0a |
Timeouts and retries
| Response timeout | Respond within 3 seconds. |
|---|---|
| Counts as success | 200 or 204 (any 2xx counts). |
| Retries | 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. |
| Ordering | Not documented. |
| Duplicates | Possible on retries. |
| Disabling | After all attempts fail, that event is dropped. |
Retry schedule
| Attempt | When |
|---|---|
| Retry 1 | 5 minutes after delivery |
| Retry 2 | 20 minutes after retry 1 |
| Retry 3 | 60 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