# Verify signatures

Every webhook and callout is signed per [Standard Webhooks](https://www.standardwebhooks.com/). Verify every request before you trust it.

## Before you start

- The signing secret of the endpoint (`whsec_...`), kept on your server only. See [Webhooks overview](/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. |

## Steps

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.

```js
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.

```php
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'));
```

```python
import base64
import hashlib
import hmac
import 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

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

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.

## 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 `v1` entry.
- A request is refused as stale: the timestamp is more than 5 minutes from your clock. Check your server time.

## 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.

## Related topics

- [Webhooks overview](/webhooks/overview/)
- [Delivery and retries](/webhooks/delivery/)
- [Event catalog](/reference/webhook-events/)
- [Errors](/errors/)