Skip to main content
Your endpoint URL is reachable by anyone who learns it. The signature is what makes a request that arrives there provably ours. Each endpoint has its own secret, shown once when you create it and revealable afterwards in the console under Settings → Webhooks. Treat it like a password: environment variable, never in a repository, never in client-side code.

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 .:
2. HMAC it with the whole secret. The key is the whole secret string, including its 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, and JSON.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 fresh t 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 one v1, so an endpoint cannot accept both secrets during a changeover. The way to rotate without dropping a delivery:
  1. Rotate in the console and copy the new secret.
  2. Deploy it to your receiver.
  3. If any delivery failed in between, resend it from the endpoint’s delivery log — the resend is signed with the new secret.
There is no quiet hour to aim for. Deliveries fire when the change that caused them lands, and a retry can arrive up to 15.8 hours after the delivery it belongs to, so the only thing that narrows the exposure is keeping steps 1 and 2 close together and checking the log afterwards.