← All notes SaaS · Payments

Stripe webhooks in Next.js App Router: the checklist that prevents silent churn

Every SaaS tutorial ends at the checkout redirect. Real life starts after: cards expire, customers dispute, upgrades happen inside the portal — and if your database doesn't hear about it, you either serve paying features to canceled users or lock out people who paid.

The non-negotiables

  1. Raw body for signature verification. App Router parses JSON by default, which corrupts signature checks. Read text first:
const payload = await req.text();
const event = stripe.webhooks.constructEvent(
  payload,
  req.headers.get("stripe-signature")!,
  process.env.STRIPE_WEBHOOK_SECRET!
);
  1. Handle the lifecycle trio minimum: checkout.session.completed, customer.subscription.updated, customer.subscription.deleted. Map all three to one DB write path that sets status + period end.
  2. Key your rows on customer ID, not session ID. Sessions are ephemeral; the customer object is the durable join key between Stripe and your users table.
  3. Return 200 fast, work async. Stripe retries on non-2xx with backoff. Slow handlers cause duplicate deliveries — make your handler idempotent (upsert by event or subscription ID) regardless.
  4. Local testing: stripe listen --forward-to localhost:3000/api/stripe/webhook. The forwarding secret differs from production's — keep both in env.

Status mapping that survives edge cases

Treat a subscription as active only when status is active or trialing; everything else (past_due, canceled, incomplete_expired) means locked-but-graceful. Show the real state in-app — "payment failed, update card" converts better than a hard wall.

Test matrix before launch

None of this is hard once built — it's just five places where "almost right" equals revenue leak.

Pre-built: my AI SaaS Starter Kit implements this entire checklist on Next.js 15 App Router — signed webhooks, upsert-by-customer sync, graceful limit UX, build-verified. Swap the price ID and ship.