Webhooks

Verifying Signatures

Check that a webhook really came from BookYourPTO and was not replayed, with verification samples in Node.js, Python, PHP, Ruby, Go and C#.

Anyone who learns your endpoint's URL can send it requests. Verify the signature on every delivery before you act on it, and reject anything that fails.

Signatures follow the open Standard Webhooks scheme, so you can also use one of the libraries that implement it for your language.

The headers

HeaderValue
webhook-idThe event id, the same as id in the body. It does not change between retries.
webhook-timestampWhen this attempt was signed, in Unix seconds. Every attempt, including a retry or a redelivery, is signed afresh with a new timestamp.
webhook-signatureOne or more signatures, separated by spaces, each in the form v1,<signature>

Every delivery also carries Content-Type: application/json and User-Agent: Webhooks/1.0.

How the signature is computed

  1. Take the signing secret, remove the whsec_ prefix and base64-decode the rest. The result is the key.
  2. Build the signed content by joining the id, the timestamp and the raw request body with full stops: {webhook-id}.{webhook-timestamp}.{raw body}.
  3. Compute HMAC-SHA256 of the signed content with the key, and base64-encode the result.
  4. The delivery is genuine if that value equals any v1 signature in webhook-signature.

Test vector

Use these values to check step 1 to 3 of your implementation. The timestamp is fixed, so switch the timestamp check off for this test only.

InputValue
Secretwhsec_YSBmaXhlZCBleGFtcGxlIGtleSwgbmV2ZXIgdXNlIGl0 (an example, never use it)
webhook-idevt_01JABCDEFGHJKMNPQRSTVWXYZ
webhook-timestamp1791824652
Raw bodyThe single line below, with no trailing newline
{"id":"evt_01JABCDEFGHJKMNPQRSTVWXYZ","type":"webhook.test","api_version":"v1","created_at":"2026-10-12T17:04:11.512Z","organization_id":"cmorg0000000000000000000","data":{"object":{"id":"cmwhk0000000000000000000","object":"webhook_endpoint","message":"A test event, sent on request to check that this endpoint receives and verifies events."}}}

The expected webhook-signature value is:

v1,7/SrARBfCegLSNnPiRNB2zj/Ii8MbpAkGAy7mhDmVN8=

Rules for verifying

  • Use the raw body exactly as received. Parsing the JSON and serializing it again changes whitespace and key order, and the signature will not match. Read the body as bytes before any JSON middleware touches it.
  • Compare in constant time, using your language's constant-time comparison, never ==.
  • Reject old or future timestamps. Refuse a delivery whose webhook-timestamp is more than 5 minutes away from your server's clock, in either direction. This stops a captured request from being replayed later. Keep your server's clock synchronised.
  • Accept any matching signature. While a secret is being rotated, each delivery carries two signatures, one for the old secret and one for the new, separated by a space. Accept the delivery if either matches the secret you hold.
  • Respond with 401 or 400 when verification fails, and do not process the payload.

Code samples

Each sample returns successfully only for a genuine, fresh delivery. Load the secret from your secret store, never from source code.

import crypto from 'node:crypto'

const TOLERANCE_SECONDS = 5 * 60

// rawBody: the request body as a Buffer, before JSON parsing
// (with Express: app.post('/webhooks', express.raw({ type: 'application/json' }), ...))
export function verifyWebhook(rawBody, headers, secret) {
  const id = headers['webhook-id']
  const timestamp = headers['webhook-timestamp']
  const signatures = headers['webhook-signature']
  if (!id || !timestamp || !signatures) throw new Error('Missing webhook headers')

  const ts = Number(timestamp)
  const now = Math.floor(Date.now() / 1000)
  if (!Number.isInteger(ts) || Math.abs(now - ts) > TOLERANCE_SECONDS) {
    throw new Error('Timestamp outside the tolerance')
  }

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
  const expected = crypto
    .createHmac('sha256', key)
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest()

  for (const entry of signatures.split(' ')) {
    const [version, signature] = entry.split(',')
    if (version !== 'v1' || !signature) continue
    const given = Buffer.from(signature, 'base64')
    if (given.length === expected.length && crypto.timingSafeEqual(given, expected)) {
      return JSON.parse(rawBody.toString('utf8'))
    }
  }
  throw new Error('No matching signature')
}

Rotating the signing secret

Rotate the secret when it may have been exposed, when someone who knew it leaves, or as routine hygiene.

  1. In Settings > API, open the Webhooks tab and choose Rotate secret in the endpoint's menu. You need to own the endpoint or have a role above its owner's.
  2. Copy the new secret. It is shown once.
  3. Deploy it to your receiver within 24 hours.

For 24 hours after a rotation the old secret keeps signing too: every delivery carries two signatures, the new secret's first and the old secret's second. Because your code accepts any matching signature, no delivery is rejected while you switch. After 24 hours only the new secret signs.

Rotating again within those 24 hours ends the overlap for the oldest secret: only the secret being replaced keeps signing alongside the new one.

Troubleshooting

SymptomLikely cause
Signature never matchesThe body was parsed and re-serialized before verifying, or the secret was copied with extra whitespace or without the whsec_ prefix handling
Only some deliveries failA proxy or framework rewrites some bodies (for example, normalising line endings or character encoding)
Fails with "timestamp outside the tolerance"Your server's clock is wrong. Enable time synchronisation (NTP)
Fails after rotatingThe old secret is still deployed after its 24-hour overlap ended
Fails only on retriesThe timestamp was checked against the first attempt's time. Each attempt is signed with its own timestamp; verify every attempt as it arrives