§ 06 When it fails
Why signature verification fails
Nearly every failure is one of these. The first two account for most of them.
01
The secret is the wrong one
No signatures found matching the expected signature for payload · every request fails, even fresh ones
Stripe's docs call this the most common cause. Each Stripe endpoint has its own whsec_ secret, and an endpoint used in both test and live mode has a different secret for each. Events forwarded by stripe listen are signed with the secret the CLI prints, not the Dashboard's. For Slack, use the Signing Secret, not the Client Secret. For Shopify, the app's client secret; after you rotate it, Shopify can take up to an hour to switch.
Then check for invisible extras: a trailing newline or space copied into an .env file is part of the key. Stripe's Node library adds a note to its error when the secret contains whitespace.
02
The body was parsed before you checked it
Webhook payload must be provided as a string or a Buffer … Payload was provided as a parsed JavaScript object instead
A JSON middleware ran first, so your code got an object, or a re-serialized string, instead of the bytes that were signed. In Express, put express.raw() on the webhook route and mount app.use(express.json()) after it: Express runs middleware in order. In Flask, call request.get_data() before request.json or request.form. Stripe's troubleshooting guide lists the fixes for Next.js and AWS API Gateway too.
03
Hex where the provider uses base64, or the reverse
Your value is 64 characters, the header's is 44 (or the other way round)
The bytes are the same; the text isn't. SHA-256 gives 32 bytes: 64 characters in hex, 44 in base64 (ending in =). Stripe, GitHub and Slack use hex; Shopify and Standard Webhooks use base64. Also keep or drop the prefix consistently: GitHub's header starts with sha256=, Slack's with v0=, Standard Webhooks' with v1,.
04
A tool added or removed whitespace
A replayed or hand-made request fails, while the real delivery passed
Editors often save a fixture file with a final newline, and echo adds one too. curl -d @file.json strips carriage returns and newlines from the file. When you replay a captured body, send it byte for byte: printf '%s' or --data-binary @file.json. Then remember the timestamp: a replay of an old capture is rejected as too old, by design.
05
Your server's clock is off
Timestamp outside the tolerance zone · valid signatures rejected
The signature matches, but the age check fails because your clock says it's later (or earlier) than it is. Stripe recommends NTP to keep your server's clock in sync. Don't "fix" it by setting the tolerance to 0: in Stripe's libraries, that turns the check off entirely.
06
A proxy or relay changed the body
It passes in the webhook tester, fails in your app
Something between the provider and your code rewrote the bytes. The smee.io client parses the JSON and writes it out again, so whitespace and escapes can change (smee-client#325). An AWS API Gateway mapping template needs extra setup to pass the raw body through. GitHub's troubleshooting list says the same about proxies and load balancers: they must not modify the payload or headers. Stripe's library error says it too: a forwarding tool has to keep "the exact request body, including JSON formatting and new line style".
07
Comparing with ==, or crashing on length
It works, but leaks timing · or a garbage header gives a 500 instead of a 401
A plain == or === leaks timing; use a constant-time comparison. But note: Node's crypto.timingSafeEqual throws if the two buffers have different lengths, so check the length first, as the safeEqual helper above does. Python's hmac.compare_digest accepts str only if it's ASCII and raises TypeError otherwise; comparing encoded bytes, as above, avoids that on odd input.
08
Only the first signature was checked
Verification starts failing right after you roll a Stripe secret
During a roll, Stripe sends one v1= per active secret. Code that reads only the first one fails whenever the match is in another position. Check every v1 value and accept if any matches. The same goes for each entry in a Standard Webhooks webhook-signature list.
09
The Standard Webhooks secret wasn't decoded
No matching signature found · with a secret you just copied
A Standard Webhooks secret looks like Stripe's, whsec_ plus text, but it's used differently. Stripe uses the whole string as the key. Standard Webhooks strips whsec_ and base64-decodes the rest, and that decoded data is the key. Feed the string straight into createHmac and every signature fails. Switch the playground to Standard Webhooks to see the decoded key length.
Check a real requestPoint the provider at the
webhook tester, trigger an event, open the request, and paste your secret into the
Signature tab. It shows the signed content, the value in the header and the value it computed, in your browser. If it passes there and fails in your app, your app (or something in front of it) changed the bytes. If it fails there too, the secret is wrong.