Skip to content
Zyloft

Handling Stripe webhooks in Next.js the right way

When a webhook handler fails, customers pay without getting access. How to handle Stripe webhooks safely in the Next.js App Router: signatures, idempotency, ordering and local testing.

Updated 10 November 20264 min read

Webhooks are how your app learns that a payment went through, a subscription renewed or a customer opened a dispute. If the handler isn't reliable, access, invoices and emails drift away from what actually happened in Stripe. The good news: five rules make a webhook handler robust.

How Stripe delivers webhooks

  • Stripe sends each event as an HTTPS POST with a JSON event to your endpoint.
  • An event only counts as delivered when your endpoint answers with a 2xx status code.
  • If delivery fails, Stripe retries in live mode with exponential backoff for up to three days.
  • Event order is not guaranteed, and the same event can arrive more than once.

The route handler in the App Router

In the App Router, the webhook is a route handler, for example at app/api/stripe/webhook/route.ts. What matters is reading the raw request body with request.text(): if the body is parsed as JSON and serialised again, the bytes change and the signature check fails.

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

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

export async function POST(request: Request) {
  // The raw body, byte for byte: parsing it first breaks the signature check.
  const body = await request.text();
  const signature = request.headers.get("stripe-signature");
  if (!signature) return new Response("Missing signature", { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }

  await handleEvent(event); // idempotent, see below
  return new Response(null, { status: 200 });
}

constructEvent uses Node.js crypto. If your handler runs in the Edge runtime, use constructEventAsync instead.

Rule 1: Always verify the signature

Your webhook endpoint is public. Only the signature proves a request really comes from Stripe. Stripe sends it in the Stripe-Signature header together with a timestamp. The official libraries check both and, by default, reject requests older than five minutes, which protects against replay attacks. Never set that tolerance to 0: it switches the check off.

Each endpoint has its own secret starting with whsec_. The Stripe CLI uses a different secret for local testing than your dashboard endpoint does. Keep them in separate environment variables.

Rule 2: Catch duplicates

Because an event can arrive more than once, every handler must be idempotent: the same event twice must not grant access twice, create two invoices or send two emails. The simplest approach is to store the IDs of processed events under a unique key, in the same transaction as the actual change.

sql
create table processed_events (
  id text primary key,                         -- evt_…
  processed_at timestamptz not null default now()
);

-- In the handler, inside one transaction with the actual work:
insert into processed_events (id) values ($1)
on conflict (id) do nothing;
-- 0 rows inserted → already handled: answer 200 and stop.

In rare cases Stripe creates two separate events with different IDs for the same action. Where that matters, also detect duplicates by the ID of the object in data.object together with the event type.

Rule 3: Don't rely on order

Creating a subscription produces several events that can arrive in any order. Build your handler so it doesn't infer the current state from the order: when in doubt, fetch the object's current state from the Stripe API before updating your record.

Rule 4: Respond fast, work later

Answer with 2xx before slow work starts. If your response takes too long, Stripe treats the delivery as failed and tries again, even though your code may already have done its work. Heavy work such as emails, PDFs or calls to other services belongs in a queue or background job.

Rule 5: Subscribe only to the events you need

In the dashboard, subscribe only to the event types your app actually handles, for example checkout.session.completed, customer.subscription.updated and invoice.payment_failed. Everything else adds load without benefit.

Testing locally with the Stripe CLI

The Stripe CLI forwards events to your local server and triggers test events on demand. stripe listen prints the signing secret to use locally when it starts.

bash
stripe listen --forward-to localhost:3000/api/stripe/webhook
stripe trigger checkout.session.completed

Checklist before going live

  • Raw body read with request.text() and signature verified with constructEvent
  • Separate secrets for the CLI, test and live endpoints
  • Processed event IDs stored under a unique key
  • No assumptions about event order
  • 2xx right away, slow work in the background
  • Only the event types you need subscribed
  • Failed deliveries get noticed, not only through customer complaints

Keeping an eye on failed deliveries

Stripe's dashboard shows which deliveries failed. If you receive webhooks from several providers, such as GitHub or Shopify as well, a single place that records every webhook, forwards it with a signature and can resend failed deliveries pays off.

As of November 2026, based on Stripe's webhook documentation. Details such as retry periods can change; Stripe's current documentation is authoritative.

3 min read

Junior developer skills that matter in your first job

Tutorials and puzzle sites train syntax and algorithms. Your first job asks for something else: understanding tickets, planning, testing and handling reviews. Here's how to practise exactly that.

3 min read

Invoicing as a Kleinunternehmer in Germany

Since 2025, new revenue thresholds and simplified invoicing rules apply to small businesses in Germany. What belongs on your invoice, what doesn't, and where e-invoicing stands.