Verify webhook signatures
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
Section titled “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
Section titled “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
Section titled “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 ofHMAC-SHA-256(secret, "${t}.${rawBody}")whererawBodyis 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
Section titled “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
Section titled “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
Section titled “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:
- Copy the new secret immediately.
- Deploy your receiver accepting both old and new secrets.
- Within seven days, finish the cutover.
After seven days the retired secret is rejected. During the window, deliveries verify against whichever secret matches.
Troubleshooting
Section titled “Troubleshooting”malformed_header—torv1is 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? and Replay and troubleshoot webhook deliveries.