Skip to main content
Every webhook delivery carries:
The signature is computed as:
Where raw_body is the bytes of the request body as received, not a re-serialized JSON string.

Verification steps

  1. Parse t= and v1= from the X-Webhook-Signature header.
  2. Reject the request if |now - t| > 300 seconds (anti-replay window of ±5 minutes).
  3. Recompute the HMAC with your stored signing secret over the string "{t}.{raw_body}".
  4. Compare the recomputed signature with v1 using a constant-time equality function.
If any step fails, return 400 Bad Request and do not process the payload.

Node.js

In Express, use express.raw({ type: 'application/json' }) on the webhook route to get the raw body bytes before JSON parsing. Calling JSON.parse then JSON.stringify re-serializes the payload and breaks the signature.

Python

Kotlin

Go

Common mistakes

  • Re-serializing JSON. Sign the raw bytes as received, not a pretty-printed or re-normalized JSON string. Middleware in Express, Django, or Spring often re-parses the body; use a raw-body reader specifically on the webhook route.
  • Plain string comparison. Use a constant-time comparison (crypto.timingSafeEqual, hmac.compare_digest, MessageDigest.isEqual, hmac.Equal). Plain == leaks timing information that can be exploited.
  • Skipping the timestamp check. Without the ±5 min check, a captured valid request can be replayed indefinitely by an attacker.
  • Trimming or decoding the header. Pass the X-Webhook-Signature header value verbatim — do not URL-decode or trim whitespace before splitting on ,.