Stripe bien hecho es parte del producto, no un botón de checkout
Subscriptions con prorating, Customer Portal para el self-service, un webhook router idempotente que sobrevive al día en que Stripe reintenta el mismo evento cuatro veces. La capa de billing que crece con el producto, en vez de pedir una reescritura en la serie A.
Por qué este stack
Customer Portal es la función más barata que no construyes tú
Cambios de plan, flujos de cancelación, descarga de recibos, historial de facturas, actualización del método de pago. Todo llega gratis con Customer Portal. Construirlo en casa son dos meses-ingeniero de trabajo que nadie quiere mantener después.
Subscriptions gestiona los casos límite que hunden el billing casero
Prorating en upgrade, downgrade al límite del periodo, add-ons a mitad de ciclo, extensión de trials, reintentos en tarjeta rechazada, emails de dunning. Stripe los gestiona todos. Nosotros conectamos el listener para que la base de datos refleje lo que Stripe sabe.
Los webhooks como fuente de verdad
El client nunca decide si una subscripción está activa. El webhook recibe el evento, valida la firma, escribe la fila canónica, y la aplicación lee desde ahí. Sin race condition entre el success del checkout y el estado de la base.
Tax y Radar son compliance que de otra manera pospondrías
Stripe Tax gestiona en automático IVA, GST y sales tax para las regiones que importan a tu buyer. Radar gestiona las reglas anti-fraude sin que escribas un motor de heurísticas. Las dos llegan como configuración, no como proyecto paralelo.
Sigma e informes para el equipo financiero sin dashboard custom
Tu equipo de finanzas escribe SQL contra los datos vivos de Stripe. Sin export CSV, sin script de reconciliación mensual, sin libro contable paralelo que va en drift de la verdad.
Qué construimos con esta tecnología
Integración Stripe Checkout
Checkout hosted para lanzamientos rápidos, o Elements embebido para flujos completamente brandeados. Elegimos por datos de conversión, no por preferencia estética.
Subscriptions con prorating
Planes por tier, mensual y anual, add-ons usage-based, prorating en cada upgrade, downgrade al límite del periodo, subscripciones pausadas.
Customer Portal embebido
Gestión de plan self-service, flujos de cancelación, historial de facturas, actualización del método de pago. Configurado para encajar con marca y producto.
Webhook event router
Handler idempotente que deduplica por event ID, routing type-safe por nombre de evento, transacción DB por evento, dead-letter queue para mensajes envenenados.
Stripe Tax conectado
Cálculo automático de IVA, GST y sales tax por región. Tax IDs recogidos en el checkout, formato de factura conforme a la jurisdicción del buyer.
Reglas anti-fraude Radar
Reglas de bloqueo, reglas de revisión, allow lists para clientes recurrentes. Calibradas a los patrones de fraude reales del producto, no a un ruleset genérico.
Stripe Connect para marketplaces
Cuentas conectadas Express, Standard o Custom según el modelo del marketplace. Onboarding, payouts, KYC, reportes 1099 donde aplique.
Billing usage-based
Metering, ventanas de agregación, precios por tier, umbrales de overage. Stripe Meters o registros de uso custom según el modelo.
Personalización de facturas
Templates de factura brandeados, formato de líneas, multi-divisa, breakdown de líneas de impuestos, footer copy personalizado.
Dunning y reintentos inteligentes
Recuperación de pago fallido, ventanas de smart retry, secuencia de email de dunning conectada a tu proveedor de email transaccional.
Jobs de reconciliación
Un job nocturno que compara eventos de Stripe con la base de datos y hace emerger el drift antes de que lo haga el equipo financiero.
Migración desde PayPal, Paddle, Lemon Squeezy
Subscripciones existentes migradas con el ciclo de billing preservado, métodos de pago de los clientes importados donde el proveedor origen lo permite.
Un webhook router idempotente que sobrevive a los reintentos de Stripe
Stripe reintenta los webhooks fallidos durante hasta tres días. Cada handler que enviamos es idempotente por event ID, type-safe por nombre de evento, y transaccional respecto a la escritura en la base de datos.
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.
Preguntas frecuentes
¿Stripe Checkout o Elements?
Checkout para la mayoría de SaaS, más rápido de enviar, menos regresiones de conversión, hosted por Stripe así el scope PCI se mantiene pequeño. Elements cuando el buyer pide un checkout in-app completamente brandeado (a menudo flujos premium consumer o marketplace). Elegimos por datos de conversión, no por gusto.
¿Cómo manejan la idempotencia de los webhooks?
Cada evento tiene un event ID de Stripe. Persistimos ese ID con un constraint unique antes de procesar el evento; un reintento duplicado choca con el constraint y devuelve 200 sin reejecutar el trabajo. El handler también va dentro de una transacción de base de datos, así una escritura parcial nunca deja la fila a medio estado.
¿Migráis desde PayPal, Paddle o Lemon Squeezy?
Sí. Las subscripciones activas migran con su ciclo de billing preservado; los métodos de pago se importan vía las herramientas de migración de cliente del proveedor donde se soportan (Stripe tiene importadores directos para algunos). El cutover ocurre en un solo día con una freeze window y un runbook que el equipo ejecuta paso a paso.
¿Cómo manejáis multi-divisa?
Una sola cuenta Stripe, múltiples divisas de presentación. Los precios se cotizan en la divisa del buyer al checkout, la divisa de payout se fija por región, el FX lo gestiona Stripe. Los reportes del dashboard siempre muestran la divisa canónica para la reconciliación.
¿Hacéis Stripe Connect para marketplaces?
Sí. Express para onboarding rápido (default), Custom cuando el marketplace necesita control total sobre la experiencia de la cuenta conectada. Hemos conectado payouts, reportes 1099 donde aplica, reintentos KYC y dashboards de cuenta conectada para marketplaces en tres verticales distintas.
¿Compliance PCI e IVA UE?
El scope PCI se queda en SAQ A cuando el checkout ocurre en Stripe Hosted Checkout o vía Elements con inputs en iframe. El IVA UE lo gestiona Stripe Tax recogiendo el tax ID del buyer al checkout. Documentamos la postura de compliance en el traspaso técnico.
¿Dunning y recuperación de ingresos?
Smart retry configurados a tu base de clientes, emails de dunning conectados a tu proveedor de transaccional (Resend por defecto), banners in-app cuando el método de pago necesita atención. El MRR recuperado vuelve al dashboard de administración.
Cuéntanos cómo factura tu producto
Una llamada de scoping, un número concreto en la primera respuesta, sin teatro de agencia. Una capa de billing en dos a cuatro semanas.