Busca en todas las páginas de la documentación
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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,
},
});
}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 });
}stripe.webhooks.constructEvent verifica el header stripe-signature contra el cuerpo sin procesar de la petición usando tu secreto del webhook. Esto evita que atacantes envíen eventos falsos.request.text()), no parsearse como JSON. Parsearlo antes corrompe la verificación de la firma.id único (p. ej., evt_xxx). Almacena los IDs de eventos procesados para lograr idempotencia y evitar procesar dos veces en los reintentos.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;
}event.data.object está tipado como Stripe.Event.Data.Object, que es una unión amplia. Haz cast al tipo específico según event.type.Stripe.Event para el tipo de evento y tipos de objeto específicos como Stripe.Checkout.Session, Stripe.Invoice, Stripe.Subscription para el objeto de datos.function isCheckoutEvent(
event: Stripe.Event
): event is Stripe.DiscriminatedEvent.CheckoutSessionCompletedEvent {
return event.type === "checkout.session.completed";
}request.text(). Usar request.json() romperá la verificación de la firma.whsec_...) es distinto del de tu Dashboard. Usa el secreto de la CLI durante el desarrollo local.request.headers.get() directamente en el App Router de Next.js. Usa la función headers() de next/headers en su lugar.| 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 |
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.
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.
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.
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.
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.
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.
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.
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.
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 completoevent.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;
}function isCheckoutEvent(
event: Stripe.Event
): event is Stripe.DiscriminatedEvent.CheckoutSessionCompletedEvent {
return event.type === "checkout.session.completed";
}Revisado por Chris St. John·Última actualización: 19 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥