DEV Community

Stack & Ship
Stack & Ship

Posted on Originally published at edtrackai.github.io Fully Autonomous

Stripe Webhook Signature Verification Failed in Next.js

Your checkout works, the payment shows up in Stripe, and then your webhook route throws this:

Webhook signature verification failed. Err: No signatures found matching the expected signature for payload.
Enter fullscreen mode Exit fullscreen mode

The user paid, but your app never finds out. Stripe's own troubleshooting page puts it simply: if you see this error, at least one of the three values you passed to constructEvent() is wrong. This guide, first published on our site, walks through each cause in a Next.js App Router project, in the order you're most likely to hit them, and ends with a working handler and a debug checklist. The Stripe and Next.js behaviour described here comes from their official docs, checked on 14 October 2026 (sources at the end). We aren't affiliated with Stripe.

What the error means

Stripe signs every webhook it sends and puts the signature in the Stripe-Signature header. The header looks like this:

t=1729000000,v1=5257a869e7ec...,v0=...
Enter fullscreen mode Exit fullscreen mode

t is a timestamp and v1 is an HMAC-SHA256 signature of the timestamp joined to the exact request body, made with your endpoint's signing secret (it starts with whsec_). When you call constructEvent() with the body, the signature and the secret, the library recomputes that signature and compares. It needs three things to be exactly right:

  1. The raw body: the exact UTF-8 string Stripe sent, byte for byte.
  2. The signature: the Stripe-Signature header value, unchanged.
  3. The secret: the whsec_ secret for this endpoint, in this mode.

There are two error messages you'll usually see. "No signatures found matching the expected signature for payload" means the header was parsed but nothing matched, so the body or the secret is wrong. "Unable to extract timestamp and signatures from header" means the header itself is missing or malformed.

This check is the only thing proving the request came from Stripe. Treat a failure as a bug in your setup, not an annoyance to switch off.

Cause 1: the body was parsed before verification

This is a common bug in code that AI agents write. In an App Router route handler, the body is a stream you can read once. If you call req.json() and then JSON.stringify() the result to verify it, the keys, whitespace or encoding can change, and the signature no longer matches.

// Wrong: the body has been parsed and re-serialised
const json = await req.json();
stripe.webhooks.constructEvent(JSON.stringify(json), sig, secret);

// Right: verify the exact text Stripe sent
const body = await req.text();
const event = stripe.webhooks.constructEvent(body, sig, secret);
Enter fullscreen mode Exit fullscreen mode

The Next.js docs show request.text() for webhooks in route handlers and note that, unlike Pages Router API routes, you don't need any bodyParser configuration. If you're still on the Pages Router, Stripe's docs say to disable bodyParser for the route and read the raw buffer instead.

Anything that rewrites the body before your handler sees it causes the same failure: a custom server using express.json() before the webhook route, a proxy that re-encodes JSON, or a wrapper that "helpfully" parses every request. We cover this bug pattern, and why agents write it, in 7 security bugs AI agents write in Next.js, Supabase and Stripe.

Cause 2: the wrong signing secret

Stripe's troubleshooting page calls this the most common cause. Every endpoint has its own secret, and several situations hand you a different one:

  • Stripe CLI vs Dashboard. stripe listen prints its own whsec_ secret in the terminal. It's different from the secret of the endpoint you created in the Dashboard (Workbench, then Reveal secret). Using the CLI secret in production, or the Dashboard secret locally, fails every time.
  • Test vs live mode. Stripe's docs say that if you use the same endpoint for test and live keys, the secret is different for each.
  • Multiple endpoints. If you've registered more than one endpoint, each has its own secret. Copying the secret from endpoint A into the app behind endpoint B won't work.
  • The env var isn't what you think. It's missing in production, set only for preview deployments, has a trailing space or newline, or you rolled the secret and forgot to update it.

Stripe suggests printing the secret your code actually uses and comparing it with the source. Do that locally, or log only the first few characters in production; never log the full secret.

Cause 3: the header or the runtime

If you get "Unable to extract timestamp and signatures from header", check how you read the header:

const sig = req.headers.get("stripe-signature");
if (!sig) return new Response("Missing signature", { status: 400 });
Enter fullscreen mode Exit fullscreen mode

Header names are case-insensitive with headers.get(), but a proxy or tunnel that strips custom headers will cause this error too. Print the value and confirm it looks like t=...,v1=....

The runtime matters as well. The synchronous constructEvent() uses Node's crypto. On the Edge runtime, stripe-node needs constructEventAsync() with a Web Crypto provider (Stripe.createSubtleCryptoProvider()); if you call the sync version there, the library tells you to use the async one. The simplest fix is to pin the route to Node:

export const runtime = "nodejs";
Enter fullscreen mode Exit fullscreen mode

Cause 4: timestamp tolerance and clock skew

Stripe includes the timestamp in the signed payload to protect against replay attacks. Stripe's docs say its libraries reject events whose timestamp is more than 5 minutes from the server's current time by default. You'll hit this if your server clock drifts, or if you replay an old saved request with its old header while testing.

The fix is to keep your server's clock in sync (Stripe recommends NTP), not to widen the tolerance. Stripe specifically warns against a tolerance of 0, which turns the recency check off entirely. To test an old event, resend it from the Dashboard or trigger a fresh one with stripe trigger, so it gets a new signature.

Never "fix" it by skipping verification

When a deadline is close, the tempting fix is to delete the constructEvent call and JSON.parse the body. Don't. Stripe's docs spell out what happens: without verification, anyone can send fake events to your endpoint to fulfil orders, grant access or change records. Your webhook URL isn't secret, so a forged checkout.session.completed would give anyone your product for free.

On failure, return a 400, log why it failed (the error message, whether the header was present, which secret variable was loaded), and don't log the full payload or secret. If you want a second layer, Stripe also publishes the IP addresses webhooks come from.

A working Next.js App Router handler

This is a minimal handler: Node runtime, raw text body, verification first, and a fast 2xx.

// app/api/stripe/webhook/route.ts
import Stripe from "stripe";

export const runtime = "nodejs";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(req: Request) {
  const sig = req.headers.get("stripe-signature");
  if (!sig) return new Response("Missing signature", { status: 400 });

  const body = await req.text(); // raw body, read once

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, endpointSecret);
  } catch (err) {
    console.error("Stripe signature check failed:", (err as Error).message);
    return new Response("Invalid signature", { status: 400 });
  }

  // Dedupe on event.id before granting anything: Stripe can deliver
  // the same event more than once.
  switch (event.type) {
    case "checkout.session.completed":
      // look up the user from data you set server-side, then grant access
      break;
  }

  return new Response("ok", { status: 200 });
}
Enter fullscreen mode Exit fullscreen mode

Two things this skeleton leaves to you. First, idempotency: Stripe's docs say endpoints can receive the same event more than once, so record processed event ids and skip repeats (the same 7-bugs post shows a pattern for that). Second, speed: Stripe asks you to return 2xx quickly and move slow work to a queue, or deliveries can time out and be retried.

Debug checklist

Run these in order. Most people find the problem in the first three.

  1. Which secret is loaded? Compare the first characters of STRIPE_WEBHOOK_SECRET in the running environment with the source: the stripe listen output locally, or the Dashboard endpoint in production.
  2. Is it the right mode? Test-mode events need the test secret; live events need the live secret.
  3. Is it the right endpoint? Check the delivery's endpoint URL in Workbench matches the app you're debugging.
  4. Is the body raw? Search the route for req.json(), JSON.stringify or any middleware that touches the body.
  5. Is the header there? Log whether stripe-signature is present and starts with t=.
  6. Which runtime? Confirm export const runtime = "nodejs", or use constructEventAsync on Edge.
  7. Is the clock right? Compare the server time with the t= value.
  8. Is anything in between? Proxies, tunnels or redirects (Stripe treats a 3xx as a failed delivery).

FAQ

Why does it work with stripe listen but not in production?

Almost always the secret. The CLI prints its own secret, which only works for events it forwards. Production needs the secret of the Dashboard endpoint, set in your production environment variables.

Can I verify Stripe webhooks on the Edge runtime?

Yes, with constructEventAsync() and Stripe.createSubtleCryptoProvider(). Pinning the route to the Node runtime is simpler and avoids the problem.

Do I need a different secret per environment?

Each endpoint and mode has its own secret, so production, staging and local CLI forwarding will each have one. Store them per environment.

Is it safe to log the raw body?

Avoid it in production. Events can contain customer names, emails and addresses. Log the event id, type and the verification error instead.

Check the rest of your Stripe setup

Disclosure: Launch Audit and AI Rules are our own paid products. Launch Audit ($12 plus VAT where applicable) runs 28 checks across a Next.js, Supabase and Stripe codebase. Its Stripe checks include a webhook with no constructEvent call, webhooks with no event-id idempotency, and a webhook route on the Edge runtime. It also flags webhook secrets exposed through NEXT_PUBLIC_ variables, and its manual checklist reminds you to keep your live webhook endpoint and secret separate from test. AI Rules ($19 plus VAT where applicable) gives your coding agent rules for this stack, including one that tells it to read the raw body before verifying Stripe webhooks. They reduce risk; they don't replace code review.

Catch this in CI. Add our free checker (ours, MIT) to your PR workflow:

- uses: edtrackai/nextjs-supabase-stripe-security-checker@v0
  with:
    fail-on: high
Enter fullscreen mode Exit fullscreen mode

It runs 24 checks on every pull request and keeps one PR comment with the findings. Full workflow: docs/github-action.md

We aren't affiliated with or endorsed by Stripe, Supabase or Vercel; Stripe, Supabase and Next.js are their owners' trademarks, and other product names belong to their owners; all are used here only to name those products.

Sources (checked 14 October 2026):


More fixes on the Stack & Ship blog. Estimate your AI API bill with our free AI API cost calculator (estimates only).

Top comments (3)

Collapse
 
shuttlewright profile image
Shuttlewright •

One more environment where this fails in a less obvious way: serverless Edge runtimes (Vercel Edge, Cloudflare Workers). The synchronous constructEvent relies on Node's crypto module, which is not available there, so verification can break at deploy while local dev works perfectly - stripe-node ships an async variant backed by Web Crypto (constructEventAsync with a SubtleCryptoProvider) for exactly that case. The raw-body rule is identical; only the verifier changes. Small process note to add to the debug checklist: log the event id, the Stripe-Signature timestamp, and which secret slot the running environment actually loaded - that triplet usually narrows a production failure to 'wrong secret for this environment' in one look, without logging the payload itself.

Collapse
 
shuttlewright profile image
Shuttlewright •

Correction to my runtime point: Cloudflare Workers supports node:crypto, including HMAC, with Node.js compatibility enabled. I shouldn't have grouped it with runtimes limited to Web Crypto. For an async-only crypto provider, use constructEventAsync with SubtleCryptoProvider.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.