Skip to content

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.

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.

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

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.

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.

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.

  1. Make sure your receiver reads its signing secrets from mutable configuration and accepts more than one.
  2. Open the subscription’s detail page, click Rotate secret, and copy the new secret immediately. It is shown once and is otherwise unrecoverable.
  3. Add the new secret to your receiver’s configuration. Until you do, deliveries fail signature verification, retry, and can trip the auto-disable threshold.
  4. 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.

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_headert or v1 is missing. Check your receiver reads the header verbatim.
  • expired_timestamp: the t in 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.