Every webhook delivery carries a signature header. Verify it.
Refusing to verify is the single most common way customers ship
exploitable webhook receivers; we do not consider an unverified
endpoint a working integration.
The signing secret is the whsec_* plaintext value you received once
when creating the endpoint. Rotate via
POST /v1/webhooks/{id}/rotate-secret — the new plaintext is shown
once; the old secret keeps verifying for 24 hours so deploys can roll.
The verification rules
A payload is valid iff all four hold:
- The signature header parses (one
t, one v1).
Math.abs(Date.now()/1000 - t) <= toleranceSec (default 300, i.e. 5 minutes).
HMAC_SHA256(secret, "${t}.${rawBody}").hex() === v1.
- Comparison uses a constant-time equality check (
crypto.timingSafeEqual or equivalent).
Use the raw bytes of the request body. Re-serialising via
JSON.stringify(JSON.parse(body)) will corrupt the signature on any
payload with non-canonical key order.
Reference implementations
Common pitfalls
Don’t re-serialise the body. Frameworks that parse JSON before your
handler runs will hand you a Ruby Hash / Python dict / JS object whose
JSON.stringify representation does not match the bytes we signed. Use
the raw-body middleware (express.raw, Request.body(),
request.body.read).
Don’t compare with === / == / .equal?. A naive equality check leaks
signature bytes through timing. Use timingSafeEqual, hmac.compare_digest,
hmac.Equal, or Rack::Utils.secure_compare.
Don’t trust req.body.timestamp over the header t. Some CDNs rewrite or
strip request bodies. The t in the header is what we signed; verify against
that, not against the JSON body.
Secret rotation
Rotate when an employee with secret access leaves, after any suspected
leak, and at least annually:
The previous secret keeps verifying for 24 hours, so a rolling
deploy that updates secrets across replicas does not drop deliveries.
After 24 hours, only the new secret is valid.