Saltar al contenido principal

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.

Fran Cobos 5 min de lectura 933 palabras

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:

  1. Recibir el body sin parsear (raw Buffer, no JSON)
  2. Verificar la firma con tu webhook secret
  3. Responder 200 rápido (en menos de 30s) aunque proceses en background
  4. 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_SECRET es 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_failed para degradar el plan
  • El userId se pasa en metadata al 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.

Fran Cobos

Fran Cobos

Desarrollador Full Stack especializado en IA aplicada, automatización y desarrollo web. Escribo sobre herramientas, tutoriales y casos reales para programadores.

¿Necesitas desarrollo a medida?

Apps web, IA, módulos ERP — cuéntame tu proyecto.