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. Ours does not, and
neither should yours.
We always send exactly one v1. If a header ever carries two, our parser — and the
examples below — silently keep the last one rather than reject it; a stricter receiver may
reject outright instead, since nothing we send 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 you back a string that looks identical — but 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
hmac.compare_digest, crypto.timingSafeEqual, hmac.Equal — not ==. 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. These are asserted against our own signer in CI, so if your code reproduces this digest, it agrees with what we actually send.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 our CI, so it cannot drift from what we send.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 — we would rather say so than imply a guarantee we do
not have.
Storing and rotating the secret
Keep the secret in an environment variable or a secret manager, one per endpoint. We store it encrypted, but we can still read it back — the server has to, in order to sign — so it is not recoverable-proof the way an API key is. 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.
The way 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.