Why webhooks are signed
Your webhook endpoint is a public URL that accepts POSTs and does something important in response: marks an order paid, deploys code, sends a message. Anyone who knows or guesses the URL can POST to it. TLS proves the request reached you unaltered but says nothing about who sent it. The provider therefore signs each request with a secret only you and they know, and you recompute the signature and compare. If it matches, the body is exactly what they sent. If not, drop it.
How an HMAC signature is built
HMAC is a hash (almost always SHA-256) keyed with a secret. The provider computes HMAC-SHA256(secret, message) and puts the result in a header. The message is usually the raw request body, sometimes with a timestamp prepended. The exact recipe differs by provider, and the differences are where most bugs live:
| Provider | Header | Message | Encoding |
|---|---|---|---|
| Stripe | Stripe-Signature: t=1700000000,v1=... | "{t}.{raw body}" | hex |
| GitHub | X-Hub-Signature-256: sha256=... | raw body | hex |
| Slack | X-Slack-Signature: v0=... | "v0:{X-Slack-Request-Timestamp}:{raw body}" | hex |
| Shopify | X-Shopify-Hmac-Sha256 | raw body | Base64 |
| Twilio | X-Twilio-Signature | full URL + sorted POST params | Base64, HMAC-SHA1 |
| Standard Webhooks | webhook-signature: v1,... | "{id}.{timestamp}.{raw body}" | Base64 |
Read the provider's documentation for the exact message format. The pattern is always the same; the punctuation is not.
The raw body mistake
Nearly every signature mismatch I have debugged came down to this: the code signed something other than the exact bytes the provider sent. Web frameworks parse the JSON body for you, and if you then re-serialise it (JSON.stringify(req.body), json.dumps(request.json)) you get different bytes: keys reordered, whitespace removed, unicode escaped differently, floats reformatted. The HMAC of those bytes is not the HMAC of the original.
You must have access to the raw request body as bytes, before any parsing. In Express that means express.raw({ type: 'application/json' }) on the webhook route instead of express.json(), or a verify callback that stashes req.rawBody. In Django, request.body. In Go, io.ReadAll(r.Body) before decoding. In Rails, request.raw_post. If a middleware or an API gateway has already consumed and re-encoded the body, the signature can never verify and you need to fix the pipeline.
Verifying step by step
- Read the raw body bytes. Do not decode, trim or re-encode.
- Read the signature header and, if present, the timestamp header.
- Build the message exactly as the provider specifies (for Stripe,
timestamp + "." + body). - Compute
HMAC-SHA256(secret, message)with the secret as bytes (UTF-8 of the string they gave you, unless they say it is Base64 or hex encoded, in which case decode it first). - Encode the result the way they do: lowercase hex or Base64.
- Compare to the header value using a constant-time comparison (
crypto.timingSafeEqual,hmac.compare_digest,hmac.Equal,ActiveSupport::SecurityUtils.secure_compare). A plain==leaks timing information that lets an attacker forge a signature byte by byte. - If a timestamp is included, reject requests older than a few minutes. This stops a captured valid request being replayed later.
- Only then parse the JSON and act on it.
Reproducing a signature by hand
When it does not match, take the provider out of the loop. Point the webhook at the webhook tester, trigger an event, and copy the raw body and the signature header from the captured request. Then in the HMAC generator: paste the body (or the timestamp-dot-body message) as the text, the signing secret as the key, and the header's signature as the expected checksum. It will tell you which algorithm and encoding match, or that nothing does. If it matches there and not in your code, your code is not signing the same bytes. If it does not match there either, the secret is wrong (rotated, wrong environment, copied with a trailing newline).
# the same thing from a shell printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" # GitHub style, hex printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" # Stripe style printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -binary | base64 # Shopify style
printf '%s' rather than echo: echo appends a newline and the newline changes the hash.
Other reasons it does not match
- Wrong secret. Test vs live keys, a secret per endpoint (Stripe issues one per webhook endpoint, not per account), or a secret that was rotated.
- Secret encoding. Some providers give you a Base64 string that must be decoded to bytes before use. Using the string as-is produces a valid-looking but wrong HMAC.
- Header case or prefix.
sha256=must be stripped before comparing. Some frameworks lower-case header names, some do not. - Multiple signatures. During a secret rotation the header may carry two values (Stripe's
v1=...,v1=...). Accept if any matches. - Body transformation by a proxy. An API gateway that pretty-prints JSON, a CDN that normalises encoding, a framework that decodes
+as space. Compare the body length in the captured request to what your handler received. - Charset. The body is bytes. If your code turns it into a string and back with the wrong encoding, non-ASCII payloads break while ASCII ones pass, which is a confusing intermittent failure.
Beyond the signature
Verify, then respond quickly (200 within a few seconds) and do the real work asynchronously; providers retry on timeout and you will process events twice. Make handlers idempotent using the event ID, because retries happen even when you did respond. Restrict the endpoint by source IP only if the provider publishes stable ranges (check with the ASN lookup), and treat it as a second layer, not a replacement for the signature.