Stripe Webhooks con Next.js: Implementación Real Paso a Paso (2026)
Cómo implementar Stripe Webhooks en Next.js correctamente: verificación de firma, manejo de eventos, idempotencia y testing en local con Stripe CLI.
Tabla de contenidos
Stripe Webhooks es donde la mayoría de integraciones de pago fallan en producción. La verificación de firma, el raw body, la idempotencia — hay varios puntos donde meter la pata. Esta guía los cubre todos.
Por qué los webhooks de Stripe son especiales
A diferencia de una petición normal, Stripe envía el evento firmado criptográficamente. Tu servidor debe:
- Recibir el body sin parsear (raw Buffer, no JSON)
- Verificar la firma con tu webhook secret
- Responder 200 rápido (en menos de 30s) aunque proceses en background
- Ser idempotente — el mismo evento puede llegar dos veces
Si fallas en cualquiera de estos puntos, los pagos quedan sin procesar sin que el usuario lo sepa.
Setup en Next.js (App Router)
Instalar Stripe
npm install stripe
Configurar variables de entorno
# .env.local
STRIPE_SECRET_KEY=sk_test_... # Del dashboard de Stripe → API Keys
STRIPE_WEBHOOK_SECRET=whsec_... # Del dashboard → Webhooks → tu endpoint
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
Crear el endpoint del webhook
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// ⚠️ Crítico: deshabilita el body parsing de Next.js para esta ruta
export const runtime = 'nodejs'; // necesario para leer el raw body
export async function POST(request: NextRequest) {
const body = await request.text(); // raw string, no JSON
const signature = request.headers.get('stripe-signature');
if (!signature) {
return NextResponse.json({ error: 'No signature' }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error('Webhook signature verification failed:', err);
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
// Procesa el evento
try {
await procesarEvento(event);
} catch (err) {
console.error(`Error procesando evento ${event.type}:`, err);
// Devuelve 500 para que Stripe reintente
return NextResponse.json({ error: 'Processing failed' }, { status: 500 });
}
return NextResponse.json({ received: true });
}
Procesar eventos con idempotencia
// lib/stripe-events.ts
import { prisma } from '@/lib/prisma';
import Stripe from 'stripe';
export async function procesarEvento(event: Stripe.Event) {
// Idempotencia: si ya procesamos este evento, ignorar
const eventoExistente = await prisma.stripeEvent.findUnique({
where: { stripeEventId: event.id },
});
if (eventoExistente) {
console.log(`Evento ${event.id} ya procesado, ignorando`);
return;
}
// Registra el evento ANTES de procesarlo (evita race conditions)
await prisma.stripeEvent.create({
data: {
stripeEventId: event.id,
type: event.type,
processedAt: new Date(),
},
});
// Procesa según el tipo de evento
switch (event.type) {
case 'checkout.session.completed':
await onCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
break;
case 'customer.subscription.created':
case 'customer.subscription.updated':
await onSubscriptionChanged(event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.deleted':
await onSubscriptionCancelled(event.data.object as Stripe.Subscription);
break;
case 'invoice.payment_failed':
await onPaymentFailed(event.data.object as Stripe.Invoice);
break;
default:
console.log(`Evento no manejado: ${event.type}`);
}
}
Handlers de eventos reales
async function onCheckoutCompleted(session: Stripe.Checkout.Session) {
const userId = session.metadata?.userId;
if (!userId) {
throw new Error(`Checkout sin userId en metadata: ${session.id}`);
}
await prisma.user.update({
where: { id: userId },
data: {
plan: 'pro',
stripeCustomerId: session.customer as string,
stripeSubscriptionId: session.subscription as string,
},
});
// Enviar email de bienvenida al plan pro
// await enviarEmailBienvenidaPro(session.customer_details?.email);
console.log(`Usuario ${userId} actualizado a plan pro`);
}
async function onSubscriptionCancelled(subscription: Stripe.Subscription) {
await prisma.user.updateMany({
where: { stripeSubscriptionId: subscription.id },
data: {
plan: 'free',
stripeSubscriptionId: null,
},
});
}
async function onPaymentFailed(invoice: Stripe.Invoice) {
const customerId = invoice.customer as string;
// Buscar usuario por customerId de Stripe
const usuario = await prisma.user.findFirst({
where: { stripeCustomerId: customerId },
});
if (usuario) {
// Notificar al usuario que actualice su método de pago
// await enviarEmailPagoFallido(usuario.email);
console.log(`Pago fallido para usuario ${usuario.id}`);
}
}
Schema de Prisma para idempotencia
// schema.prisma
model StripeEvent {
id String @id @default(cuid())
stripeEventId String @unique
type String
processedAt DateTime
createdAt DateTime @default(now())
}
model User {
id String @id @default(cuid())
email String @unique
plan String @default("free")
stripeCustomerId String? @unique
stripeSubscriptionId String? @unique
}
Pasar userId a los webhooks: metadata
Stripe no sabe nada de tus usuarios. Tienes que pasar el userId al crear el checkout:
// app/api/checkout/route.ts
import Stripe from 'stripe';
import { auth } from '@/lib/auth'; // tu sistema de auth
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: NextRequest) {
const session = await auth(); // obtener sesión del usuario
if (!session?.user?.id) {
return NextResponse.json({ error: 'No autenticado' }, { status: 401 });
}
const checkout = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: process.env.STRIPE_PRICE_ID, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/precios`,
metadata: {
userId: session.user.id, // ← así lo recuperas en el webhook
},
});
return NextResponse.json({ url: checkout.url });
}
Testing en local con Stripe CLI
# Instalar Stripe CLI (Mac)
brew install stripe/stripe-cli/stripe
# Windows
scoop install stripe
# Login
stripe login
# Terminal 1 — reenvía eventos a tu servidor local
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Terminal 2 — dispara eventos de prueba
stripe trigger checkout.session.completed
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_failed
El CLI muestra en tiempo real qué eventos se envían y qué responde tu servidor.
Checklist antes de ir a producción
-
STRIPE_WEBHOOK_SECRETes el del endpoint de producción (no el del CLI local) - El endpoint devuelve 200 antes de 30 segundos
- Tienes idempotencia implementada (tabla
StripeEvent) - Manejas
invoice.payment_failedpara degradar el plan - El
userIdse pasa enmetadataal crear el checkout - Los logs de errores están configurados (Sentry, etc.)
Si combinas esto con Auth.js para la autenticación y variables de entorno correctamente configuradas, tienes la base de cualquier SaaS de pago. Puedes ver un caso real en el artículo del SaaS con NestJS y React.