Un webhook que devuelve 401 o 403 cae en un pequeño número de cajones. La mayoría de las veces ni siquiera es un problema de firma — es un middleware o un valor por defecto del framework que rechaza la petición antes de que tu código corra. Esta es la checklist de diagnóstico que usamos, en el orden en que la usamos.
Primero, separa 401 de 403
Significan cosas distintas y el arreglo es distinto.
401 Unauthorized: el servidor recibió la petición, miró las credenciales (o la firma) y no las aceptó. Tu handler probablemente corrió, calculó una firma esperada y rechazó.
403 Forbidden: el servidor recibió la petición y se negó a procesarla por algún otro motivo. A menudo la petición nunca llegó a tu handler — un middleware o un valor por defecto del framework envió el 403.
Abre la captura de peticiones de tu túnel y mira la respuesta. Si ves el cuerpo de respuesta de tu handler («invalid signature»), es un 401 de tu código. Si la respuesta es genérica y tus logs del handler no muestran nada, el framework la rechazó antes de que tu handler corriera.
Las cinco causas más probables
1. Middleware CSRF (Django, Rails, Laravel)
La protección CSRF por defecto rechaza POSTs sin token de sesión. Los proveedores de webhook no envían uno. Síntomas: 403, cuerpo de respuesta genérico, sin logs del handler.
Arreglo: excluye la ruta del webhook de la protección CSRF. En Django, @csrf_exempt. En Rails, skip_before_action :verify_authenticity_token, only: [:webhook]. En Laravel, añade la ruta a VerifyCsrfToken::$except. La guía de webhooks de Django recorre la versión de Django de punta a punta.
2. Middleware de auth aplicado demasiado amplio
Añadiste middleware de auth de forma global (JWT, comprobación de sesión, requisito de API key) y la ruta del webhook lo heredó. El proveedor no envía tu cabecera de auth, así que el middleware envía un 401 antes de que tu handler corra.
Arreglo: excluye la ruta del webhook de tu middleware de auth. La autenticación del webhook es la firma, no el esquema que protege el resto de tu API.
3. Verificación de firma fallando
Tu handler corrió, calculó una firma esperada y no coincidió. Cinco subcausas, en orden aproximado de frecuencia descendente:
- Cuerpo parseado por middleware antes de que corriera la verificación (el cuerpo crudo ya no está).
- Codificación equivocada (hex vs base64). Mira la guía de verificación de firmas.
- Secreto equivocado (test vs live, dashboard vs CLI, archivo env vs runtime).
- Timestamp demasiado viejo (la firma es válida pero rancia — probablemente pruebas con un payload viejo reenviado).
- Espacios en blanco al final del secreto cargado desde un archivo env.
4. URL de túnel equivocada registrada
Reiniciaste tu túnel y la URL rotó, pero el dashboard del proveedor todavía tiene la vieja. El síntoma parece un 401 porque la petición nunca llegó a ti — pero en realidad es una petición distinta llegando a un servidor distinto (a menudo la sesión de túnel anterior, que ahora rechaza o devuelve 401).
Arreglo: confirma que la URL en el dashboard del proveedor coincide con tu sesión de túnel actual. Si necesitas una URL estable, mira los túneles con nombre o un subdominio reservado.
5. Restricciones de CORS, content-type o método
Menos común en webhooks pero posible. Si tu ruta solo acepta application/json y el proveedor envía application/x-www-form-urlencoded (Twilio, por ejemplo), algunos frameworks dan 415 — pero uno mal configurado podría dar 403. O tu ruta está registrada para GET y el proveedor hace POST.
El flujo de diagnóstico de 90 segundos
Este es el orden por el que pasamos:
- Lee el cuerpo de respuesta. Si tiene las palabras de tu handler, la petición llegó al handler. Pasa a depurar la firma. Si es una página de error genérica del framework, la petición no llegó al handler. Pasa a depurar el middleware.
- Revisa los logs del handler. ¿Disparan algún statement de log de tu handler? Confirma si la petición llegó a ti.
- Revisa la URL registrada. Abre el dashboard del proveedor. Confirma que la URL coincide con tu túnel actual. Confirma que apunta a la ruta correcta.
- Compara las cabeceras entrantes. La captura del túnel te muestra las cabeceras exactas que envió el proveedor. Compara con lo que lee tu código. Las cabeceras son insensibles a mayúsculas pero la forma de acceder a ellas difiere por framework —
request.headers.get('Stripe-Signature')en algunos,request.META['HTTP_STRIPE_SIGNATURE']en otros. - Verifica que el cuerpo está crudo al momento de la firma. Imprime la longitud del cuerpo justo antes de calcular la firma. Si es cero o sorprendentemente pequeña, un middleware se lo comió.
- Vuelve a comprobar el secreto. Compara la variable de entorno en tu runtime con el dashboard.
console.log(process.env.WEBHOOK_SECRET.length)— ¿coincide la longitud con lo que muestra el dashboard?
En nuestra experiencia, los pasos 1 y 5 atrapan el 80 % de los casos dentro de los primeros dos minutos.
Captura la petición que falla
La herramienta más útil aquí es un túnel con captura y replay de peticiones. No necesitas esperar a que el proveedor reintente — reproduces la petición capturada contra tu handler local mientras depuras. Cada intento es instantáneo.
Si usas un túnel que no captura peticiones, depuras con una mano atada a la espalda. Cambiar a uno que sí (o correr tcpdump, o poner nginx delante de tu servidor de desarrollo) se paga solo la primera vez que ahorras una hora.
Cómo se ve el 401 desde cada proveedor
- Stripe: un 401 de tu código significa que la verificación de firma falló. El dashboard de Stripe muestra la entrega como fallida e incluye tu cuerpo de respuesta.
- GitHub: si tu handler devuelve 401, GitHub marca la entrega como fallida y reintenta. La página de entregas recientes muestra la respuesta.
- Shopify: un 401 desde un handler de webhook está bien por seguridad pero Shopify reintentará. Tras 19 fallos consecutivos en 48 horas, Shopify desactiva la suscripción.
Para el contexto más amplio de depuración de webhooks, mira cómo depurar webhooks en local. Únete a la lista de espera de PortPreview para un túnel con captura integrada.