Web · Desarrollo Node.js / Next.js / FastAPI

Patrones Manejador Webhooks

Cultiva Automatiza — endpoint /api/webhooks/stripe
·
Proveedores: Stripe Lemon Squeezy n8n Cloud
·
Stack: Next.js 14 · Supabase · Upstash Redis
1 · Secuencia de ejecución obligatoria
1
Leer cuerpo raw
Sin parsear JSON todavía. La firma usa el body original.
Si parseas antes → verificación falla
2
Verificar firma
HMAC-SHA256 con la cabecera stripe-signature. Rechazar con 401 si inválida.
3
Parsear evento
Solo después de verificar. Usar stripe.webhooks.constructEvent()
4
Check idempotencia
¿Ya procesamos este event.id? → 200 inmediato, sin procesar de nuevo.
5
Procesar + registrar
Lógica de negocio en background. Guardar event_id en DB. Retornar 200.
2 · Implementación de referencia — Next.js App Router
📄 app/api/webhooks/stripe/route.ts
Stripe TypeScript
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { supabase } from '@/lib/supabase';
import { queue } from '@/lib/queue'; // BullMQ + Upstash Redis
import { logger } from '@/lib/logger'; // pino structured logger

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

// ─── PASO 0: Forzar raw body — crítico para verificación de firma ───────────
export const config = { api: { bodyParser: false } };

export async function POST(req: NextRequest) {
  const requestId = crypto.randomUUID();
  const startTime = Date.now();

  // ─── PASO 1: Leer raw body (NO usar req.json() todavía) ──────────────────
  const body = await req.text();
  const sig = req.headers.get('stripe-signature');

  // ─── PASO 2: Verificar firma ANTES de parsear ────────────────────────────
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig!,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    logger.warn({ requestId, err: (err as Error).message }, 'Firma inválida — rechazado');
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
  }

  // ─── PASO 3: Parsear ya realizado por constructEvent() ───────────────────
  const { id: eventId, type: eventType } = event;

  logger.info({
    requestId, eventId, eventType, provider: 'stripe'
  }, 'Webhook recibido');

  // ─── PASO 4: Check idempotencia — evita doble procesamiento ─────────────
  const { data: existing } = await supabase
    .from('processed_webhook_events')
    .select('event_id')
    .eq('event_id', eventId)
    .maybeSingle();

  if (existing) {
    logger.info({ requestId, eventId }, 'Evento duplicado — ya procesado');
    return NextResponse.json({ status: 'duplicate' }, { status: 200 });
  }

  // ─── PASO 5: Encolar procesamiento asíncrono (responder 200 primero) ─────
  // Stripe timeout: 30 s. Encolamos para no bloquear la respuesta.
  await queue.add('stripe-webhook', { event, requestId }, {
    attempts: 5,
    backoff: { type: 'exponential', delay: 2000 },
    removeOnComplete: true,
    removeOnFail: false, // queda en DLQ para inspección
  });

  // Registrar event_id antes de responder (protege contra race conditions)
  await supabase.from('processed_webhook_events').insert({
    event_id: eventId,
    event_type: eventType,
    provider: 'stripe',
    processed_at: new Date().toISOString(),
  });

  logger.info({
    requestId, eventId, eventType,
    duration: Date.now() - startTime
  }, 'Webhook encolado correctamente');

  return NextResponse.json({ status: 'queued' }, { status: 200 });
}
📊
Códigos HTTP de respuesta
Qué hace el proveedor con cada código
Código Significado Acción del proveedor
2xx Entregado OK Sin reintento
400 Request inválida Sin reintento (fallo permanente)
401/403 Error de auth Sin reintento
408 Timeout Reintento automático
429 Rate limited Reintento con delay
5xx Error servidor Reintento con backoff
💡
Retorna 200 inmediatamente tras validar. Procesa en background para evitar timeouts de Stripe (30s) que generan reintentos y duplicados.
Checklist de idempotencia
Evitar doble procesamiento garantizado
  • Extraer event_id único del payload o cabecera del proveedor
  • Consultar tabla processed_webhook_events antes de procesar
  • Si ya existe → retornar 200 sin re-procesar (el proveedor marcará como entregado)
  • Insertar event_id antes de responder (no después del procesamiento)
  • Para race conditions: usar ON CONFLICT DO NOTHING o lock de Redis
  • Limpiar eventos viejos (DELETE WHERE processed_at < NOW() - 30 days)
supabase — schema SQL SQL
CREATE TABLE processed_webhook_events (
  event_id    VARCHAR(255) PRIMARY KEY,
  event_type  VARCHAR(100),
  provider   VARCHAR(50),
  processed_at TIMESTAMP DEFAULT NOW(),
  payload    JSONB
);

-- Prevenir duplicados en entornos multi-instancia
INSERT INTO processed_webhook_events (event_id)
VALUES ($1)
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id;
🔄
Lógica de reintentos
Backoff exponencial con BullMQ
⚠️
Stripe reintenta hasta 87 horas antes de darse por vencido. Si tu handler falla con 5xx, recibirás el mismo evento múltiples veces.
lib/workers/stripeWorker.ts TypeScript
const worker = new Worker('stripe-webhook',
  async (job) => {
    const { event } = job.data;
    switch (event.type) {
      case 'customer.subscription.created':
        await activateSubscription(event);
        break;
      case 'invoice.payment_succeeded':
        await renewSubscription(event);
        break;
      case 'customer.subscription.deleted':
        await cancelSubscription(event);
        break;
    }
  },
  {
    connection: redis,
    concurrency: 5,
  }
);

// Configurar DLQ para fallos definitivos
worker.on('failed', async (job, err) => {
  if (job!.attemptsMade >= 5) {
    await alertTeam({
      eventId: job!.data.event.id,
      error: err.message,
    });
  }
});
Gotchas por framework
Errores comunes a evitar
Next.js App Router
export const config = { api: { bodyParser: false } } — NO funciona en App Router
✓ Usar await req.text() para obtener raw body
✓ Añadir ruta a matcher en middleware para skip de auth
Express
express.json() antes del webhook consumer destruye el raw body
✓ Usar express.raw({ type: 'application/json' }) solo para esa ruta
✓ Registrar el webhook handler antes del middleware global de JSON
FastAPI (n8n Cloud)
✗ Declarar parámetro como body: dict consume el body stream
✓ Usar body = await request.body() para raw bytes
✓ Luego json.loads(body) para parsear tras verificar
3 · Logging estructurado — trazabilidad completa
📝 Ejemplo de logs — evento Stripe procesado correctamente
JSON (pino)
// 1. Recepción del webhook
{"level":"info","requestId":"a3f2c1d9-...","eventId":"evt_1Nxyz...","eventType":"customer.subscription.created","provider":"stripe","msg":"Webhook recibido"}

// 2. Procesado y encolado
{"level":"info","requestId":"a3f2c1d9-...","eventId":"evt_1Nxyz...","eventType":"customer.subscription.created","duration":47,"msg":"Webhook encolado correctamente"}

// 3. Evento duplicado (idempotencia activa)
{"level":"info","requestId":"b7a1e2f0-...","eventId":"evt_1Nxyz...","msg":"Evento duplicado — ya procesado"}

// 4. Firma inválida (ataque o configuración incorrecta)
{"level":"warn","requestId":"c9d3b4a5-...","err":"No signatures found matching the expected signature for payload","msg":"Firma inválida — rechazado"}

// 5. Worker fallo — intento 3/5 (backoff exponencial)
{"level":"error","jobId":"bull:stripe-webhook:42","eventId":"evt_1Nxyz...","attempt":3,"nextRetry":16000,"err":"Connection timeout to Supabase","msg":"Worker falló — reintentando"}