La maggior parte dei tutorial Stripe collega un pulsante Checkout e si ferma lì. Il billing in produzione si rompe il giorno in cui Stripe ritrasmette un evento quattro volte e il database si ritrova con tre righe aggiornate a metà. Il webhook router qui sotto è quello che rilasciamo: idempotente per event ID, type-safe per nome evento, transazionale rispetto al database e osservabile quando un evento fallisce.
1. Il route handler
Il webhook arriva su una route in runtime Node. Ci serve il body raw per validare la firma, quindi lo leggiamo come testo prima di parsare. Il check di idempotenza avviene prima del processing dell'evento.
// 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 })
}
// Idempotenza: usciamo se questo event ID è già stato processato.
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',
)
// Restituire 500 fa ritrasmettere Stripe. Restituire 200 fa ack;
// scegliamo in base a se il fallimento è recuperabile.
return new Response('handler failed', { status: 500 })
}
}
2. La tabella di idempotenza
Una sola tabella con vincolo unique su event_id è tutto il meccanismo. Un retry duplicato sbatte sul vincolo ed esce subito.
-- 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. Il router type-safe
Una discriminated union mappa i tipi di evento sugli handler. TypeScript restringe la forma dell'evento per ogni case, così l'handler riceve un payload tipizzato senza un cast 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:
// Gli eventi non gestiti tornano 200 senza lavoro; la tabella li
// registra comunque così possiamo verificare il traffico senza perdere
// l'evento.
return
}
}
4. Un handler transazionale
L'handler della subscription fa upsert della riga canonica dentro una sola transazione. O il database combacia con Stripe, o la scrittura fa rollback e Stripe ritrasmette.
// 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. Cosa ti compra questo
Quando Stripe ritrasmette un evento, il vincolo unique su event_id cortocircuita il lavoro duplicato. Quando la scrittura sul database fallisce, tutto l'handler fa rollback e Stripe ritrasmette. Quando esce un nuovo tipo di evento, aggiungi un case nel router e TypeScript ti dice la forma del payload. La tabella di audit registra ogni evento che l'applicazione abbia mai visto, così il giorno in cui un cliente chiede "perché il mio abbonamento mostra il piano sbagliato", hai le ricevute.
Il billing in produzione non è un pulsante Checkout. È la disciplina di trattare gli eventi Stripe come la fonte di verità e far rincorrere il database, retry-safe, type-safe, ogni volta.
Approfondimenti