Why Webhooks Are Hard
Stripe calls your server when things happen: payment succeeded, subscription renewed, card declined. If your handler fails, Stripe retries. If your server is down, events queue up.
The failure modes are nasty:
- Process the same event twice → duplicate fulfillment
- Miss an event → user pays but doesn't get access
- Process out of order → subscription logic breaks
Here's how to handle this correctly.
Webhook Signature Verification
Never process events without verifying they came from Stripe:
import Stripe from 'stripe';
import { buffer } from 'micro'; // or read raw body
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const body = await request.text();
const signature = request.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 Error', { status: 400 });
}
await handleEvent(event);
return new Response('OK', { status: 200 });
}
Critical: Use the raw request body, not parsed JSON. Any transformation breaks the signature.
Idempotency: Process Once, No Matter What
Stripe retries failed webhooks. Your handler must be safe to call multiple times with the same event.
async function handleEvent(event: Stripe.Event) {
// Check if we've already processed this event
const existing = await db.webhookEvent.findUnique({
where: { stripeEventId: event.id },
});
if (existing?.processedAt) {
console.log(`Event ${event.id} already processed, skipping`);
return;
}
// Record that we're processing it
await db.webhookEvent.upsert({
where: { stripeEventId: event.id },
create: { stripeEventId: event.id, type: event.type },
update: {},
});
try {
await processEvent(event);
// Mark as successfully processed
await db.webhookEvent.update({
where: { stripeEventId: event.id },
data: { processedAt: new Date() },
});
} catch (error) {
// Log failure — Stripe will retry
await db.webhookEvent.update({
where: { stripeEventId: event.id },
data: { error: (error as Error).message },
});
throw error; // Return 500 so Stripe retries
}
}
Event Handler Pattern
async function processEvent(event: Stripe.Event) {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
await handleCheckoutCompleted(session);
break;
}
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
await syncSubscription(subscription);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await cancelSubscription(subscription);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
await handlePaymentFailed(invoice);
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
}
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
const userId = session.metadata?.userId;
if (!userId) throw new Error('No userId in session metadata');
if (session.mode === 'payment') {
// One-time purchase
await fulfillOrder(userId, session);
} else if (session.mode === 'subscription') {
// New subscription started
await activateSubscription(userId, session.subscription as string);
}
}
async function syncSubscription(subscription: Stripe.Subscription) {
await db.subscription.upsert({
where: { stripeSubscriptionId: subscription.id },
create: {
stripeSubscriptionId: subscription.id,
stripeCustomerId: subscription.customer as string,
status: subscription.status,
priceId: subscription.items.data[0].price.id,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
update: {
status: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
});
}
The Webhook Events Table
model WebhookEvent {
id String @id @default(cuid())
stripeEventId String @unique
type String
processedAt DateTime?
error String?
createdAt DateTime @default(now())
@@index([type])
@@index([processedAt])
}
This table is your audit log and idempotency guard in one.
Local Testing
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Log in
stripe login
# Forward events to your local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Trigger test events
stripe trigger payment_intent.succeeded
stripe trigger customer.subscription.created
Deployment Checklist
- [ ] Webhook secret stored in environment variables
- [ ] Raw body parsing (not JSON middleware) for webhook endpoint
- [ ] Idempotency check using event ID
- [ ] Retry on failure (return 5xx, don't swallow errors)
- [ ] Webhook events table for audit log
- [ ] All relevant event types handled
- [ ] Tested with Stripe CLI locally
- [ ] Webhook endpoint registered in Stripe Dashboard
Stripe's retry behavior means your handlers run in distributed, unreliable conditions. Design for it from the start.
Complete Stripe webhook handler with idempotency, subscription sync, and one-time fulfillment: Whoff Agents AI SaaS Starter Kit.
Top comments (0)