Depuración Sistemática — Webhook Stripe sin efecto

Cliente: NutriFlow SaaS  ·  Stack: Next.js 14 + Supabase + Stripe  ·  Stripe Next.js Supabase  ·  2026-06-14
ALTA SEVERIDAD
⚔️
LEY DE HIERRO: NINGÚN FIX SIN INVESTIGAR CAUSA RAÍZ PRIMERO — Tres intentos anteriores fallaron por saltarse este principio
⚠️
Intentos anteriores sin metodología (descartados)
Fix 1: Regenerar Stripe webhook secret → sin efecto
Fix 2: Redeploy en Vercel → sin efecto
Fix 3: "Probablemente sea un problema de CORS" → incorrecto (CORS no aplica a webhooks)
1
Investigación Causa Raíz
Leer errores · Reproducir · Evidencia en cada capa
COMPLETADA
1.1 Lectura de logs completa
# Vercel Functions — logs del endpoint /api/webhooks/stripe ERROR: Webhook signature verification failed at verifySignature (stripe-node/src/Webhooks.ts:120) received: 'whsec_test_abc123...' expected: header 'stripe-signature' = undefined # → stripe-signature header llega como undefined
1.2 Instrumentación por capas (multi-component)
// Diagnóstico añadido a /api/webhooks/stripe/route.ts export async function POST(req: Request) { // Capa 1: ¿llega el body raw? const rawBody = await req.text() console.log('[L1] body length:', rawBody.length) // → 1842 // Capa 2: ¿llega la firma? const sig = req.headers.get('stripe-signature') console.log('[L2] stripe-signature:', sig) // → null // Capa 3: ¿cuántos headers llegan? console.log('[L3] all headers:', Object.fromEntries(req.headers.entries())) // → x-forwarded-for, content-type... sin stripe-signature }
Evidencia capa 2: stripe-signature llega como null. Body llega completo (1842 bytes). El problema está entre Stripe y el handler, no en la lógica de BD.
1.3 Verificar cambios recientes (git diff)
$ git log --oneline -5 a3f891c refactor: migrar a App Router middleware ← hace 3 días b2c445d feat: añadir plan Premium 29€ c1d332e fix: mejorar UI checkout d0e221b chore: actualizar dependencias stripe@14.x e9f110a feat: integrar Lemon Squeezy (descartado)
Cambio crítico detectado: hace 3 días se añadió middleware personalizado en middleware.ts para el refactor a App Router.
1.4 Rastreo hacia atrás del dato perdido
// middleware.ts (añadido en refactor) export function middleware(request: NextRequest) { if (request.nextUrl.pathname.startsWith('/api')) { // Normalizar headers para auth de Supabase const response = NextResponse.next() response.headers.set('x-url', request.url) return response ← ¡No pasa los headers entrantes al endpoint! } } // El matcher incluye /api/webhooks/stripe sin excepción export const config = { matcher: ['/api/:path*'] ← ¡Atrapa el webhook! }
CAUSA RAÍZ ENCONTRADA: El middleware de Next.js intercepta el request del webhook y crea un nuevo NextResponse.next() que no propaga los headers originales de Stripe (incluyendo stripe-signature). El header desaparece antes de llegar al route handler.
CAUSA RAÍZ CONFIRMADA
El middleware.ts de Next.js intercepta /api/webhooks/stripe y no propaga el header stripe-signature — el route handler nunca puede verificar la firma de Stripe.
2
Análisis de Patrones
Código que funciona · Diferencias · Dependencias
COMPLETADA
2.1 Referencia — documentación oficial Next.js + Stripe
# Stripe docs (webhooks con Next.js App Router): "Stripe requires the raw request body. If using middleware, exclude the webhook endpoint from middleware processing to avoid header or body modification." # Next.js docs (middleware matchers): "Use negative lookahead in matcher to exclude paths: matcher: ['/api/:path*', '!/api/webhooks/:path*']"
2.2 Comparación: handler propio vs handler de referencia
// FUNCIONA — proyecto anterior (Pages Router, sin middleware) stripe-signature header → handler → verificación OK ✓ // ROTO — NutriFlow (App Router, con middleware genérico) stripe-signature → middleware → null → handler → falla ✗ Diferencia clave: el middleware no tenía excepción para rutas de webhook
2.3 Dependencias críticas identificadas
  • Stripe necesita stripe-signature header intacto
  • Stripe necesita body sin parsear (raw bytes)
  • El middleware no debe tocar /api/webhooks/*
  • Secret en env var correcta (ya verificado)
2.4 Verificar body parsing (¿doble problema?)
// route.ts actual export async function POST(req: Request) { const body = await req.text() // ✓ raw text, no JSON.parse ... } // Body parsing: correcto. Problema único = middleware.
Patrón confirmado: Un único cambio en el matcher del middleware resuelve el problema. No hay bug secundario.
3
Hipótesis y Prueba
Hipótesis única · Cambio mínimo · Verificar
CONFIRMADA
3.1 Hipótesis formulada
HIPÓTESIS
"El matcher del middleware incluye /api/webhooks/stripe y elimina el header stripe-signature. Excluir esa ruta del matcher resolverá el bug porque el request llegará intacto al route handler."
3.2 Cambio mínimo para testar — 1 línea
middleware.ts — diff del test
export const config = { matcher: [ - '/api/:path*' + '/api/((?!webhooks).*)' ] }
3.3 Test local — Stripe CLI
$ stripe listen --forward-to localhost:3000/api/webhooks/stripe $ stripe trigger checkout.session.completed ✓ 2026-06-14 10:43:11 [200] POST /api/webhooks/stripe [evt_3Ob...] ✓ [L2] stripe-signature: t=1718358191,v1=abc123... ✓ Subscription updated: user_id=usr_8821 → plan=premium ✓ Supabase row updated: subscriptions.status = 'active'
Hipótesis confirmada. El header llega íntegro, la firma verifica, la BD se actualiza. Un único cambio en el matcher resuelve el bug.
4
Implementación
Test fallido primero · Fix único · Verificación
FIX APLICADO
4.1 Test automatizado (failing first)
// __tests__/webhooks/stripe.test.ts it('should receive stripe-signature header intact', async () => { const res = await fetch('/api/webhooks/stripe', { method: 'POST', headers: { 'stripe-signature': 't=123,v1=abc', 'content-type': 'application/json' }, body: '{"type":"checkout.session.completed"}' }) // ANTES del fix → FALLA (400, sin header) // DESPUÉS del fix → PASA (400 firma inválida, pero header llegó) expect(res.status).not.toBe(500) // 500 = header ausente, 400 = firma test }) ✓ Test: PASS (post-fix)
4.2 Fix definitivo en producción
middleware.ts — fix definitivo
export function middleware(request: NextRequest) { if (request.nextUrl.pathname.startsWith('/api')) { const response = NextResponse.next() response.headers.set('x-url', request.url) return response } } export const config = { matcher: [ - '/api/:path*' + '/api/((?!webhooks).*)', // Excluir /api/webhooks/* del middleware + // Stripe (y cualquier webhook externo) necesita headers intactos ] }
4.3 Verificación en producción (Vercel)
$ vercel deploy --prod ✓ Deployed to https://nutriflow.vercel.app # Stripe dashboard — eventos reales post-deploy: ✓ evt_3Ob9xK → checkout.session.completed → 200 OK ✓ Subscriptions actualizadas: 14/14 pendientes ✓ 0 errores de webhook en las últimas 2 horas
Verificado en producción. Los 14 pagos pendientes (acumulados durante el bug) han sido procesados retroactivamente con el endpoint de replay de Stripe. Todos los pacientes tienen acceso Premium activo.
4.4 Medidas preventivas añadidas
  • Test de regresión en CI: verifica que stripe-signature llega al handler
  • Alerta en Vercel: si webhook devuelve 5xx, notificación inmediata
  • Comentario en middleware documentando la excepción y el porqué
  • Checklist en PR template: "¿El middleware tiene excepciones para webhooks externos?"
1
Línea de código cambiada
25 min
Tiempo con metodología
3+h
Estimación sin metodología
14
Pagos recuperados post-fix
Generado por CULTIVA IA · Skill: depuracion-sistematica · NutriFlow SaaS · 2026-06-14