La mayoría de los tutoriales de Stripe conectan un botón Checkout y se quedan ahí. El billing en producción se rompe el día en que Stripe reintenta un evento cuatro veces y la base de datos termina con tres filas actualizadas a medias. El webhook router de abajo es el que enviamos: idempotente por event ID, type-safe por nombre de evento, transaccional respecto a la base de datos, y observable cuando un evento falla.
1. El route handler
El webhook llega a una route en runtime Node. Necesitamos el body raw para validar la firma, así que lo leemos como texto antes de parsear. El check de idempotencia ocurre antes de que el evento se procese.
// 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 })
}
// Idempotencia: salimos si este event ID ya se procesó.
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',
)
// Devolver 500 hace que Stripe reintente. Devolver 200 hace ack;
// elegimos según si el fallo es recuperable.
return new Response('handler failed', { status: 500 })
}
}
2. La tabla de idempotencia
Una sola tabla con un constraint unique sobre event_id es todo el mecanismo. Un reintento duplicado choca con el constraint y sale temprano.
-- 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. El router type-safe
Una discriminated union mapea tipos de evento a handlers. TypeScript estrecha la forma del evento por cada case, así el handler recibe un payload totalmente tipado sin un cast en runtime.
// 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:
// Los eventos no manejados devuelven 200 sin trabajo; la tabla los
// registra igual así podemos auditar el tráfico sin perder el evento.
return
}
}
4. Un handler transaccional
El handler de subscription hace upsert de la fila canónica dentro de una sola transacción. O la base de datos coincide con Stripe, o la escritura hace rollback y Stripe reintenta.
// 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. Qué te compra esto
Cuando Stripe reintenta un evento, el constraint unique sobre event_id cortocircuita el trabajo duplicado. Cuando la escritura en la base falla, todo el handler hace rollback y Stripe reintenta. Cuando sale un nuevo tipo de evento, añades un case en el router y TypeScript te dice la forma del payload. La tabla de auditoría registra cada evento que la aplicación haya visto, así el día que un cliente pregunta "¿por qué mi subscripción muestra el plan equivocado?", tienes los recibos.
El billing en producción no es un botón Checkout. Es la disciplina de tratar los eventos de Stripe como fuente de verdad y hacer que la base de datos vaya detrás, retry-safe, type-safe, cada vez.
Lecturas relacionadas