Stripe webhooks have a reputation for being straightforward. And they are — right up until a user gets charged, the webhook fails silently, and their account never gets upgraded. Then you're sifting through Stripe's dashboard at midnight wondering why your event handler returned 200 but nothing actually happened. We've been there. Multiple times.
This post is everything we know about making webhook handling actually reliable in a Next.js app. Not the happy path. The full picture — signature verification, idempotency, handling retries, which events to care about, and the subtle bugs that will bite you in production.
First: Understand What Stripe Actually Expects
Stripe will POST to your endpoint, wait up to 30 seconds for a response, and retry the event if it doesn't get a 2xx back. Simple. But here's what catches people: Stripe considers anything that isn't a 2xx a failure, including 5xx errors. If your database throws, your handler crashes, or you forget to return a response — Stripe retries. It'll try up to 3 days with exponential backoff. So if your handler is broken, you'll get hammered with the same events over and over.
The other thing: always respond fast. Do your async processing after you return the 200. If you're doing slow database operations synchronously before responding, you'll start timing out under load. Stripe's 30-second limit sounds generous until you have 50 users signing up simultaneously.
Signature Verification Is Non-Negotiable
Before anything else, verify the webhook signature. This is Stripe's way of proving that the request actually came from them and not some random person who found your endpoint URL. Skip this and you're trusting any POST request that shows up at your webhook URL — which is a terrible idea.
The tricky part in Next.js: you need the raw request body as a Buffer, not the parsed JSON. Next.js App Router parses the body by default, which destroys the signature. You have to opt out of body parsing.
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-06-20',
});
export async function POST(request: Request) {
const body = await request.text(); // Get raw body as string
const headersList = await headers();
const signature = headersList.get('stripe-signature');
if (!signature) {
return new Response('No signature', { status: 400 });
}
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('Invalid signature', { status: 400 });
}
// Process the event
try {
await handleStripeEvent(event);
} catch (err) {
console.error('Webhook handler failed:', err);
// Still return 200 in some cases — more on this below
return new Response('Handler error', { status: 500 });
}
return new Response('OK', { status: 200 });
}Notice we use `request.text()` not `request.json()`. This gives us the raw string that Stripe signed. If you parse it to JSON first and then stringify it back, the whitespace might differ and the signature check will fail. We made this mistake once. The error message from Stripe is not helpful.
Idempotency: Handle Duplicate Events Gracefully
Because Stripe retries failed events, your handler will sometimes process the same event twice. Maybe your handler succeeded but your server crashed before responding. Maybe Stripe had a hiccup. Doesn't matter — you need to handle it. Charging a user twice because you processed `payment_intent.succeeded` twice is not the kind of bug you want.
The solution is storing processed event IDs. Before doing anything, check if you've already handled this event. If yes, return 200 immediately. Simple, but most tutorials skip this.
// db/schema.ts (Drizzle)
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
export const processedWebhookEvents = pgTable('processed_webhook_events', {
eventId: text('event_id').primaryKey(),
eventType: text('event_type').notNull(),
processedAt: timestamp('processed_at').defaultNow().notNull(),
});
// In your handler
async function handleStripeEvent(event: Stripe.Event) {
// Check idempotency first
const existing = await db
.select()
.from(processedWebhookEvents)
.where(eq(processedWebhookEvents.eventId, event.id))
.limit(1);
if (existing.length > 0) {
console.log(`Event ${event.id} already processed, skipping`);
return;
}
// Handle the event based on type
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
break;
case 'customer.subscription.updated':
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.deleted':
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
break;
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object as Stripe.Invoice);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
// Mark as processed
await db.insert(processedWebhookEvents).values({
eventId: event.id,
eventType: event.type,
});
}Store processed event IDs. Always. It's one table, one query, and it saves you from catastrophic double-processing bugs that are nearly impossible to debug after the fact.
Which Events Actually Matter for a SaaS
Stripe has over 200 event types. You don't need most of them. Here's what we handle in practice for a subscription-based SaaS:
- checkout.session.completed — User completed checkout. This is where you provision access. Always triggered once at the start.
- customer.subscription.created — Subscription created. Often fires alongside checkout.session.completed, so be careful not to double-provision.
- customer.subscription.updated — Plan changed, trial ended, subscription paused. Check the status field.
- customer.subscription.deleted — Subscription cancelled (immediately or at period end depending on your settings). Revoke access here.
- invoice.payment_succeeded — Successful renewal payment. Good for logging, sending receipts.
- invoice.payment_failed — Payment failed. Trigger your dunning flow, email the user.
- customer.subscription.trial_will_end — 3 days before trial ends. Send a reminder email.
One subtle thing: `checkout.session.completed` and `customer.subscription.created` both fire when a new subscription starts. We use `checkout.session.completed` as the canonical event for provisioning because it carries the metadata we attach at checkout time (like our internal user ID). `customer.subscription.created` doesn't always have that context.
Passing Context Through Checkout
The most common bug we see in webhook implementations: the webhook fires but you have no idea which user it belongs to. Stripe gives you a customer ID and a subscription ID, but not your internal user ID. You need to set that up yourself when creating the checkout session.
// When creating a checkout session
const session = await stripe.checkout.sessions.create({
customer: stripeCustomerId, // Create or retrieve on signup
mode: 'subscription',
line_items: [
{
price: priceId,
quantity: 1,
},
],
metadata: {
userId: user.id, // Your internal user ID
plan: 'pro',
},
subscription_data: {
metadata: {
userId: user.id, // Also on the subscription object itself
},
},
success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?upgraded=true`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
});
// In your webhook handler
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
const userId = session.metadata?.userId;
if (!userId) {
// This is bad. Log it, alert yourself.
console.error('No userId in checkout session metadata', session.id);
throw new Error('Missing userId metadata');
}
await db
.update(users)
.set({
stripeSubscriptionId: session.subscription as string,
plan: 'pro',
planExpiresAt: null, // Active subscription, no expiry
})
.where(eq(users.id, userId));
}Set the metadata on both the checkout session and the `subscription_data`. The session metadata is accessible in `checkout.session.completed`. The subscription metadata is accessible in all subsequent subscription events. You need both.
When to Return 500 vs 200
This is actually a nuanced decision. If you return 500, Stripe retries. Sometimes that's what you want — maybe your database was momentarily unavailable and the event should be retried. But sometimes returning 500 causes more harm than good.
Our rule of thumb: return 500 for transient errors (database connection issues, network timeouts) where retrying will likely succeed. Return 200 for permanent errors (like missing metadata we should have set) where retrying will just fail again. For permanent errors, log them loudly and set up an alert — but don't let Stripe hammer your endpoint for 3 days over something that will never succeed.
// Distinguishing transient vs permanent errors
export class WebhookPermanentError extends Error {
constructor(message: string) {
super(message);
this.name = 'WebhookPermanentError';
}
}
export async function POST(request: Request) {
// ... signature verification ...
try {
await handleStripeEvent(event);
return new Response('OK', { status: 200 });
} catch (err) {
if (err instanceof WebhookPermanentError) {
// Log loudly, alert, but return 200 so Stripe stops retrying
console.error('Permanent webhook error, manual intervention needed:', err.message, {
eventId: event.id,
eventType: event.type,
});
// Optionally: send yourself a Slack message or email here
return new Response('Acknowledged', { status: 200 });
}
// Transient error — let Stripe retry
console.error('Transient webhook error:', err);
return new Response('Retry', { status: 500 });
}
}Testing Webhooks Locally
The Stripe CLI is genuinely excellent. Install it, run `stripe login`, and then:
# Forward events to your local Next.js server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# In another terminal, trigger specific events
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed
# Or replay a specific event from your dashboard
stripe events resend evt_1234567890The CLI gives you a webhook secret for local development — it's different from your production one. Make sure you're using `STRIPE_WEBHOOK_SECRET` from the CLI output when running locally. We keep two environment variables: `STRIPE_WEBHOOK_SECRET` for local (from the CLI) and use the same var name in production with the actual secret from the Stripe dashboard. Nothing special, just don't mix them up.
One more thing: Stripe's dashboard has a webhook event log under Developers → Webhooks. Every event, every response code, every retry. When something breaks in production, that's your first stop. You can also replay events from there, which is useful when you've fixed a bug and need to reprocess events that failed.
Set up the Stripe CLI on day one. Don't wait until you need to debug a production issue to figure out how to test webhooks locally.
The Subscription Status Trap
A common mistake: only updating your database when subscriptions are created or deleted, and ignoring `customer.subscription.updated`. Subscription status can change in all sorts of ways — trial ends, payment fails, subscription pauses, plan changes, discount applied. If you only handle creation and deletion, your database will drift out of sync with Stripe's actual state.
The simplest solution: when you receive any subscription event, fetch the full subscription object from Stripe and sync its status to your database. Don't try to incrementally update based on what changed. Just overwrite with the current truth.
async function syncSubscription(subscriptionId: string) {
// Fetch fresh data from Stripe — don't trust the event data alone
const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
expand: ['default_payment_method'],
});
const userId = subscription.metadata.userId;
if (!userId) throw new WebhookPermanentError(`No userId on subscription ${subscriptionId}`);
const isActive = ['active', 'trialing'].includes(subscription.status);
await db
.update(users)
.set({
stripeSubscriptionId: subscription.id,
stripeSubscriptionStatus: subscription.status,
plan: isActive ? getPlanFromPriceId(subscription.items.data[0].price.id) : 'free',
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
})
.where(eq(users.id, userId));
}
// Then in your event handlers:
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
await syncSubscription(subscription.id);
}
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
await syncSubscription(subscription.id); // Status will be 'canceled'
}Fetching fresh data from Stripe on every event might feel wasteful, but it means your database is always accurate. The alternative is subtle sync bugs that are a nightmare to diagnose six months later.
If you're using a Next.js SaaS template from peal.dev, most of this is already wired up with Drizzle and the right event handlers included — so you're not starting from scratch every time. But understanding what's happening underneath is still worth it, because you'll inevitably need to customize something.
A Few Things We Still Get Wrong Sometimes
- Forgetting to add the new webhook event type in the Stripe dashboard when we start handling a new event. The event fires, Stripe logs it as undelivered because the endpoint isn't subscribed to it, and we spend 20 minutes confused.
- Using the test mode webhook secret in production or vice versa. Always double-check which Stripe mode you're in.
- Not handling the case where a customer upgrades and then immediately downgrades within the same billing period. subscription.updated fires twice in quick succession and the order isn't guaranteed.
- Logging too much in webhook handlers. We once logged the full event object including customer data, which filled up our log storage in Loki faster than expected. Log event IDs and types, not full payloads.
- Assuming checkout.session.completed always has a subscription ID. If you also sell one-time products through Stripe, the session might not have a subscription. Check before accessing it.
Stripe webhooks aren't rocket science, but they require you to think through failure cases that most tutorials ignore. Verify signatures, handle duplicates, pass context through metadata, sync fresh data instead of patching incrementally, and distinguish retryable from permanent errors. Get those five things right and you'll have a webhook handler that actually works in production — not just in your localhost demo.
