Stripe Webhooks 2026: Setup and Best Practices
·APIScout Team
stripewebhookspaymentsnext-jsidempotency2026
TL;DR
Stripe webhooks are how your backend learns about payment events. When a customer subscribes, pays, or cancels — Stripe doesn't wait for you to ask; it pushes the event to your endpoint. The implementation is straightforward, but production reliability requires signature verification (security), idempotency (no double-processing), graceful retry handling (Stripe retries for 72 hours), and testing without real payments. This guide covers all of it.
Key Takeaways
- Always verify webhook signatures — skip this and anyone can send fake payment events to your endpoint
- Return 200 immediately — process the event asynchronously; Stripe retries if you don't respond within 30s
- Idempotency is required — Stripe can deliver the same event multiple times; your handler must be safe to call twice
- Local testing: Stripe CLI
stripe listenforwards live events to localhost - Critical events:
checkout.session.completed,customer.subscription.updated,invoice.payment_failed - Webhook retry window: 72 hours, exponential backoff
The Webhook Architecture
User action → Stripe processes → Stripe sends webhook → Your endpoint
↓
Returns 200 immediately
↓
Process event async
↓
Update your database
Stripe events you must handle for a subscription SaaS:
checkout.session.completed → New subscription started
customer.subscription.updated → Plan change, renewal, reactivation
customer.subscription.deleted → Subscription cancelled/expired
invoice.payment_succeeded → Successful payment (recurring)
invoice.payment_failed → Failed payment (dunning started)
customer.subscription.trial_will_end → Trial ending in 3 days
payment_method.attached → New payment method added
Setup: Next.js App Router
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { headers } from 'next/headers';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-12-18.acacia',
});
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(request: Request) {
const body = await request.text(); // Raw text — must NOT be parsed first
const headersList = await headers();
const sig = headersList.get('stripe-signature');
if (!sig) {
return new Response('No signature', { status: 400 });
}
let event: Stripe.Event;
try {
// Verify signature — throws if invalid:
event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
} catch (err) {
console.error('Webhook signature verification failed:', err);
return new Response('Webhook signature verification failed', { status: 400 });
}
// Return 200 immediately — process async:
handleWebhookEvent(event).catch((err) => {
console.error('Webhook processing failed:', err, { eventId: event.id, type: event.type });
});
return new Response('OK', { status: 200 });
}
async function handleWebhookEvent(event: Stripe.Event) {
// Check idempotency — skip if already processed:
const already = await db.stripeEvent.findUnique({ where: { stripeEventId: event.id } });
if (already) {
console.log(`Skipping duplicate event: ${event.id}`);
return;
}
// Process the event:
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 event as processed:
await db.stripeEvent.create({
data: {
stripeEventId: event.id,
type: event.type,
processedAt: new Date(),
},
});
}
Idempotency: Handle Duplicate Events
Stripe guarantees "at least once" delivery — the same event can arrive twice. Your handlers must be safe to run multiple times with the same input.
// WRONG — not idempotent:
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
await db.user.update({
where: { stripeCustomerId: session.customer as string },
data: { plan: 'pro' },
});
await sendWelcomeEmail(user.email); // Will send twice if event delivered twice!
}
// RIGHT — idempotent:
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
const customerId = session.customer as string;
// Use upsert instead of create/update:
const subscription = await stripe.subscriptions.retrieve(session.subscription as string);
const result = await db.subscription.upsert({
where: { stripeCustomerId: customerId },
create: {
stripeCustomerId: customerId,
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
status: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
update: {
stripeSubscriptionId: subscription.id,
status: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
});
// Only send email if this is the FIRST time we're processing:
// (check if subscription was just created, not updated)
if (result._count?.create === 1) { // Prisma 5+ returns create count
await sendWelcomeEmail(customerId);
}
}
// Schema for idempotency table:
// Prisma:
model StripeEvent {
id String @id @default(cuid())
stripeEventId String @unique
type String
processedAt DateTime @default(now())
@@index([stripeEventId])
}
The Critical Handlers
// checkout.session.completed — new subscription
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
if (session.mode !== 'subscription') return; // Ignore one-time payments
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
);
const priceId = subscription.items.data[0].price.id;
// Map Stripe price to your plan:
const planMap: Record<string, string> = {
[process.env.STRIPE_PRO_MONTHLY_PRICE_ID!]: 'pro',
[process.env.STRIPE_PRO_ANNUAL_PRICE_ID!]: 'pro',
[process.env.STRIPE_TEAM_MONTHLY_PRICE_ID!]: 'team',
};
const plan = planMap[priceId] ?? 'free';
await db.user.update({
where: { stripeCustomerId: session.customer as string },
data: {
plan,
stripeSubscriptionId: subscription.id,
subscriptionStatus: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
});
}
// customer.subscription.updated — renewals, upgrades, cancellations
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const priceId = subscription.items.data[0].price.id;
const planMap: Record<string, string> = {
[process.env.STRIPE_PRO_MONTHLY_PRICE_ID!]: 'pro',
[process.env.STRIPE_TEAM_MONTHLY_PRICE_ID!]: 'team',
};
await db.user.update({
where: { stripeSubscriptionId: subscription.id },
data: {
plan: planMap[priceId] ?? 'free',
subscriptionStatus: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
// cancelAtPeriodEnd: true means they've cancelled but still have access
cancelledAt: subscription.cancel_at_period_end
? new Date(subscription.current_period_end * 1000)
: null,
},
});
}
// customer.subscription.deleted — access should be revoked
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
await db.user.update({
where: { stripeSubscriptionId: subscription.id },
data: {
plan: 'free',
subscriptionStatus: 'canceled',
stripeSubscriptionId: null,
},
});
}
// invoice.payment_failed — send dunning email
async function handlePaymentFailed(invoice: Stripe.Invoice) {
const user = await db.user.findUnique({
where: { stripeCustomerId: invoice.customer as string },
});
if (!user) return;
// Stripe automatically retries — we just need to notify the user:
await sendEmail({
to: user.email,
subject: 'Action required: payment failed',
template: 'payment-failed',
data: {
amount: (invoice.amount_due / 100).toFixed(2),
currency: invoice.currency.toUpperCase(),
retryUrl: `${process.env.NEXT_PUBLIC_APP_URL}/billing`,
},
});
}
Local Testing with Stripe CLI
# Install Stripe CLI:
brew install stripe/stripe-cli/stripe
stripe login
# Forward events to your local server:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Output:
# > Ready! Your webhook signing secret is: whsec_test_... (copy this to .env)
# Trigger specific events:
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed
# Or replay a real production event:
stripe events resend evt_1234567890
# .env.local — use the CLI-generated secret for local testing:
STRIPE_WEBHOOK_SECRET=whsec_test_abc123... # From stripe listen output
# .env.production — use Stripe Dashboard webhook secret for production:
STRIPE_WEBHOOK_SECRET=whsec_live_xyz789... # From Stripe Dashboard
Registering the Webhook in Production
// Programmatic webhook registration (optional — usually done in Stripe Dashboard):
const webhook = await stripe.webhookEndpoints.create({
url: 'https://yourdomain.com/api/webhooks/stripe',
enabled_events: [
'checkout.session.completed',
'customer.subscription.updated',
'customer.subscription.deleted',
'invoice.payment_succeeded',
'invoice.payment_failed',
'customer.subscription.trial_will_end',
],
});
console.log('Webhook secret:', webhook.secret);
// Save this to STRIPE_WEBHOOK_SECRET in your environment
Debugging Webhook Issues
// Add detailed logging:
async function handleWebhookEvent(event: Stripe.Event) {
const startTime = Date.now();
console.log(`[Webhook] Processing: ${event.type} (${event.id})`);
try {
// ... your event handling
const duration = Date.now() - startTime;
console.log(`[Webhook] Completed: ${event.type} in ${duration}ms`);
} catch (err) {
console.error(`[Webhook] Failed: ${event.type}`, {
eventId: event.id,
error: err instanceof Error ? err.message : err,
duration: Date.now() - startTime,
});
throw err; // Rethrow so it gets logged
}
}
# View webhook delivery attempts in Stripe Dashboard:
# Dashboard → Developers → Webhooks → [your endpoint] → Recent deliveries
# Or via CLI:
stripe events list --limit 20
stripe events retrieve evt_1234567890
Explore and compare payment APIs at APIScout.