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
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ó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.
-
✓
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)
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;
⚠️
Stripe reintenta hasta 87 horas antes de darse por vencido. Si tu handler falla con 5xx, recibirás el mismo evento múltiples veces.
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,
});
}
});
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
// 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"}