Skip to main content
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 header

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:
  1. The signature header parses (one t, one v1).
  2. Math.abs(Date.now()/1000 - t) <= toleranceSec (default 300, i.e. 5 minutes).
  3. HMAC_SHA256(secret, "${t}.${rawBody}").hex() === v1.
  4. 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.