Guides
Build guide

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.

Dharmendra Jagodana10 min read

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.

MessageWhat 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 BufferYou 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 headerThe header arrived mangled. Cause 4
No signatures found with expected schemeA t= but no v1=. Cause 4
Timestamp outside the tolerance zoneClock 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
stripe
webhooks
nextjs

Frequently Asked Questions

Do I need bodyParser: false for a Stripe webhook in the Next.js App Router?

No. That config key is a Pages Router setting and a route handler under app/ ignores it entirely. In the App Router you get the raw bytes by calling await req.text() and never touching req.json().

Is the Stripe secret key used in signature verification?

No. constructEvent computes the HMAC from the endpoint signing secret alone. Our own webhook route builds a Stripe client from the secret key purely to reach stripe.webhooks.constructEvent, and the sk_ key plays no part in the hash.

Why does it still fail when I copied the signing secret straight out of Stripe?

Check for whitespace and check which endpoint it came from. stripe-node appends "Note: The provided signing secret contains whitespace" when it spots one, and every endpoint has its own whsec_ value, so a CLI listener secret will never verify a dashboard endpoint delivery.

How far can a server clock drift before Stripe webhooks fail?

stripe-node ships a DEFAULT_TOLERANCE of 300 seconds, so a clock more than five minutes behind Stripe throws "Timestamp outside the tolerance zone". Every other failure throws a different string, which is why skew is the last thing to check rather than the first.

Ship it

Create a project and run this guide against your own workspace.

7-day free trialNo credit card requiredCancel anytime