Stripe Webhooks 2026: Setup and Best Practices
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
Production Gotchas
Issues that trip teams in production:
The 30-second rule: Stripe expects a 200 response within 30 seconds. If your handleWebhookEvent takes longer (slow database write, external API call), Stripe marks the delivery as failed and retries. The pattern in this guide — return 200 immediately, process async — solves this. But make sure your async processing doesn't silently fail. Log errors explicitly; if the async handler throws, Stripe won't know and won't retry.
Ordering is not guaranteed: Stripe's retry logic can deliver events out of order. A customer.subscription.deleted event can arrive before customer.subscription.updated. Design your handlers to be order-independent: always fetch the latest subscription state from Stripe API rather than relying on event payload alone. const subscription = await stripe.subscriptions.retrieve(event.data.object.id) ensures you always have current state.
Test vs production webhook secrets: your STRIPE_WEBHOOK_SECRET from stripe listen (test) is different from the secret in Stripe Dashboard (production). Switching environments and forgetting to update this env var causes Webhook signature verification failed in production. Use separate .env.local (test) and production secrets.
Webhook endpoint availability: your webhook endpoint must be publicly reachable before you go live. If you deploy to Vercel, the /api/webhooks/stripe route is available immediately on deployment. If you use a VPS, make sure the domain and SSL are configured before registering the webhook URL in Stripe Dashboard.
Monitoring Webhook Health
Stripe Dashboard → Developers → Webhooks shows delivery attempt history for each endpoint. Events with failed deliveries appear in red with the HTTP status code your endpoint returned. Check this daily when you first launch; weekly after the system stabilizes.
For automated monitoring: create a Datadog/Grafana alert on HTTP 500 responses to /api/webhooks/stripe. Stripe retries on 5xx — so a spike in errors from your webhook endpoint shows up as repeated delivery attempts. Track key metrics: webhook delivery success rate (should be >99%), processing time per event type (should be <500ms for database writes), and event lag (time from Stripe generation to your processing — event.created vs Date.now()). Stripe retries for 72 hours with exponential backoff — if your endpoint is down for more than 3 days, events are permanently lost, putting your subscription state out of sync.
Webhook Testing in CI/CD
The Stripe CLI's stripe listen command works locally but can't run in cloud CI without a publicly reachable URL. Three approaches for testing webhook handling in CI:
Local forwarding with ngrok or Cloudflare Tunnel: run your app server in one process and forward Stripe's webhooks through a tunnel in another. GitHub Actions can run both — start the Next.js dev server, start ngrok, register the tunnel URL as a Stripe webhook endpoint for the test run, and tear down after. This is the closest to production behavior and tests the full path: Stripe → internet → your endpoint. The downside is test isolation: each CI run needs a unique tunnel URL, and stripe trigger in test mode still requires valid test-mode credentials.
Mocked webhook handler tests: skip the HTTP layer entirely and call your handleWebhookEvent function directly with mocked Stripe.Event objects. This is the fastest approach for testing event handler logic — create the event object as JavaScript, call the handler, assert the database changes. The limitation is that you're not testing the signature verification step, the raw body parsing, or the 200-response-before-async-processing pattern. Use these for unit testing individual handler functions; don't rely on them as the only test of your webhook endpoint.
Docker Compose integration tests: run a local server, a test database, and the Stripe CLI in Docker Compose for a fully isolated test environment. The Stripe CLI in stripe listen mode can forward to your containerized server without an internet connection by running in --forward-to mode against the container's internal network address. This approach takes more setup but produces reliable, isolated webhook tests that work identically in any environment and don't require ngrok accounts or tunnel configuration.
Designing Event-Driven State Machines
Stripe subscription webhooks map cleanly to a state machine. Modeling your subscription state explicitly prevents the common bug where state transitions are handled inconsistently across multiple event types.
The subscription states your application needs to handle:
free → trialing (checkout.session.completed with trial_period_days)
trialing → active (customer.subscription.updated when trial converts to paid)
trialing → canceled (customer.subscription.deleted if user cancels during trial)
free → active (checkout.session.completed without trial)
active → past_due (customer.subscription.updated when payment fails)
past_due → active (customer.subscription.updated on successful retry)
past_due → canceled (customer.subscription.deleted after all retries exhausted)
active → canceled (customer.subscription.deleted on user cancellation)
active → active (customer.subscription.updated on plan change or renewal)
The critical design decision: never derive subscription state from the event type alone. Derive it from the subscription object's fields. When you receive customer.subscription.updated, retrieve the subscription from Stripe (don't trust the event payload alone — see the ordering caveat in Production Gotchas) and then read subscription.status and subscription.items.data[0].price.id to determine what to write to your database.
This is especially important for the active → canceled transition. A user who cancels with cancel_at_period_end: true doesn't lose access immediately — they're still active until current_period_end. The customer.subscription.updated webhook fires when they cancel, with status: 'active' and cancel_at_period_end: true. The customer.subscription.deleted webhook fires at the period end when access should be revoked. If you revoke access on cancel_at_period_end: true instead of waiting for the deleted event, you've broken the user's paid access for the remainder of their billing cycle — a support headache and a potential chargeback.
Building the state machine as an explicit table (previous state + event → new state) makes these transitions documentable, testable, and auditable. Run the state table through unit tests with mocked Stripe event objects before deploying subscription logic changes.
Webhook Security Beyond Signature Verification
Signature verification stops fake webhook events, but it's not the only security consideration for your webhook endpoint.
IP allowlisting: Stripe publishes its webhook IP ranges in a JSON file (linked from Stripe's developer documentation). You can restrict your webhook endpoint to only accept requests from those IPs at the load balancer or CDN layer, before the request reaches your application. This provides defense-in-depth: even if an attacker finds a signature verification bypass, they can't reach your endpoint from unauthorized IPs. Update the allowlist periodically, as Stripe adds new IP ranges when expanding infrastructure.
Rate limiting at the endpoint level: Stripe delivers webhooks in bursts during high-activity periods (large batches of renewals, a fraud spike triggering many failure events). Your webhook endpoint should handle concurrent requests without queuing issues. If your database writes become a bottleneck under burst load, consider queuing webhook events (Upstash QStash, Inngest, or AWS SQS) and processing them with a worker. The queue provides natural rate limiting and makes retries easy if processing fails.
Secrets rotation: if your STRIPE_WEBHOOK_SECRET is compromised, rotate it in Stripe Dashboard immediately (Dashboard → Developers → Webhooks → [endpoint] → Signing secret → Roll secret). Stripe supports a secret rotation window that allows both the old and new secrets to be valid simultaneously for a brief period, so you can deploy the new secret without dropping events during the transition. This is the same pattern used for API key rotation — deploy the new secret first, then revoke the old one after confirming delivery is working.
Methodology
Stripe API version referenced: 2024-12-18.acacia. Code examples use the stripe npm package 16.x. Webhook retry behavior (72-hour window, exponential backoff) sourced from Stripe's official webhook documentation. Stripe CLI version: 1.22.x, which introduced Docker-compatible forwarding. All webhook event types and field names are based on Stripe's published event reference as of March 2026. The idempotency pattern using a StripeEvent table is one of several valid approaches — alternatives include distributed locks (Redis) or Postgres advisory locks for higher-throughput webhook processing. IP allowlist data sourced from Stripe's published webhook IP range JSON at the URL in their developer documentation. The state machine transition table in "Designing Event-Driven State Machines" reflects behavior as of the Stripe Billing API version cited; subscription lifecycle behavior can differ between API versions, so test thoroughly after any Stripe API version upgrade.
Explore and compare payment APIs at APIScout.
Related: Build a Payment System: Stripe + Plaid 2026, Building a SaaS Backend, How to Add Stripe Payments to Your Next.js App