Stripe will deliver the same webhook twice. Your server will crash during webhook processing. A payment will succeed but your database won't know about it. All three of these will happen in production.
Here's how to handle every edge case before it costs you money or customers.
Why webhooks are harder than they look
The mental model most developers have: Stripe calls your endpoint, you update the database, done.
The reality:
- Stripe retries failed webhooks for 72 hours
- Your server can crash mid-processing (after Stripe gets a 200, before you write to the database)
- The same event can arrive out of order (subscription updated before subscription created)
- Your endpoint can be down during a critical payment event
- Stripe test mode and live mode send to the same URL if you're not careful
None of this is rare. All of it will happen if you run a production SaaS.
The complete webhook handler
// app/api/stripe/webhook/route.ts (Next.js App Router)
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { prisma } from '@/lib/db';
import { handleSubscriptionChange, handlePaymentSuccess } from '@/lib/billing';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(request: NextRequest) {
const body = await request.text(); // Must be raw text for signature verification
const signature = request.headers.get('stripe-signature');
if (!signature) {
return NextResponse.json({ error: 'No signature' }, { status: 400 });
}
let event: Stripe.Event;
// Step 1: Verify the webhook signature
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
console.error('Webhook signature verification failed:', err);
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
// Step 2: Idempotency check — have we already processed this event?
const existingEvent = await prisma.stripeEvent.findUnique({
where: { stripeEventId: event.id },
});
if (existingEvent) {
// Already processed — return 200 so Stripe stops retrying
console.log(`Duplicate event ignored: ${event.id}`);
return NextResponse.json({ received: true, duplicate: true });
}
// Step 3: Record the event BEFORE processing (prevents double-processing on crash)
await prisma.stripeEvent.create({
data: {
stripeEventId: event.id,
type: event.type,
status: 'processing',
rawPayload: JSON.stringify(event),
},
});
try {
// Step 4: Route to appropriate handler
await handleEvent(event);
// Step 5: Mark as successfully processed
await prisma.stripeEvent.update({
where: { stripeEventId: event.id },
data: { status: 'processed', processedAt: new Date() },
});
return NextResponse.json({ received: true });
} catch (error) {
// Step 6: Mark as failed — Stripe will retry
await prisma.stripeEvent.update({
where: { stripeEventId: event.id },
data: {
status: 'failed',
error: error instanceof Error ? error.message : String(error),
},
});
console.error(`Webhook processing failed for ${event.id}:`, error);
// Return 500 so Stripe retries
return NextResponse.json({ error: 'Processing failed' }, { status: 500 });
}
}
async function handleEvent(event: Stripe.Event) {
switch (event.type) {
case 'customer.subscription.created':
await handleSubscriptionCreated(event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.updated':
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.deleted':
await handleSubscriptionCanceled(event.data.object as Stripe.Subscription);
break;
case 'invoice.payment_succeeded':
await handlePaymentSucceeded(event.data.object as Stripe.Invoice);
break;
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object as Stripe.Invoice);
break;
case 'customer.subscription.trial_will_end':
await handleTrialEnding(event.data.object as Stripe.Subscription);
break;
case 'checkout.session.completed':
await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
}
The idempotency table
The stripeEvent table is the most important part. Here's the Prisma schema:
model StripeEvent {
id String @id @default(cuid())
stripeEventId String @unique // The critical unique constraint
type String
status String // 'processing' | 'processed' | 'failed'
rawPayload String // Keep the full event for debugging
error String?
processedAt DateTime?
createdAt DateTime @default(now())
@@index([type])
@@index([status])
@@index([createdAt])
}
The stripeEventId @unique constraint is the guard. If two requests for the same event hit your server simultaneously (Stripe retrying a slow response), only one will succeed the create call — the other will get a unique constraint violation and you handle it gracefully.
The subscription handlers
async function handleSubscriptionCreated(subscription: Stripe.Subscription) {
const customerId = subscription.customer as string;
const user = await prisma.user.findFirst({
where: { stripeCustomerId: customerId },
});
if (!user) {
// Customer exists in Stripe but not in your DB —
// could be a test event or a race condition with signup
console.error(`No user found for Stripe customer: ${customerId}`);
throw new Error(`User not found: ${customerId}`); // Trigger retry
}
await prisma.subscription.upsert({
where: { userId: user.id },
create: {
userId: user.id,
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
status: subscription.status,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
update: {
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
status: subscription.status,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
// Send welcome email
await sendWelcomeEmail(user.email, subscription);
}
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
// This fires on EVERY subscription change — plan changes, renewals, cancellation scheduling
// Use upsert because the subscription might not be in your DB yet (race condition)
await prisma.subscription.upsert({
where: { stripeSubscriptionId: subscription.id },
create: {
// ... (same as created handler)
},
update: {
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
status: subscription.status,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
// If plan changed, notify user
if (subscription.items.data[0].price.id !== previousPriceId) {
await sendPlanChangeEmail(userId, newPlan);
}
}
async function handleSubscriptionCanceled(subscription: Stripe.Subscription) {
await prisma.subscription.update({
where: { stripeSubscriptionId: subscription.id },
data: {
status: 'canceled',
canceledAt: new Date(),
},
});
// Downgrade user permissions immediately
// Note: Don't delete data — they might resubscribe
await sendCancellationEmail(userId);
await sendChurnSurveyEmail(userId); // Find out why
}
Payment failure handling — the one that costs you money
This is the event most developers don't handle properly:
async function handlePaymentFailed(invoice: Stripe.Invoice) {
const subscriptionId = invoice.subscription as string;
const subscription = await prisma.subscription.findFirst({
where: { stripeSubscriptionId: subscriptionId },
include: { user: true },
});
if (!subscription) return;
const attemptCount = invoice.attempt_count;
// Stripe retries: day 1, day 3, day 5, day 7 (configurable in Dashboard)
// Tailor your response to the retry attempt
if (attemptCount === 1) {
// First failure — gentle email, check card details
await sendPaymentFailedEmail(subscription.user.email, {
message: "We couldn't process your payment. Please update your card.",
urgency: 'low',
});
} else if (attemptCount === 2 || attemptCount === 3) {
// Second/third failure — more urgent, account at risk
await sendPaymentFailedEmail(subscription.user.email, {
message: "Your account will be suspended if payment isn't resolved.",
urgency: 'high',
});
} else {
// Final failure — Stripe will cancel the subscription
// The canceled event will fire separately
await sendFinalPaymentFailureEmail(subscription.user.email);
}
// Track dunning state in your database
await prisma.subscription.update({
where: { id: subscription.id },
data: {
paymentFailedAt: new Date(),
paymentAttempts: attemptCount,
},
});
}
Verifying your webhook endpoint works
Local development with Stripe CLI:
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Forward webhooks to your local server
stripe listen --forward-to localhost:3000/api/stripe/webhook
# In another terminal, trigger a test event
stripe trigger customer.subscription.created
stripe trigger invoice.payment_failed
stripe trigger checkout.session.completed
The CLI prints your webhook signing secret on startup — put this in .env.local as STRIPE_WEBHOOK_SECRET.
Don't use the same webhook secret for test and production. Stripe gives you separate secrets. Mixing them is how test events end up processing against real customer data.
The events you must handle
Minimum set for a subscription SaaS:
| Event | Why |
|---|---|
customer.subscription.created |
User just subscribed |
customer.subscription.updated |
Plan change, renewal, anything |
customer.subscription.deleted |
Subscription ended (cancelled or payment failed) |
invoice.payment_succeeded |
Payment processed — extend access period |
invoice.payment_failed |
Payment failed — start dunning flow |
customer.subscription.trial_will_end |
3 days before trial ends — prompt to add card |
checkout.session.completed |
One-time purchase completed |
Events you should handle but often skip:
| Event | Why |
|---|---|
customer.updated |
Email or billing address changed |
invoice.upcoming |
Upcoming renewal — send reminder |
charge.dispute.created |
Chargeback filed — respond within 7 days |
payment_method.detached |
Card removed — ask for replacement |
The mistake that costs real money
Here's the failure mode I see constantly:
// BROKEN — returns 200 before actually processing
export async function POST(request: NextRequest) {
const event = await verifyWebhook(request);
// Acknowledge immediately (correct intent, wrong placement)
const response = NextResponse.json({ received: true }); // 200 to Stripe
// Process after acknowledging
await updateDatabase(event); // If this fails, Stripe thinks it succeeded
return response;
}
If updateDatabase throws after you've already sent the 200, Stripe won't retry. Your customer paid. Your database doesn't know. You've lost the event.
The fix is simple: process first, respond second. Or process within a transaction that can be rolled back.
What this looks like assembled
This pattern — idempotency table, raw event storage, proper status tracking, retry-safe handlers — is exactly what the AI SaaS Starter Kit ships with. The Stripe integration is complete: subscription lifecycle, customer portal, payment failure handling, dunning emails, and the webhook handler above.
You clone it and the webhook infrastructure is already there. The 10 hours you'd spend building this correctly are already spent.
Stripe's official webhook docs are comprehensive — docs.stripe.com/webhooks. For the complete list of event types, check docs.stripe.com/api/events/types.
Top comments (0)