Every webhook delivery is signed with HMAC-SHA-256 over the request body and a timestamp. Verifying the signature is the only way to confirm a request came from the platform and was not tampered with.

## Why signatures matter

A webhook URL is reachable from the public internet. Without verification, anyone who learns your URL can POST arbitrary payloads. The signing secret is shown to you once on create and on rotation, then stored encrypted at rest and never returned again — we decrypt it only to compute a fresh HMAC at send time.

## Delivery headers

Every delivery carries these headers:

| Header                    | Value                                                                                                  |
| ------------------------- | ----------------------------------------------------------------------------------------------------- |
| `X-Webhook-Signature`     | The HMAC signature and timestamp (see below).                                                          |
| `X-Webhook-Id`            | The event id. **Stable across every retry of the same event** — dedupe on this to handle at-least-once retries. |
| `X-Webhook-Delivery-Id`   | The delivery id. **Distinct per delivery**, so a replay reuses `X-Webhook-Id` but carries a new `X-Webhook-Delivery-Id`. |
| `X-Webhook-Origin`        | How the delivery was created: `event` (a real platform event), `replay` (a manual re-send), or `test_ping` (a synthetic test). |

Deliveries are **at-least-once**: the same event may arrive more than once. Treat `X-Webhook-Id` as the idempotency key and make your handler idempotent.

## The signature header

The `X-Webhook-Signature` header has the form:

```
X-Webhook-Signature: t=1717000000,v1=abc123...
```

- `t` — UNIX epoch seconds at signing time.
- `v1` — lower-case hex of `HMAC-SHA-256(secret, "${t}.${rawBody}")` where `rawBody` is the exact bytes of the request body.

The `v1` prefix pins the version; a future format change would ship as `v2=…` alongside, so parse `v1` explicitly.

## Verify the signature

```
parts = parseHeader(req.headers['x-webhook-signature'])
if abs(now() - parts.t) > 300: reject "expired"
expected = hmacSha256Hex(SECRET, parts.t + "." + rawBody)
if !constantTimeEqual(expected, parts.v1): reject "invalid"
```

Compute the HMAC over the **exact bytes** your framework gave you. If you re-serialize the JSON before computing the HMAC, the signature will not match. Read the raw body first, verify, then parse.

Use a constant-time compare (`crypto.timingSafeEqual`, `hmac.compare_digest`, `subtle.ConstantTimeCompare`). A naive `===` is a timing oracle.

## Tolerance window

The platform rejects timestamps more than five minutes (300 seconds) old or in the future. The window is symmetric so small clock drift does not break verification.

## Rotate the signing secret

Open the subscription's detail page and click **Rotate secret**. We generate a fresh 32-byte secret and return it once. The previous secret stays valid for seven days:

1. Copy the new secret immediately.
2. Deploy your receiver accepting both old and new secrets.
3. Within seven days, finish the cutover.

After seven days the retired secret is rejected. During the window, deliveries verify against whichever secret matches.

## Troubleshooting

- **`malformed_header`** — `t` or `v1` is missing. Check your receiver reads the header verbatim.
- **`expired_timestamp`** — your clock is more than five minutes off ours. Run NTP.
- **`signature_mismatch`** — the body bytes you HMAC'd do not match. The usual culprit is JSON re-serialization before the verifier runs.

If verifications keep failing, see [Why is my webhook not firing?](/developers/faq/webhook-not-firing) and [Replay and troubleshoot webhook deliveries](/developers/webhooks-replay-and-troubleshoot).