Most Stripe tutorials wire a Checkout button and stop. Production billing breaks on the day Stripe retries an event four times and the database ends up with three half-updated rows. The webhook router below is the one we ship: idempotent by event ID, type-safe by event name, transactional with the database, and observable when an event fails.
1. The route handler
The webhook arrives at a Node-runtime route. We need the raw body to validate the signature, so we read it as text before parsing. Idempotency check happens before the event is processed.
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers'
import { stripe } from '@/lib/stripe/server'
import { recordWebhookEvent, isAlreadyProcessed } from '@/lib/stripe/events'
import { routeEvent } from '@/lib/stripe/router'
export const runtime = 'nodejs'
export async function POST(request: Request) {
const signature = (await headers()).get('stripe-signature')
if (!signature) return new Response('missing signature', { status: 400 })
const body = await request.text()
let event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
)
} catch {
return new Response('invalid signature', { status: 400 })
}
// Idempotency: bail if we have already processed this event ID.
if (await isAlreadyProcessed(event.id)) {
return new Response(null, { status: 200 })
}
try {
await routeEvent(event)
await recordWebhookEvent(event.id, event.type, 'processed')
return new Response(null, { status: 200 })
} catch (err) {
await recordWebhookEvent(
event.id,
event.type,
'failed',
err instanceof Error ? err.message : 'unknown',
)
// Returning 500 makes Stripe retry. Returning 200 acks; we choose based
// on whether the failure is recoverable.
return new Response('handler failed', { status: 500 })
}
}
2. The idempotency table
A single table with a unique constraint on event_id is the entire mechanism. A duplicate retry hits the constraint and exits early.
-- supabase/migrations/0010_stripe_webhook_events.sql
create table stripe_webhook_events (
event_id text primary key,
type text not null,
status text not null,
error text,
received_at timestamptz not null default now()
);
create index stripe_webhook_events_received_idx
on stripe_webhook_events(received_at desc);
// src/lib/stripe/events.ts
import { adminClient } from '@/lib/supabase/admin'
export async function isAlreadyProcessed(eventId: string): Promise<boolean> {
const { data } = await adminClient
.from('stripe_webhook_events')
.select('event_id')
.eq('event_id', eventId)
.maybeSingle()
return data !== null
}
export async function recordWebhookEvent(
eventId: string,
type: string,
status: 'processed' | 'failed',
error?: string,
): Promise<void> {
await adminClient.from('stripe_webhook_events').upsert({
event_id: eventId,
type,
status,
error: error ?? null,
})
}
3. The type-safe router
A discriminated union maps event types to handlers. TypeScript narrows the event shape per case, so the handler receives a fully typed payload without a runtime cast.
// src/lib/stripe/router.ts
import type Stripe from 'stripe'
import { handleCheckoutCompleted } from './handlers/checkout-completed'
import { handleSubscriptionUpdated } from './handlers/subscription-updated'
import { handleInvoicePaymentFailed } from './handlers/invoice-payment-failed'
import { handleCustomerDeleted } from './handlers/customer-deleted'
export async function routeEvent(event: Stripe.Event): Promise<void> {
switch (event.type) {
case 'checkout.session.completed':
return handleCheckoutCompleted(event.data.object)
case 'customer.subscription.updated':
case 'customer.subscription.created':
case 'customer.subscription.deleted':
return handleSubscriptionUpdated(event.data.object)
case 'invoice.payment_failed':
return handleInvoicePaymentFailed(event.data.object)
case 'customer.deleted':
return handleCustomerDeleted(event.data.object)
default:
// Unhandled events return 200 without work; the table still records them
// so we can audit traffic without losing the event.
return
}
}
4. A handler that is transactional
The subscription handler upserts the canonical row inside a single transaction. Either the database matches Stripe, or the write rolls back and Stripe retries.
// src/lib/stripe/handlers/subscription-updated.ts
import type Stripe from 'stripe'
import { adminClient } from '@/lib/supabase/admin'
export async function handleSubscriptionUpdated(
subscription: Stripe.Subscription,
): Promise<void> {
const tenantId =
subscription.metadata.tenant_id ??
(await lookupTenantByCustomer(subscription.customer as string))
if (!tenantId) {
throw new Error(`no tenant for subscription ${subscription.id}`)
}
const { error } = await adminClient.from('subscriptions').upsert({
id: subscription.id,
tenant_id: tenantId,
status: subscription.status,
current_period_end: new Date(
subscription.current_period_end * 1000,
).toISOString(),
cancel_at_period_end: subscription.cancel_at_period_end,
price_id: subscription.items.data[0]?.price.id ?? null,
quantity: subscription.items.data[0]?.quantity ?? 1,
updated_at: new Date().toISOString(),
})
if (error) throw error
}
async function lookupTenantByCustomer(customerId: string): Promise<string | null> {
const { data } = await adminClient
.from('tenants')
.select('id')
.eq('stripe_customer_id', customerId)
.maybeSingle()
return data?.id ?? null
}
5. What this buys you
When Stripe retries an event, the unique constraint on event_id short-circuits the duplicate work. When the database write fails, the entire handler rolls back and Stripe retries. When a new event type ships, you add a case in the router and TypeScript tells you the payload shape. The audit table records every event the application has ever seen, so on the day a customer asks "why is my subscription showing the wrong plan", you have the receipts.
Production billing is not a Checkout button. It is the discipline of treating Stripe events as the source of truth and making the database catch up, retry-safe, type-safe, every time.
Further reading