Stripe webhook signature verification failed in Next.js
One error string covers two different bugs. Five causes for a failed Stripe webhook signature in Next.js, ranked by how often each is actually it, each with its one-line test.
In short
Five causes, ranked. Most of the time the raw bytes are gone: await req.json() or any re-serialization changes what gets hashed, because constructEvent hashes the timestamp, a dot, and the body exactly as received. A wrong signing secret throws the identical message. Clock skew is real, and it is last.
StripeSignatureVerificationError: No signatures found matching the expected
signature for payload. Are you passing the raw request body you received from
Stripe?
That question at the end is a guess, and it is wrong about as often as it is right. The same string is thrown when the body is perfect and the signing secret is wrong, because both produce the same outcome: a computed HMAC that does not match any signature in the header. So the message is a starting point, not a diagnosis.
Below are the five causes in the order they actually turn up, with the one-line test for each. Stop as soon as yours matches.
Before you start
A Next.js App Router route handler, and stripe installed. Everything here is
checked against [email protected], the version this repo has in node_modules,
and against our own webhook route at
server/src/routes/organizations/payment/stripe/webhooks/index.ts.
Read the exact string before you change anything
stripe.webhooks.constructEvent throws seven distinct messages, and six of
them point at exactly one cause. Only the seventh is ambiguous. Find yours here
first, because four of the five sections below become irrelevant the moment you
do.
| Message | What it means |
|---|---|
No webhook payload was provided. | The payload argument was empty, null or undefined |
Webhook payload must be provided as a string or a Buffer | You passed a parsed JavaScript object. Cause 1 |
No signatures found matching the expected signature for payload. | Bytes changed, or wrong secret. Causes 1, 2 and 3 |
No stripe-signature header value was provided. | The header is missing. Cause 4 |
Unable to extract timestamp and signatures from header | The header arrived mangled. Cause 4 |
No signatures found with expected scheme | A t= but no v1=. Cause 4 |
Timestamp outside the tolerance zone | Clock skew. Cause 5, and only cause 5 |
One more that is not in that list and is not a signature failure at all:
SubtleCryptoProvider cannot be used in a synchronous context. That is the
edge runtime. constructEvent is synchronous and needs Node's crypto, so on
edge you either add export const runtime = 'nodejs' or switch to
constructEventAsync. Nothing about your secret or your body is wrong.
1. The body was parsed, or re-serialized, before it was verified
The test: search your route handler for req.json(). If it appears
anywhere above the constructEvent call, this is your bug.
This is most of them. Here is why it is fatal rather than merely untidy. The
signed material is built in Webhooks.js as:
function makeHMACContent(payload, details) {
return `${details.timestamp}.${payload}`;
}payload is the body as a string, character for character. Not the object it
parses into. JSON.parse followed by JSON.stringify is not an identity
function on a JSON document: key order survives, but whitespace, indentation
and line endings do not, and Stripe signed the bytes including those. Change
one space and the HMAC is different, so the comparison fails and you get the
"Are you passing the raw request body" message even though you are passing a
string.
Passing the parsed object directly is the friendlier failure. stripe-node
detects that the payload is neither a string nor a Uint8Array and swaps in a
message that says so outright:
Webhook payload must be provided as a string or a Buffer
(https://nodejs.org/api/buffer.html) instance representing the _raw_ request
body.Payload was provided as a parsed JavaScript object instead.
In the App Router the trap has a second edge. Request has a one-shot body
stream, so calling await req.json() and then await req.text() does not give
you a second look at the bytes, it throws. There is no ordering that rescues
this. You read the body once, as text, and parse it afterwards from the string
you already hold.
And if a forum answer told you to add this, delete it:
export const config = { api: { bodyParser: false } };That is a Pages Router directive. A route handler under app/ ignores it, so
it changes nothing and it will not fix anything.
2. It is the wrong signing secret
The test: print the first eight characters of the secret your handler is
reading. If it does not start whsec_, stop. If it does, check which endpoint
issued it.
Cause 2 produces the identical error string as cause 1, which is the whole reason this post is ranked rather than a single fix. Once the raw body is ruled out, this is next, and the secret is wrong in four distinct ways.
You reached for the API key. The secret key does not participate in the HMAC at all. Our own route constructs a client only to reach the method:
const stripe = new Stripe(stripeCredentials.secretKey, {});
event = stripe.webhooks.constructEvent(
req.rawBody,
stripeSignature as string,
endpointSecret
);endpointSecret is stripeCredentials.webhookSecret, a separate stored field.
Swap the two arguments and verification fails every time, and nothing in the
error text tells you that is what happened.
It is a different endpoint's secret. Every endpoint gets its own whsec_.
A stripe listen CLI session issues one that is unrelated to the one on your
dashboard endpoint, which is why a webhook that works locally can fail on the
first deploy with no code change between them.
Test against live, or live against test. Our setup checker classifies a key by prefix and warns when the pair disagrees:
if (key.startsWith(`${p}_live_`)) return 'live';
if (key.startsWith(`${p}_test_`)) return 'test';Signing secrets have no such prefix, so nothing catches a test-mode whsec_
sitting in a live deployment except the failures themselves.
There is whitespace in it. Copy a secret out of a dashboard into an env file and a trailing newline comes with it more often than you would think. stripe-node checks for this and appends a note to the error:
Note: The provided signing secret contains whitespace. This often indicates an
extra newline or space is in the value
If that sentence is in your log, you are done reading.
Key takeaway
You cannot compare a stored signing secret against the one Stripe holds.
Stripe shows a whsec_ once, at creation, and never again. Our setup page
says exactly that and falls back to comparing deliveries instead: it lists the
events Stripe created for the endpoint and reports how many were never
accepted. A count above zero with no connection errors is a wrong secret.
3. Something between Stripe and your handler rewrote the body
The test: log body.length at the top of the handler and compare it with
the payload size Stripe's dashboard shows for that delivery attempt. If they
differ, nothing about your code is the problem.
stripe-node names this case in the error itself, which is a good signal for how common it is: "If a webhook request is being forwarded by a third-party tool, ensure that the exact request body, including JSON formatting and new line style, is preserved." Tunnels, request-replay tools and anything that pretty prints JSON on the way through will all do it.
Middleware counts too. Our tenant server captures the raw body in a single
place, a verify callback on the JSON parser:
app.use(
express.json({
limit: '5mb',
verify: (req: express.Request, res, buf: Buffer) => {
req.rawBody = buf.toString();
},
})
);There is a quiet condition in there. express.json() runs verify only for
requests it recognises as JSON, so a proxy that strips or rewrites
Content-Type leaves req.rawBody undefined and the failure arrives as
No webhook payload was provided. rather than a signature mismatch. That is
why the route logs the header state and the byte count before it does anything
else:
[Webhook] Headers: stripe-signature=present, content-type=application/json,
rawBody=2143 bytes
rawBody=MISSING in that line ends the investigation on the spot.
In Next.js, middleware is a less likely culprit than it looks. Next buffers the
request body and replays the same bytes to the route handler, so a
middleware.ts that reads the body does not change what gets hashed. What
middleware can do is answer first. An auth guard whose matcher catches /api/*
returns a 307 to /login, Stripe does not follow redirects, and the delivery
fails without your handler or constructEvent ever running. If the dashboard
shows a 307 on the attempt rather than a 400, exclude the webhook path from
the matcher.
4. The signature header never arrived intact
The test: log the header itself. It should look like
t=1726790400,v1=<hex>, and a valid one always has both parts.
Three of the seven messages live here and each one tells you precisely how far
the header got. Missing entirely gives you
No stripe-signature header value was provided. Present but unparseable gives
Unable to extract timestamp and signatures from header. A timestamp with no
v1= gives No signatures found with expected scheme.
There is also a failure mode the App Router removes for you. In Express,
req.headers['stripe-signature'] is typed string | string[], and an array
makes stripe-node throw a plain Error rather than a
StripeSignatureVerificationError, so a catch that narrows on the Stripe
error type misses it. Our route casts with as string and accepts the risk.
req.headers.get('stripe-signature') in a route handler returns
string | null, never an array, so that class of bug cannot occur.
Worth checking one layer out: a CDN or WAF that allowlists headers will drop
stripe-signature silently, and from inside the handler that is
indistinguishable from Stripe not sending one.
5. Clock skew
The test: does the error say Timestamp outside the tolerance zone? If
not, your clock is fine and you can stop considering it.
This is last because it is the only cause with a message of its own, so it never hides behind another. The check is four lines:
// Condensed from Webhooks.js; the real line lets a caller inject `receivedAt`.
const timestampAge = Math.floor(Date.now() / 1000) - details.timestamp;
if (tolerance > 0 && timestampAge > tolerance) {
throw new StripeSignatureVerificationError(header, payload, {
message: 'Timestamp outside the tolerance zone',
});
}tolerance defaults to DEFAULT_TOLERANCE: 300, five minutes, and our webhook
route passes three arguments to constructEvent, so that default is what runs
in production. It is a one-sided window: a clock running fast produces a
negative age and sails through, a clock five minutes slow rejects everything.
Five minutes is the same window we apply to our own outgoing webhooks, where
SESSION_DEFAULTS.webhookMaxAge is 5 minutes and the docs tell integrators
to reject anything older. Two independent systems landing on the same number is
not a coincidence so much as the obvious size for a replay window.
Containers with a drifting host clock are the realistic version of this. Serverless is not, so if you are on Vercel or similar, skip it.
The route handler that works
Raw body first, header second, everything else after verification:
import Stripe from 'stripe';
// constructEvent is synchronous and needs Node crypto, not Web Crypto.
export const runtime = 'nodejs';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16',
});
export async function POST(req: Request) {
// Read once, as text. After this, req.json() is no longer available,
// which is the point.
const body = await req.text();
const signature = req.headers.get('stripe-signature');
if (!signature) {
return Response.json({ error: 'Missing signature' }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
// The message is the diagnosis. Log it, do not swallow it.
console.error('[stripe] verification failed:', (err as Error).message);
return Response.json({ error: 'Invalid signature' }, { status: 400 });
}
await enqueue(event); // your queue, your worker
return Response.json({ received: true });
}I have not executed this file. It is the App Router shape of the Express route linked above, which does run in production, with the same argument order and the same three failure branches.
Two decisions in there are worth defending. The API version is pinned because
we pin it wherever a client makes API calls:
STRIPE_API_VERSION = '2023-10-16' in stripe-client.util.ts is what the
shared client factory speaks and what new webhook endpoints get created with.
The verify-only client in our webhook route, quoted above, is the one exception
and passes {}, which is safe because verification does not depend on the
version. A client that calls the API does, and an unpinned one changes event
shape under you on Stripe's schedule rather than yours.
And the failure returns 400, not 200. A 2xx tells Stripe the event was delivered and it stops retrying, so a 200 on an unverifiable request means an endpoint with a wrong secret discards every event forever while its dashboard shows a clean green column. A 400 shows up as a failed delivery, which is where an operator will actually look. The comment in our route says as much.
Verification passing is not the same as the event landing
Worth knowing before you close the tab, because a fixed signature is where the next class of bug starts.
Our webhook route verifies, stores the event, then enqueues a BullMQ job and answers 200. The job id used to be the Stripe event id. That is wrong, and it cost us: one Stripe account can be connected to more than one organization, and every test setup that reuses a key does exactly that. Stripe delivers the same event id to both. Keyed on the event alone, whichever org enqueued first was the only one that processed it, while the second org's webhook had already answered 200 so Stripe never retried. The fix is one line:
export function webhookJobId(orgId: string, eventId: string): string {
return `webhook-${orgId}-${eventId}`;
}The payment lifecycle suite found it, and there is now a test in
stripe-webhook-path-hygiene.test.ts asserting that two orgs receiving one
event id get two different job ids.
The other silent one is a missing event type. Our
STRIPE_WEBHOOK_EVENTS constant exists because a setup page once listed a
hand-picked eight and called them essential.
checkout.session.completed was not among them. Nothing fails loudly without
it: the subscription is still recorded through
customer.subscription.created, so billing looks healthy while every
credit-pack purchase and every server-side conversion quietly never happens. We
found that in our own production org, which is why the list is now a constant
with a test that fails when it and the handler switch disagree.
If you are wiring the rest of this path, the events above are the ones behind charging per API call, proration on a plan change and a free trial without a card.
Install
npm i @buildbase/sdk