Skip to content

Verify signatures

Every webhook and callout is signed per Standard Webhooks. Verify every request before you trust it.

  • 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.
  1. Read the raw body, webhook-id, webhook-timestamp and webhook-signature.
  2. Refuse the request if one header is missing or the timestamp is more than 5 minutes from now.
  3. Decode the secret: the key is the base64 decoding of the part after whsec_.
  4. Build the signed content: <webhook-id>.<webhook-timestamp>.<raw body>.
  5. Compute the base64 HMAC-SHA256 of it with the key.
  6. Accept the request when any v1 signature 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.

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.

  1. Choose Send test event on the endpoint.
  2. Your handler verifies the request and answers 2xx.
  3. The dashboard shows your response code for the webhook.test delivery.
  • 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 v1 entry.
  • A request is refused as stale: the timestamp is more than 5 minutes from your clock. Check your server time.
  • 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.