I integrated Stripe, then Shopify, then Slack. Each time I wrote webhook signature
verification from scratch, because nothing from the last one carried over. That
got old, so I sat down and read the actual specifications for eight providers to
find out how much they really differ.
More than I expected. Here is the map, and the four places where the differences
are worth knowing about rather than just annoying.
The map
| Provider | Header(s) | Algorithm | Encoding | Timestamp | Delivery id |
|---|---|---|---|---|---|
| Stripe | Stripe-Signature |
HMAC-SHA256 | hex | 300s, stale only | body id
|
| GitHub | X-Hub-Signature-256 |
HMAC-SHA256 | hex | — | X-GitHub-Delivery |
| Shopify | X-Shopify-Hmac-SHA256 |
HMAC-SHA256 | base64 | — | X-Shopify-Webhook-Id |
| Slack |
X-Slack-Signature + X-Slack-Request-Timestamp
|
HMAC-SHA256 | hex | 300s, both ways | body event_id
|
| Standard Webhooks |
webhook-id / -timestamp / -signature
|
HMAC-SHA256 | base64 | 300s, both ways | webhook-id |
| Paddle | Paddle-Signature |
HMAC-SHA256 | hex | 5s, stale only | body event_id
|
| Twilio | X-Twilio-Signature |
HMAC-SHA1 | base64 | — | body MessageSid
|
| Telegram | X-Telegram-Bot-Api-Secret-Token |
none — shared secret | — | — | body update_id
|
Even the part everyone agrees on — HMAC-SHA256 over something — hides four
different "somethings":
Stripe <timestamp>.<body>
Slack v0:<timestamp>:<body>
Paddle <timestamp>:<body>
Standard Webhooks <id>.<timestamp>.<body>
GitHub, Shopify <body>
Note Stripe and Paddle. Same algorithm, same encoding, same two ingredients —
different delimiter. Copy a working Stripe verifier over to Paddle and change the
header name, and you get something that runs, throws no errors, and rejects every
genuine delivery.
1. The same secret prefix means two different things
Stripe's signing secret looks like whsec_abc123.... Standard Webhooks' signing
secret also looks like whsec_abc123....
They are not used the same way:
// Stripe: the entire string is the HMAC key, prefix included.
const stripeKey = utf8("whsec_abc123...");
// Standard Webhooks: base64-decode everything after the prefix.
const swKey = base64Decode("abc123...");
This one is nastier than a wrong delimiter, because the failure is silent in the
worst direction. If you write the Standard Webhooks verifier using Stripe's
convention, you compute an HMAC under the wrong key, no signature ever matches,
and you "fix" it by loosening the check. I have seen exactly that fix in the wild:
a try/catch around verification that logs and continues.
Standard Webhooks is the scheme behind Svix, Clerk and Resend, so this affects
more services than the name suggests.
2. Twilio doesn't sign the body
Twilio is the outlier twice over. It uses SHA-1 rather than SHA-256, and it signs
the request URL rather than the payload.
For form-encoded deliveries, it appends every parameter's name and value to the
URL, sorted by name, and signs that:
https://example.com/hookCallSidCA123Digits1234
For JSON deliveries, it signs the URL alone. The body is covered indirectly: the
URL is expected to carry a bodySHA256 query parameter holding the digest of the
body.
Which means: if you verify the signature but don't check that bodySHA256
actually matches the body you received, the body is not protected at all. The
signature proves the URL is genuine. The payload is free to be anything.
// Necessary, not optional, for Twilio's JSON mode.
const claimed = new URL(url).searchParams.get("bodySHA256");
const actual = bytesToHex(await crypto.subtle.digest("SHA-256", body));
if (claimed?.toLowerCase() !== actual) reject();
A verifier that skips this is a verifier that authenticates nothing about the
content — while looking, in the logs, exactly like one that works.
3. Nobody agrees on what a timestamp means
Four of the eight schemes carry a timestamp so you can reject replayed requests.
They disagree on the window and on the direction:
- Stripe: 300 seconds, and only rejects deliveries that are too old.
- Slack: 300 seconds, absolute value — too old or too far in the future.
- Standard Webhooks: same, both directions.
- Paddle: 5 seconds, stale only.
The tidy thing to do is pick 300 seconds in both directions and apply it
everywhere. I started there and it was wrong in two different directions at once.
Making Stripe two-sided breaks real deliveries. If Stripe's clock is a few seconds
ahead of yours, a genuine webhook arrives "from the future" and you reject it — for
no security benefit, because the timestamp is covered by the signature. An attacker
cannot choose it. Only Stripe can, and Stripe has no reason to lie to you.
Relaxing Paddle to 300 seconds silently widens your replay window by 60× compared
to Paddle's own SDK. If someone migrates to your library expecting parity, you have
weakened their setup without telling them.
So: implement each vendor's rule, and document it. Paddle's 5 seconds is genuinely
tight enough to reject real traffic under ordinary clock drift — that is Paddle's
choice to make, and the caller's choice to override, not the library's choice to
quietly paper over.
4. The raw body trap
This is the one that costs the most debugging hours, and it has nothing to do with
which provider you use.
A signature covers the exact bytes that were sent. Your framework, being helpful,
parses those bytes into an object before your handler runs — and the bytes are then
gone. Re-serializing gives you a JSON document, not the one:
const received = '{"amount":1000, "currency":"usd"}';
JSON.stringify(JSON.parse(received));
// '{"amount":1000,"currency":"usd"}' ← two spaces gone, signature now invalid
Key order, whitespace, unicode escaping and number formatting are all free to
change. This is not a bug in JSON.parse; it simply is not a lossless round trip.
What makes it vicious is that it fails inconsistently. Payloads with no
incidental whitespace round-trip fine, so verification often works against your own
fixtures and fails against the provider's real deliveries.
In Express the fix is entirely about middleware order:
// ✅ Raw parser on the webhook route, mounted before any JSON parser exists.
app.post("/hooks/stripe", express.raw({ type: "*/*" }), handler);
app.use(express.json()); // everything else, after
// ❌ Silently breaks every webhook route below it.
app.use(express.json());
app.post("/hooks/stripe", express.raw({ type: "*/*" }), handler);
Use type: "*/*" rather than "application/json", because GitHub and Slack can
both deliver form-encoded bodies, and a mismatched type hands you an empty object
instead of a Buffer.
Two things that bit me while implementing
Joining strings corrupts bodies that aren't valid UTF-8
The schemes that prefix the body with a timestamp are specified over bytes. The
obvious implementation is a template literal:
const signedPayload = `${timestamp}.${body}`; // works, until it doesn't
If body came off the wire as a string that is already valid UTF-8, fine. If it
contains malformed UTF-8, the round trip through JavaScript's UTF-16 strings
replaces those bytes with U+FFFD, and the signature cannot match. Join the bytes
instead:
const signedPayload = concat(utf8(`${timestamp}.`), bodyBytes);
crypto.timingSafeEqual is unusable on edge runtimes
Comparing signatures with === is a real vulnerability, not a style issue. String
equality returns on the first differing byte, so response timing leaks how many
leading bytes matched — which turns forging a signature from a 2^256 problem into
roughly 32 × 256 guesses.
Node's answer is crypto.timingSafeEqual. It has two problems here. It doesn't
exist in Web Crypto, so it is unavailable on Cloudflare Workers, Vercel Edge, Deno
and browsers. And it throws when the two inputs differ in length — which is
exactly what a truncated attacker-supplied signature produces, so you need a length
check first, and that check leaks the length.
What works on every runtime is double-HMAC blinding: generate a random key for the
single comparison, HMAC both operands under it, and compare the fixed-length
digests without an early exit.
const key = await crypto.subtle.importKey(
"raw", crypto.getRandomValues(new Uint8Array(32)),
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"],
);
const [a, b] = await Promise.all([sign(key, left), sign(key, right)]);
let diff = 0;
for (let i = 0; i < 32; i++) diff |= a[i] ^ b[i];
return diff === 0;
Because the key is unpredictable and used once, an attacker cannot steer the digest
bytes, so timing reveals nothing about the originals. It is also length-independent
for free: digests are always 32 bytes, so mismatched inputs need no special case.
To be clear about what that does and does not establish: the property is
structural — no data-dependent branches, no early exit, blinded operands. I have
not validated it with statistical timing measurements.
The takeaway
If you only remember three things:
- Capture the raw bytes before anything parses them. Most verification failures are this.
-
Never compare signatures with
===. Use a constant-time comparison, and on edge runtimes that means writing one. - Follow each provider's timestamp rule rather than inventing a uniform one. Uniformity breaks Stripe in one direction and weakens Paddle in the other.
I ended up packaging all of this so I would not write it a ninth time:
webhook-kit — one API over the eight
schemes, zero runtime dependencies, Web Crypto only so it runs on Node, Bun, Deno
and edge runtimes.
It is new, so there is no adoption to point at. If you spot something wrong in the
crypto or in my reading of a spec, I would genuinely like to know.
Top comments (0)