← Blog

Stripe webhook signature verification in a Next.js route handler, done right

Verifying Stripe webhooks in a Next.js route: read the raw body, use constructEventAsync, respect the replay window, and reply so Stripe can retry.

Your webhook endpoint is a public URL that, when it receives the right JSON, upgrades a user to your top plan. If it does not verify the signature on every request, anyone who finds the URL can POST a hand-written customer.subscription.updated event and grant themselves the plan for free. Signature verification is not hardening; it is the access control on your billing system. It is also easy to get subtly wrong in the App Router, so here is the version the kit runs, line by line.

Verify against the raw body

Stripe signs the exact bytes it sent. The single most common bug is parsing the body as JSON first: req.json() followed by re-serializing produces different bytes — whitespace, key order, number formatting — and the signature no longer matches. Read the body as text and hand that string to the SDK untouched:

export async function POST(req: Request) {
  const secret = process.env.STRIPE_WEBHOOK_SECRET;
  if (!hasStripe() || !secret) {
    return Response.json({ error: "Billing is not configured." }, { status: 503 });
  }

  const body = await req.text(); // raw bytes as Stripe signed them
  const signature = req.headers.get("stripe-signature");
  if (!signature) {
    return Response.json({ error: "Missing signature." }, { status: 400 });
  }

  let event: Stripe.Event;
  try {
    event = await stripe().webhooks.constructEventAsync(body, signature, secret);
  } catch (err) {
    console.error("Stripe webhook signature verification failed:", err);
    return Response.json({ error: "Invalid signature." }, { status: 400 });
  }
  // ...only now is the event trusted
}

The App Router helps here: a route handler gets the standard Web Request, so there is no body-parser config to turn off — you only have to resist calling req.json().

Why the async version

The SDK has two variants, constructEvent and constructEventAsync. In the installed version (22.x), the synchronous one throws when only an async crypto provider is available — the Web Crypto API that edge runtimes use — and the error message tells you to switch. Using the async variant from the start means the same handler works on the Node.js runtime and anywhere else you might move it.

The signature also carries a timestamp, and the SDK rejects events older than its default tolerance of 300 seconds. That is the replay protection: a captured, validly signed request cannot be resent hours later. Do not raise the tolerance to paper over clock problems; fix the clock.

Reply so Stripe's retries work for you

Stripe treats a non-2xx response as a failed delivery and retries it. That makes your status codes part of the protocol:

  • 400 for a bad or missing signature. It is not going to become valid; do not ask for a retry of a forged request.
  • 500 when your handler fails — database down, upsert threw. The kit catches handler errors and returns 500 deliberately, so Stripe redelivers instead of the event being silently dropped.
  • 2xx only after you have persisted the change. Acknowledge last, not first.

Make handling safe to repeat

Retries mean the same event can arrive more than once, so the handler must be idempotent. The kit writes each subscription with an upsert keyed on the Stripe subscription id, so processing an event twice converges on the same row instead of creating a duplicate. On checkout.session.completed it does not trust the event payload's snapshot either — it re-fetches the subscription from Stripe and persists the current state, then maps the user via the id it attached at checkout. The renewal-date field on that subscription has its own trap, covered in the Stripe 402 metered-billing post.

Things that bite

  • Events are not delivered in order. An updated event can arrive after a newer one. The kit upserts the snapshot each subscription event carries, which is fine for a single status change but last-writer-wins under reordering — if that matters for your plans, re-fetch the subscription (as checkout completion already does) or compare the event's created time before writing.
  • Test mode and live mode have different signing secrets. A deploy with the test secret rejects every live event as forged. Check the secret matches the mode when verification suddenly fails everywhere.
  • Local testing needs the CLI's secret. stripe listen --forward-to localhost:3000/api/billing/webhook prints its own signing secret; use that one locally, not the dashboard endpoint's.
  • Return fast. Do the database write and respond. Long work in the handler risks Stripe timing out the delivery and retrying an event you already processed.

The subscription row this keeps in sync is what resolves each request's plan — and therefore its quota and model access. Shipwright — the Next.js and Claude starter kit this blog documents — ships this webhook with checkout and the billing portal wired to it, so plan state has exactly one writer. See plan-gated requests in the live demo.

Shipwright is a Next.js 16 + Claude starter kit that ships these patterns already done.

Try the live demo →