Verify signatures
Every webhook and callout is signed per Standard Webhooks. Verify every request before you trust it.
Before you start
Section titled “Before you start”- The signing secret of the endpoint (
whsec_...), kept on your server only. See Webhooks overview. - The raw request body bytes. Never use re-serialized JSON: the signature covers the exact bytes we sent.
Every request carries these headers:
| Header | Value |
|---|---|
webhook-id |
Unique id of the event, msg_.... The same on every retry: use it to drop duplicates. |
webhook-timestamp |
Unix seconds when it was signed. |
webhook-signature |
v1,<base64 HMAC-SHA256>, possibly several separated by spaces. |
webhook-type |
Callouts only: catalog, charge, charge_status or call_minute. |
idempotency-key |
charge (the purchase id) and call_minute (<call_id>:<minute>) only. |
- Read the raw body,
webhook-id,webhook-timestampandwebhook-signature. - Refuse the request if one header is missing or the timestamp is more than 5 minutes from now.
- Decode the secret: the key is the base64 decoding of the part after
whsec_. - Build the signed content:
<webhook-id>.<webhook-timestamp>.<raw body>. - Compute the base64 HMAC-SHA256 of it with the key.
- Accept the request when any
v1signature in the header equals your result, compared in constant time. Otherwise answer 401.
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifyWebhook(secret, headers, rawBody) { const id = headers['webhook-id'] const ts = headers['webhook-timestamp'] const sigs = headers['webhook-signature'] if (!id || !/^\d+$/.test(ts ?? '') || !sigs || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64') const expected = createHmac('sha256', key).update(`${id}.${ts}.`).update(rawBody).digest() return sigs.split(' ').some((s) => { const [version, sig = ''] = s.split(',') const got = Buffer.from(sig, 'base64') return version === 'v1' && got.length === expected.length && timingSafeEqual(got, expected) })}With node:http, headers is req.headers and rawBody the collected request body, a Buffer or a string.
function verify_webhook(string $secret, array $headers, string $rawBody): bool{ $id = $headers['webhook-id'] ?? ''; $ts = $headers['webhook-timestamp'] ?? ''; $sigs = $headers['webhook-signature'] ?? ''; if ($id === '' || !ctype_digit($ts) || $sigs === '' || abs(time() - (int) $ts) > 300) { return false; } $key = base64_decode(preg_replace('/^whsec_/', '', $secret)); $expected = base64_encode(hash_hmac('sha256', "$id.$ts.$rawBody", $key, true)); foreach (explode(' ', $sigs) as $s) { [$version, $sig] = array_pad(explode(',', $s, 2), 2, ''); if ($version === 'v1' && hash_equals($expected, $sig)) { return true; } } return false;}
$ok = verify_webhook(getenv('MONDOCHAT_WEBHOOK_SECRET'), [ 'webhook-id' => $_SERVER['HTTP_WEBHOOK_ID'] ?? '', 'webhook-timestamp' => $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '', 'webhook-signature' => $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '',], file_get_contents('php://input'));import base64import hashlibimport hmacimport time
def verify_webhook(secret: str, headers, raw_body: bytes) -> bool: msg_id = headers.get("webhook-id") ts = headers.get("webhook-timestamp") or "" sigs = headers.get("webhook-signature") if not msg_id or not ts.isdigit() or not sigs or abs(time.time() - int(ts)) > 300: return False key = base64.b64decode(secret.removeprefix("whsec_")) mac = hmac.new(key, f"{msg_id}.{ts}.".encode() + raw_body, hashlib.sha256) expected = base64.b64encode(mac.digest()).decode() return any( version == "v1" and hmac.compare_digest(sig, expected) for version, _, sig in (s.partition(",") for s in sigs.split(" ")) )headers is anything with a case-insensitive .get: self.headers in http.server, request.headers in Flask, Django and FastAPI.
Rotate the secret
Section titled “Rotate the secret”Choose Rotate secret on the endpoint. It shows a new secret once. For the next 24 hours every delivery and callout carries two signatures, v1,<new> v1,<old>, so either secret verifies while you deploy the new one. After that only the new one signs.
Rotating again within the 24 hours drops the oldest secret. POST /v1/manage/apps/{app_id}/webhooks/{id}/rotate-secret does the same, and a disabled endpoint answers 409.
Check it works
Section titled “Check it works”- Choose Send test event on the endpoint.
- Your handler verifies the request and answers 2xx.
- The dashboard shows your response code for the
webhook.testdelivery.
When it fails
Section titled “When it fails”- Every request fails: you probably hash a re-serialized body. Use the raw bytes.
- Every request fails: the secret is wrong. The key is the base64 decoding of the part after
whsec_. - Only some requests fail after a rotation: your code reads one signature. Check each space separated
v1entry. - A request is refused as stale: the timestamp is more than 5 minutes from your clock. Check your server time.
Limits
Section titled “Limits”- The timestamp tolerance is 5 minutes.
- Two signatures are sent for 24 hours after a rotation. Rotating again in that time drops the oldest secret.
- The secret is shown once and stays on your server only.