Verifying a webhook signature
Everything you need to check that a webhook came from us and has not been replayed or altered: the header we send, the string it signs, and the five rules a verifier has to follow.
The header shape below was measured against twenty real deliveries captured by a receiving endpoint, not taken from the specification alone. Where a claim rests on a measurement rather than the scheme, it says so.
The header
Every delivery carries blackjack-signature:
blackjack-signature: t=<unix seconds>,v1=<64 lowercase hex>
and, while a secret rotation is in progress, a second element:
blackjack-signature: t=<unix seconds>,v1=<64 lowercase hex>,v1=<64 lowercase hex>
There is one v1= element per live secret, and the current secret is always first. This is
measured: of twenty distinct headers captured from real deliveries, fifteen were 80 characters with
one element and five were 148 with two, and 148 is 80 plus the 68 characters of ,v1= and a 64
character digest.
The delivery also carries content-type: application/json and x-request-id. Quote the
x-request-id when you contact support -- it is the same identifier our own logs are keyed on for
that delivery.
What the signature covers
Each element is an HMAC-SHA256, hex encoded in lowercase, of this exact string:
<the same unix seconds from t=>.<the raw request body>
A literal . separates them. Sign the raw body as received, before any JSON parsing or
re-serialising. Re-encoding a parsed body changes whitespace and key order, and the digest will
not match.
On a framework that parses the body for you, reading it consumes it. In a Next.js or Express
route handler, await request.json() drains the stream and the bytes cannot be recovered afterwards,
so the digest is computed over something you no longer have. Take the text first and parse it
yourself:
const raw = await request.text(); // the bytes the signature covers
// verify against `raw`, and only then:
const event = JSON.parse(raw);
The same applies to any middleware that installs a body parser ahead of your handler. If one is already in place, it has to be disabled for this route or configured to keep the raw bytes.
Verifying it
The first rule is first because it is the one an integrator writes by accident.
1. Do not select an element by its length
The obvious loop picks the offered element whose length matches your expected digest. That is a
filter that filters nothing: every v1= is 64 hex characters, so it always selects the first one.
If you hold the previous secret during a rotation, your expectation is then compared against the
element signed with the new secret and verification fails -- which is the exact failure that
multiple elements exist to prevent, reintroduced one line later. Our own first draft did this.
Compare against every element.
2. Check the timestamp first, and exactly once
Parse t=, compare it against your clock, and reject outside your tolerance before looking at
any digest. The timestamp is signed into every element, so accepting a stale one because some
element happened to match would make your replay window depend on how many of our secrets are live
at that moment.
We do not publish a fixed tolerance. The timestamp is ours; the tolerance is yours to choose, and it trades replay exposure against clock skew on your side. A few minutes is typical.
3. Compare in constant time
Use your platform's constant-time comparison (crypto.timingSafeEqual in Node, hmac.compare_digest
in Python). A byte-by-byte string comparison leaks the expected digest.
4. Do not return early on the first match
Compare all elements and combine the results, rather than returning as soon as one matches. An early return leaks, through timing, which of the live secrets signed the request.
5. Reject anything that does not parse
The whole header value must match, end to end:
^t=(\d+),((?:v1=[0-9a-f]{64})(?:,v1=[0-9a-f]{64})*)$
These five rules match the reference implementation our own SDK helpers use; each rule's reason is the one given above.
Why a second element appears
When a signing secret is rotated, the previous one stays valid until its expiry so that a receiver
which has not yet picked up the new secret keeps working. During that window we sign with both and
send both elements, rather than sending a second header: a header your existing code does not read
is a silent change, while another v1= on a header you already parse is one your verifier either
handles or visibly does not.
A verifier that follows rule 1 needs no change when a rotation starts or ends.
Your endpoint
- Respond with a 2xx status. The response body is discarded; only the status is treated as proof of delivery.
- Do not redirect. A redirect is refused rather than followed, so an endpoint answering
302receives nothing and will not be told why. A webhook that followed a redirect could be exfiltrated by a compromised endpoint answering one. - Answer promptly. The request is abandoned after a timeout, and an abandoned delivery is retried on a backoff rather than treated as delivered.
When verification fails
Answer 400. That is what our own reference receiver answers, and consistency is most of the
value, because the status itself changes nothing on our side.
Every response that is not 2xx is treated identically. It is recorded and retried on an
escalating backoff until the delivery is abandoned. There is no status that stops the retries, so
a 400, a 401 and a 503 all produce the same schedule. Choosing between them is a question about
your own logs, not about our behaviour.
And that matters more than it sounds, because a verification failure repeats. The body and the header do not change between attempts, so an event that fails to verify once fails every time, identically, until it dies. A run of identical failures on one event is the signature of a verification problem rather than an outage -- an outage recovers and this does not.
Record the header verbatim and which check failed. Our delivery log shows only the response status; the reason exists nowhere but your side. A stored header is what distinguishes a secret mismatch from a truncated value from a proxy that rewrote it, and none of those is visible to us.
When the header is absent
A different case from one that is present and does not verify, and worth separating in your logs
even though the answer is the same 400.
An absent blackjack-signature almost always means something between us and your handler removed it:
a proxy, a gateway, or a framework that strips unknown headers. A present header that fails to
verify points at the secret or at the body, which is a different repair. Logging the two the same
way costs an hour the first time it happens.
What this page does not cover
The event payload's own shape, the retry schedule, and the endpoint management API. This page is the signature only.