Skip to main content

Verifying a delivery

Every delivery carries three headers:

The exact bytes

X-Signature is HMAC-SHA256 over the byte string v1: + the timestamp + : + the raw body bytes exactly as they arrived. The prefix and the timestamp are ASCII; the body is transmitted as UTF-8 and may contain non-ASCII characters, so never re-encode it and never decode it into a charset of your own on the way in. Sign the bytes. The key is your signing secret as ASCII bytes, whsec_ prefix included — no base64-decoding step, no key derivation of your own. The header value is lowercase hex and nothing else: there is no v1= prefix on it, no comma-separated list and no algorithm name, because the version already lives inside the signed string where it cannot be swapped by whoever is replaying the body. The timestamp is Unix seconds as a decimal string, covered by the signature, and a delivery whose timestamp is more than 300 seconds away from your clock must be rejected before anything is compared. X-Signature-Previous, when present, is the same string signed with your previous secret during a rotation overlap — accept either, and treat its absence as normal.

A test vector

Check your verifier against this offline, before you register anything. The three inputs are exactly what a delivery carries; the output is what your code must compute.
The string you sign is therefore v1:1789200000: followed by those bytes, and
If your implementation produces something else, the usual causes are, in order: you signed a re-serialised body rather than the bytes; you decoded the secret instead of using it as ASCII; you left out the v1: prefix or the colon after the timestamp; or you uppercased the hex.

A second vector, with multi-byte characters in it

The vector above is pure ASCII, so it cannot tell you whether your verifier re-encodes the body. This one can, and it is the mistake worth catching before it ships: a verifier that asks for the body as ASCII or Latin-1 — getBytes("US-ASCII"), .encode("ascii"), Encoding.ASCII.GetBytes — passes the first vector, passes every test you write in English, and then rejects a real delivery the first time a body carries a character outside ASCII. Same secret, same timestamp. Only the body changes. It is synthetic — not the shape of any real event — and carries José 中村 purely to exercise multi-byte UTF-8. It is 221 bytes of UTF-8 across 216 characters, because é costs two bytes and each of 中 and 村 costs three.
If the first vector passes and this one does not, the difference is your body encoding and nothing else. What a re-encode produces varies by language — some truncate each character to one byte, some substitute ?, some raise — so there is no single wrong answer to compare against. There is only this one: 61f817f1… is what the delivery on your socket is signed with. Four rules, in order of how often they are gotten wrong:
  1. Verify against the raw request body, before any JSON parsing. JSON.parse followed by JSON.stringify does not round-trip byte-identically — key order, whitespace and 1.0 → 1 all move — so a re-serialised body fails verification every time. Capture the bytes as they arrived.
  2. Reject a timestamp more than five minutes old before comparing anything. The timestamp is inside the signed string, so a replayed body cannot carry a fresh one.
  3. Compare in constant time, using your language’s timing-safe comparison — never == on the hex strings.
  4. During a rotation, accept either signature. X-Signature-Previous is present only while a rotation overlap is open; its absence is normal, not an error. If you lose your secret or it leaks, rotate it yourself with POST /v1/webhooks/endpoint/rotate-secret — we sign with both secrets for an overlap window so you deploy on your own clock, never against ours. Rotate once and store what it returns: a second rotation inside the overlap is refused with 409 rotation_in_progress precisely so a retry can never push the secret you are running out of the signing set.
Once a signature checks out, Webhook delivery covers retries, ordering, the event catalogue and the rest of the endpoint’s lifecycle.