Webhooks de Stripe
Receta
Crea un Route Handler de Next.js que reciba eventos webhook de Stripe, verifique la firma y procese eventos clave como pagos exitosos y cambios de suscripción.
Añade el secreto del webhook a tu entorno:
# .env.local
STRIPE_WEBHOOK_SECRET=whsec_...Crea el Route Handler del webhook:
// app/api/webhooks/stripe/route.ts
import { stripe } from "@/lib/stripe";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import type Stripe from "stripe";
export async function POST(request: Request) {
const body = await request.text();
const headersList = await headers();
const signature = headersList.get("stripe-signature");
if (!signature) {
return NextResponse.json(
{ error: "Falta el header stripe-signature" },
{ status: 400 }
);
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
const message = err instanceof Error ? err.message : "Error desconocido";
console.error(`Webhook signature verification failed: ${message}`);
return NextResponse.json({ error: message }, { status: 400 });
}
try {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
await handleCheckoutCompleted(session);
break;
}
case "invoice.paid": {
const invoice = event.data.object as Stripe.Invoice;
await handleInvoicePaid(invoice);
break;
}
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionUpdated(subscription);
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionDeleted(subscription);
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
} catch (err) {
console.error(`Error processing webhook event ${event.type}:`, err);
return NextResponse.json(
{ error: "El manejador del webhook falló" },
{ status: 500 }
);
}
return NextResponse.json({ received: true });
}Implementa las funciones manejadoras:
// lib/webhook-handlers.ts
import type Stripe from "stripe";
import { db } from "@/lib/db";
export async function handleCheckoutCompleted(
session: Stripe.Checkout.Session
) {
const userId = session.metadata?.userId;
if (!userId) return;
if (session.mode === "subscription") {
await db.user.update({
where: { id: userId },
data: {
stripeCustomerId: session.customer as string,
stripeSubscriptionId: session.subscription as string,
plan: "pro",
},
});
} else if (session.mode === "payment") {
await db.purchase.create({
data: {
userId,
sessionId: session.id,
amount: session.amount_total!,
status: "completed",
},
});
}
}
export async function handleInvoicePaid(invoice: Stripe.Invoice) {
const subscriptionId = invoice.subscription as string;
if (!subscriptionId) return;
await db.user.updateMany({
where: { stripeSubscriptionId: subscriptionId },
data: {
planStatus: "active",
currentPeriodEnd: new Date(invoice.lines.data[0]?.period.end * 1000),
},
});
}
export async function handleSubscriptionUpdated(
subscription: Stripe.Subscription
) {
await db.user.updateMany({
where: { stripeSubscriptionId: subscription.id },
data: {
planStatus: subscription.status,
plan: subscription.items.data[0]?.price.lookup_key ?? "unknown",
},
});
}
export async function handleSubscriptionDeleted(
subscription: Stripe.Subscription
) {
await db.user.updateMany({
where: { stripeSubscriptionId: subscription.id },
data: {
plan: "free",
planStatus: "canceled",
stripeSubscriptionId: null,
},
});
}Ejemplo funcional
Manejador de webhook completo con idempotencia:
// app/api/webhooks/stripe/route.ts
import { stripe } from "@/lib/stripe";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import type Stripe from "stripe";
async function isEventProcessed(eventId: string): Promise<boolean> {
const existing = await db.stripeEvent.findUnique({
where: { eventId },
});
return !!existing;
}
async function markEventProcessed(eventId: string): Promise<void> {
await db.stripeEvent.create({
data: { eventId, processedAt: new Date() },
});
}
export async function POST(request: Request) {
const body = await request.text();
const headersList = await headers();
const signature = headersList.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "Sin firma" }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return NextResponse.json({ error: "Firma inválida" }, { status: 400 });
}
// Idempotencia: omitir eventos ya procesados
if (await isEventProcessed(event.id)) {
return NextResponse.json({ received: true, skipped: true });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.metadata?.userId;
if (userId) {
await db.user.update({
where: { id: userId },
data: {
stripeCustomerId: session.customer as string,
stripeSubscriptionId: session.subscription as string,
plan: "pro",
planStatus: "active",
},
});
}
break;
}
case "invoice.paid": {
const invoice = event.data.object as Stripe.Invoice;
if (invoice.subscription) {
await db.user.updateMany({
where: { stripeSubscriptionId: invoice.subscription as string },
data: { planStatus: "active" },
});
}
break;
}
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
await db.user.updateMany({
where: { stripeSubscriptionId: sub.id },
data: { plan: "free", planStatus: "canceled" },
});
break;
}
}
await markEventProcessed(event.id);
return NextResponse.json({ received: true });
}Profundización
Cómo funciona
- Stripe envía peticiones HTTP POST a tu endpoint de webhook cada vez que ocurren eventos (pagos, suscripciones, disputas, etc.).
stripe.webhooks.constructEventverifica el headerstripe-signaturecontra el cuerpo sin procesar de la petición usando tu secreto del webhook. Esto evita que atacantes envíen eventos falsos.- El cuerpo sin procesar debe leerse como texto (
request.text()), no parsearse como JSON. Parsearlo antes corrompe la verificación de la firma. - Stripe reintenta las entregas de webhook fallidas (respuestas distintas de 2xx) hasta 3 veces a lo largo de varias horas con backoff exponencial.
- Cada evento tiene un
idúnico (p. ej.,evt_xxx). Almacena los IDs de eventos procesados para lograr idempotencia y evitar procesar dos veces en los reintentos.
Variaciones
Pruebas locales con Stripe CLI:
# Instalar Stripe CLI
brew install stripe/stripe-cli/stripe
# Iniciar sesión en Stripe
stripe login
# Reenviar eventos a tu servidor local
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# En otra terminal, dispara eventos de prueba
stripe trigger checkout.session.completed
stripe trigger invoice.paid
stripe trigger customer.subscription.deletedLa CLI imprime un secreto de firma del webhook (whsec_...) cuando inicias la escucha. Úsalo como tu STRIPE_WEBHOOK_SECRET durante el desarrollo local.
Manejar payment_intent.succeeded directamente:
case "payment_intent.succeeded": {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
console.log(`Payment ${paymentIntent.id} succeeded: $${paymentIntent.amount / 100}`);
break;
}Notas de TypeScript
event.data.objectestá tipado comoStripe.Event.Data.Object, que es una unión amplia. Haz cast al tipo específico segúnevent.type.- Usa
Stripe.Eventpara el tipo de evento y tipos de objeto específicos comoStripe.Checkout.Session,Stripe.Invoice,Stripe.Subscriptionpara el objeto de datos.
function isCheckoutEvent(
event: Stripe.Event
): event is Stripe.DiscriminatedEvent.CheckoutSessionCompletedEvent {
return event.type === "checkout.session.completed";
}Errores comunes
- Debes leer el cuerpo de la petición como texto sin procesar con
request.text(). Usarrequest.json()romperá la verificación de la firma. - Los Route Handlers del App Router de Next.js no aplican middleware de parseo del cuerpo, así que el acceso al cuerpo sin procesar funciona sin configuración adicional.
- Stripe puede enviar el mismo evento varias veces. Implementa siempre idempotencia registrando los IDs de eventos procesados.
- Devuelve una respuesta 2xx rápidamente (en menos de 10 segundos). Si tu manejador necesita hacer trabajo lento, confirma el webhook primero y procesa de forma asíncrona.
- El secreto del webhook de Stripe CLI (
whsec_...) es distinto del de tu Dashboard. Usa el secreto de la CLI durante el desarrollo local. - En producción, configura el endpoint del webhook en el Dashboard de Stripe en Developers y Webhooks. Selecciona solo los eventos que realmente manejas.
- No uses
request.headers.get()directamente en el App Router de Next.js. Usa la funciónheaders()denext/headersen su lugar.
Alternativas
| Enfoque | Ventajas | Desventajas |
|---|---|---|
| Webhook con Route Handler | Control total, procesamiento en el servidor | Debes manejar verificación e idempotencia |
| Stripe CLI (desarrollo local) | Pruebas instantáneas, dispara eventos específicos | Solo para desarrollo |
| Polling de la API | No requiere infraestructura de webhooks | Retrasado, ineficiente, no recomendado |
| Svix (proxy de webhooks) | Gestión de reintentos, panel de monitorización | Servicio adicional que gestionar |
FAQs
¿Por qué debes leer el cuerpo de la petición como texto en lugar de JSON para la verificación de la firma del webhook?
stripe.webhooks.constructEvent verifica los bytes del cuerpo sin procesar contra el header de firma. Parsear primero con request.json() altera el cuerpo (orden de claves, espacios en blanco), lo que corrompe la comprobación de la firma y hace que falle la verificación.
¿Cuál es el propósito de la verificación de la firma del webhook?
Garantiza que el evento fue enviado realmente por Stripe, no por un atacante que envía payloads falsos a tu endpoint. La firma se calcula usando tu secreto del webhook, que solo tú y Stripe conocen.
¿Por qué es importante la idempotencia en los manejadores de webhooks?
Stripe puede entregar el mismo evento varias veces (reintentos ante fallos). Sin idempotencia, podrías procesar eventos dos veces — p. ej., otorgar créditos duplicados a un usuario. Almacena los IDs de eventos procesados para omitir duplicados.
¿Cómo pruebas webhooks localmente con Stripe CLI?
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# En otra terminal:
stripe trigger checkout.session.completedLa CLI imprime un secreto whsec_... para usarlo como tu STRIPE_WEBHOOK_SECRET local.
Trampa: ¿El secreto del webhook de Stripe CLI es el mismo que el del Dashboard?
No. La CLI genera su propio secreto de firma cuando ejecutas stripe listen. Usa el secreto de la CLI durante el desarrollo local y el del Dashboard en producción. Son valores distintos.
¿Con qué rapidez debe responder tu manejador de webhook?
Devuelve una respuesta 2xx en menos de 10 segundos. Si tu manejador necesita hacer trabajo lento (enviar correos, actualizaciones complejas de BD), confirma el webhook primero y procesa de forma asíncrona.
¿Qué ocurre si tu endpoint de webhook devuelve una respuesta distinta de 2xx?
Stripe reintenta la entrega hasta 3 veces a lo largo de varias horas con backoff exponencial. Tras fallar todos los reintentos, el evento se marca como fallido en tu Dashboard.
Trampa: ¿Puedes usar request.headers.get() directamente en un Route Handler del App Router de Next.js?
No. En el App Router de Next.js, usa la función headers() de next/headers para leer los headers de la petición. request.headers.get() directo puede no funcionar como esperas para todos los headers.
¿Qué eventos de webhook debería manejar una app SaaS típica?
checkout.session.completed— compra/suscripción inicial creadainvoice.paid— pago recurrente exitosocustomer.subscription.updated— cambios de plan, cambios de estadocustomer.subscription.deleted— suscripción cancelada por completo
TypeScript: ¿Cómo tipas el objeto de datos del evento para distintos tipos de evento?
event.data.object está tipado como una unión amplia. Haz cast según event.type:
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
break;
}
case "invoice.paid": {
const invoice = event.data.object as Stripe.Invoice;
break;
}TypeScript: ¿Cómo puedes crear un type guard para eventos de webhook específicos?
function isCheckoutEvent(
event: Stripe.Event
): event is Stripe.DiscriminatedEvent.CheckoutSessionCompletedEvent {
return event.type === "checkout.session.completed";
}