The signature
Every delivery carries:
Reject anything that does not match those two shapes before you do any crypto. The
v1 length
rule is not pedantry: some hex decoders silently drop a trailing odd character, so accepting
“any hex” means several different header strings verify the same payload.
A delivery always carries exactly one v1. If a header ever carries two, the examples below
keep the last one rather than reject it. A stricter receiver may reject outright instead, since
nothing sent to you will ever trip that check.
Computing it
Two steps, in this order. 1. Build the signed string. Join the timestamp and the raw body with a single.:
whsec_ prefix. Not the part after the prefix, the whole thing, exactly as the console showed
it to you. Take HMAC-SHA256, render it lowercase hex, and compare it to v1 in constant time.
Use the raw body
Sign the exact bytes that arrived. Not a parsed object, not a re-serialised one. This is the mistake that costs an afternoon. Most web frameworks parse JSON for you, andJSON.stringify on the result gives back a string that looks identical. Key order, spacing and
number formatting are all free to differ, and created_at carries milliseconds that a
round-trip through a date type will quietly drop. One changed byte changes the whole digest, so
every signature mismatches and nothing in the error tells you why.
In Express, that means express.raw({ type: 'application/json' }) on the webhook route. In
Flask, request.get_data(), not request.json. In Go, read r.Body yourself before decoding.
Check the timestamp
Reject anything more than five minutes away from your own clock, in either direction. A future timestamp is as much a red flag as a stale one, and clamping only the past leaves a clock-skewed replay valid indefinitely. Retries are re-signed with a fresht at send time, so a legitimate retry sixteen hours later
still arrives inside the window.
Compare in constant time
Usehmac.compare_digest, crypto.timingSafeEqual or hmac.Equal, never ==. A
byte-at-a-time comparison leaks how much of a guess was right.
Test vector
Fixed values you can check your implementation against before wiring anything up. They are asserted against the real signer in CI, so a digest your code reproduces here agrees with what arrives at your endpoint.These are fixed test values, not a specimen delivery. A real payload’s
created_at carries
milliseconds (2026-07-29T06:15:02.128Z); this one is a constant chosen so the vector never
moves.Node
This exact snippet is executed against the vector above in CI, so it cannot drift from what arrives at your endpoint.Python
Go
Only the Node example is executed automatically. The Python and Go examples are checked against
the same vector by hand rather than in CI, which is stated here rather than left to imply a
guarantee that does not exist.
Storing and rotating the secret
Keep the secret in an environment variable or a secret manager, one per endpoint. It is stored encrypted, but it stays readable in a way an API key does not, because signing needs it back. If you think it has leaked, rotate it. Rotation happens immediately and it is a hard cutover. The moment you rotate, the old secret stops signing anything. Deliveries already in flight, and any retry of a delivery signed with the old secret, will fail verification at your end. There is no overlap window, and the header carries exactly onev1, so an endpoint cannot accept both secrets during a changeover.
To rotate without dropping a delivery:
- Rotate in the console and copy the new secret.
- Deploy it to your receiver.
- If any delivery failed in between, resend it from the endpoint’s delivery log. The resend is signed with the new secret.