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”Reject timestamps more than five minutes (300 seconds) old or in the future. Keep the window symmetric so small clock drift does not break verification. On an outbound delivery the platform only signs, it never verifies, so this freshness check is yours: without it a captured delivery can be replayed against your endpoint indefinitely.
Rotate the signing secret
Section titled “Rotate the signing secret”Rotation is immediate and single-secret. From the moment you rotate, every delivery, including a retry or a replay of an event emitted before the rotation, is signed with the new secret only. The previous secret is never used to sign again, so there is no platform-side grace period.
Because of that, your receiver must be able to accept a second secret without a code deploy. Read the secret from configuration or a secrets manager you can update independently, and have the verifier try each configured secret in turn.
- Make sure your receiver reads its signing secrets from mutable configuration and accepts more than one.
- Open the subscription’s detail page, click Rotate secret, and copy the new secret immediately. It is shown once and is otherwise unrecoverable.
- Add the new secret to your receiver’s configuration. Until you do, deliveries fail signature verification, retry, and can trip the auto-disable threshold.
- Remove the old secret once you have confirmed a delivery verified against the new one.
For seven days after a rotation the console marks the subscription Rotating. That badge is a reminder to finish the cutover; it does not extend the old secret’s validity on our side.
Troubleshooting
Section titled “Troubleshooting”These are the three ways verification fails. They are the failure classes to name in your own verifier’s logs; the platform never sends a reason code back to you.
malformed_header—torv1is missing. Check your receiver reads the header verbatim.expired_timestamp: thetin the signature header is outside your freshness window. Usually your server’s clock has drifted, so 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.