How to Handle Stripe Webhooks in a Next.js SaaS App
Step-by-step guide to wiring Stripe webhooks reliably in Next.js—covering signature verification, idempotency, subscription state sync, and local testing.
Contents
Stripe webhooks are the backbone of subscription billing in any SaaS. They tell your app when a payment succeeds, when a subscription changes, when a card expires. If your webhook handler is wrong, your billing state drifts silently and customers get access they shouldn't have—or lose access they paid for.
This guide covers the full implementation: signature verification, idempotent processing, subscription state mapping, and testing locally before you ship.
Why Webhooks Break in Production
Most implementations get the happy path right. They fail on:
- Duplicate delivery: Stripe retries on non-2xx responses. Your handler runs twice and creates double records.
- Unverified signatures: You process any POST, not just those from Stripe.
- Synchronous blocking: You fetch the full object inside the handler and it times out.
- Missing event types: You handle
checkout.session.completedbut missinvoice.payment_failed.
Architecture Before You Code
Webhook handler contract
Your webhook endpoint must return a 2xx within 30 seconds. Do minimal work inline—write to a queue or DB, then process asynchronously.
The correct pattern:
- Verify Stripe signature.
- Write the raw event to a
stripe_eventstable (with event ID for idempotency). - Return
200immediately. - Process event state changes in a background job or queue.
This decouples ingestion from processing and makes retries safe.
Implementation
1. Create the Route Handler
src/app/api/webhooks/stripe/route.tsimport { headers } from "next/headers";
import Stripe from "stripe";
import { db } from "@/lib/db";
import { stripeEvents } from "@/db/schema";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-06-20",
});
export async function POST(req: Request) {
const body = await req.text();
const signature = (await headers()).get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return new Response("Webhook signature verification failed", { status: 400 });
}
// Idempotency check
const existing = await db.query.stripeEvents.findFirst({
where: (e, { eq }) => eq(e.stripeEventId, event.id),
});
if (existing) {
return new Response("Already processed", { status: 200 });
}
// Write event to DB for async processing
await db.insert(stripeEvents).values({
stripeEventId: event.id,
type: event.type,
payload: JSON.stringify(event),
processedAt: null,
});
return new Response("OK", { status: 200 });
}Raw body required
Stripe signature verification requires the raw request body as a string. Do not parse JSON before calling constructEvent. Use req.text(), not req.json().
2. Schema for the Events Table
// drizzle/schema.ts
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
export const stripeEvents = pgTable("stripe_events", {
id: text("id").primaryKey().default("gen_random_uuid()"),
stripeEventId: text("stripe_event_id").notNull().unique(),
type: text("type").notNull(),
payload: text("payload").notNull(),
processedAt: timestamp("processed_at"),
createdAt: timestamp("created_at").defaultNow(),
});3. Event Processor
Handle event types in a separate processor that runs after the event is stored:
// src/lib/stripe-processor.ts
import Stripe from "stripe";
import { db } from "@/lib/db";
import { users, subscriptions } from "@/db/schema";
import { eq } from "drizzle-orm";
export async function processStripeEvent(eventPayload: string) {
const event: Stripe.Event = JSON.parse(eventPayload);
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
await handleCheckoutCompleted(session);
break;
}
case "customer.subscription.updated":
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
await syncSubscription(sub);
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
await handlePaymentFailed(invoice);
break;
}
default:
// Log unhandled types; don't error
console.log(`Unhandled Stripe event: ${event.type}`);
}
}
async function syncSubscription(sub: Stripe.Subscription) {
await db
.update(subscriptions)
.set({
status: sub.status,
currentPeriodEnd: new Date(sub.current_period_end * 1000),
planId: sub.items.data[0]?.price.id ?? null,
})
.where(eq(subscriptions.stripeSubscriptionId, sub.id));
}4. Critical Event Types to Handle
Don't miss these
Most billing state bugs come from handling only the happy path and ignoring these events.
| Event | When It Fires | What to Do |
|---|---|---|
checkout.session.completed | New subscription confirmed | Create subscription record, grant access |
customer.subscription.updated | Plan change, quantity change | Update plan in DB |
customer.subscription.deleted | Cancellation at period end | Revoke access after current_period_end |
invoice.payment_succeeded | Renewal payment | Extend period, log revenue |
invoice.payment_failed | Card declined | Mark account at risk, send warning email |
customer.subscription.trial_will_end | 3 days before trial ends | Send conversion email |
Testing Locally
Install the Stripe CLI:
brew install stripe/stripe-cli/stripe
stripe loginForward webhooks to your local dev server:
stripe listen --forward-to localhost:3000/api/webhooks/stripeTrigger a specific event:
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failedWebhook secret for local testing
The Stripe CLI outputs a whsec_... secret when you run stripe listen. Set this as STRIPE_WEBHOOK_SECRET in .env.local during development. Your production secret is different—set it separately in your deployment environment.
Tradeoffs
Sync vs. async processing
Inline processing (handle the event inside the route handler) is simpler but risks timeout failures on slow database queries or downstream calls. The two-phase approach (store then process) is more resilient but requires a background worker or cron job to drain the queue.
For low-volume SaaS under ~100 events/day, inline is acceptable if your DB queries are fast. Above that, use a background job.
Idempotency cost
The idempotency check adds a DB read on every webhook call. This is cheap but measurable. Alternative: use Postgres ON CONFLICT DO NOTHING on the insert and handle the constraint error as "already processed."
Verification Checklist
Before going to production:
- Signature verification tested with an invalid secret (expect 400).
- Duplicate event delivery tested (same event ID sent twice, second is a no-op).
invoice.payment_failedtriggers correct account state.customer.subscription.deletedrevokes access at the right time.- Webhook secret is in production environment variables (not committed to git).
- Production webhook endpoint registered in Stripe Dashboard with the correct events checked.
Next Steps
- Auth + Billing Launch Checklist — full pre-launch gate for auth and billing systems.
- ShipAI Production Playbook — broader production hardening for your SaaS.
- Set up usage-based billing limits in ShipAI's admin panel to gate features by plan.