Notes
Stripe "No signatures found matching the expected signature": twelve causes, one at a time
Stripe webhook signature verification fails with this exact message when constructEvent() rejects a webhook request outright, before your code ever reads the event:
Webhook signature verification failed. Err: No signatures found matching the expected signature for payload.constructEvent() checks exactly three inputs: the raw request body, the whsec_ signing secret, and the Stripe-Signature header. It fails the same way on Next.js and on plain Express, usually because some layer before your handler already parsed or rewrote the raw body. Below are the twelve causes, one at a time.
01
The body already arrived as parsed JSON
Something upstream, a global body parser, a logging proxy, an API gateway, parsed the request body into a JavaScript object before constructEvent() ran. Stripe's docs are direct: "Stripe requires the raw body of the request to perform signature verification... Any manipulation to the raw body of the request causes the verification to fail." A parsed object is not the raw body, even when its content looks identical.
Fix
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const event = stripe.webhooks.constructEvent(
req.body, req.headers['stripe-signature'], secret
);
});
02
The body was parsed, then re-serialized
Some handlers call JSON.parse() on the body, do something with it, then call JSON.stringify() on the result before passing it to constructEvent(). Re-serializing can change whitespace or key order versus the exact bytes Stripe signed. The HMAC comparison fails even though the data looks identical.
Fix
// wrong: constructEvent(JSON.stringify(JSON.parse(raw)), sig, secret)
const event = stripe.webhooks.constructEvent(raw, sig, secret); // keep the original bytes
03
A global body parser runs before the webhook route
In Express, app.use(express.json()) mounted ahead of the webhook route consumes and parses the stream first. Stripe's troubleshooting docs single this out: "the order of middleware configuration matters." Scope raw-body parsing to the webhook route alone, mounted before any global parser.
Fix
app.post('/webhook', express.raw({ type: 'application/json' }), webhookHandler);
app.use(express.json()); // every other route, mounted after
04
Next.js App Router: req.json() instead of req.text()
A route handler under app/api/.../route.ts that calls await req.json() has already parsed the body. Stripe's own Next.js example in the stripe-node repository reads it with req.text() specifically, so constructEvent() gets the untouched string.
Fix
export async function POST(req) {
const body = await req.text(); // not req.json()
const event = stripe.webhooks.constructEvent(
body, req.headers.get('stripe-signature'), secret
);
}
05
Missing await on req.text()
req.text() returns a Promise. Without await, constructEvent() receives the pending Promise, not a resolved string. That produces a different error, "Webhook payload must be provided as a string or a Buffer", so if that is what you see, check this line.
Fix
const body = await req.text();
06
Next.js Pages Router: default body parser not disabled
Pages Router API routes parse the body as JSON by default. Without export const config = { api: { bodyParser: false } }, Next has already consumed and parsed the raw bytes before your handler runs.
Fix
export const config = { api: { bodyParser: false } };
async function buffer(req) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
return Buffer.concat(chunks);
}
07
The secret is an API key, not the signing secret
The value stored as STRIPE_WEBHOOK_SECRET sometimes starts with sk_ or pk_, an API key copied from the wrong place. Stripe's Dashboard docs describe the real thing plainly: "a signing secret beginning with whsec_ appears" on the endpoint's own settings page. An API key never verifies a webhook signature.
Fix
Copy the whsec_... value from Dashboard, Webhooks, your endpoint, Reveal secret, or from the stripe listen terminal output for local testing.
08
The secret variable never reaches the handler
If the environment variable holding the secret is unset, misspelled, or simply missing in this deployment environment, constructEvent() throws the same "No signatures found" error as a wrong secret. Stripe's library does not distinguish an empty secret from an incorrect one.
Fix
Log the first few characters of the secret at boot (never the whole value) to confirm it actually loaded in this environment, not just on your machine.
09
Using the CLI's forwarding secret against a live endpoint
The whsec_ value printed by stripe listen is generated for local forwarding, not for any registered endpoint. Stripe's docs: "Don't verify signatures on events forwarded by the CLI using the secret from a Dashboard-managed endpoint, or the other way around." Every endpoint and mode, test and live, has its own secret.
Fix
Use the whsec_ secret from Dashboard, Webhooks, the specific live-mode endpoint that is actually receiving the request.
10
Reading the wrong header
Stripe only ever sends the signature in the Stripe-Signature header (Node normalizes header names to lowercase). Reading any other header name, or a header renamed by a proxy along the way, never finds a valid value.
Fix
const signature = req.headers['stripe-signature'];
11
Tolerance set to zero, or a skewed server clock
constructEvent() compares the timestamp Stripe signed into the header against the server clock, inside a tolerance window that defaults to 300 seconds. Stripe's docs warn: "Don't use a tolerance value of 0," it disables the recency check rather than tightening it. A clock drifted past the window fails the same way, even with a correct secret and body.
Fix
Drop any custom tolerance argument to keep the 300-second default, and sync the server clock with NTP.
12
Serverless and edge runtimes: base64 bodies and constructEventAsync
On AWS Lambda behind API Gateway, the body commonly arrives base64-encoded (event.isBase64Encoded: true) and must be decoded first. Cloudflare Workers and other edge runtimes have no Node crypto module, only the async Web Crypto API, so the synchronous constructEvent() throws CryptoProviderOnlySupportsAsyncError, whose message says: "Use await constructEventAsync(...) instead of constructEvent(...)."
Fix
const payload = event.isBase64Encoded
? Buffer.from(event.body, 'base64') : event.body;
// on an edge runtime:
const stripeEvent = await stripe.webhooks.constructEventAsync(body, sig, secret);
13
Checklist
- Pass the raw request body, the exact bytes Stripe sent, into
constructEvent(), before anyJSON.parseor re-serialization touches it. - Confirm the
whsec_secret matches the specific endpoint receiving this request. A Dashboard-created endpoint and a runningstripe listeneach generate a different one, and test and live modes each have their own. - Read the signature from the
stripe-signatureheader exactly, not a differently named or re-cased copy. - Leave tolerance at the 300-second default, and keep the server clock synced with NTP.
- Re-check after every deploy. A new body parser, proxy, or changed environment variable can silently break raw-body access again.
14
Check it in 30 seconds
The Stripe Webhook Signature Doctor runs these twelve checks against your framework and your pasted code, entirely in your browser.
15
Sources
- Stripe docs: Receive Stripe events in your webhook endpoint, verifying signatures
- Stripe docs: Check webhook signatures, troubleshooting
- Stripe docs: Stripe CLI overview
- Next.js docs: Route Handlers (route.ts)
- Express docs: body-parser middleware
- stripe-node source: Webhooks.ts (default tolerance, constructEventAsync)
- stripe-node README: raw body requirement