Falla la verificación de la firma del webhook de Stripe — descubre por qué
constructEvent de Stripe lanza No signatures found matching the expected signature for payload y no tienes ni idea de cuál de una docena de cosas ha fallado. Esta página hace una sola cosa: toma los tres elementos que te da Stripe — el secreto whsec_, el cuerpo en bruto exacto y la cabecera Stripe-Signature —, recalcula el HMAC y señala la causa que realmente lo rompió.
En resumen: 9 de cada 10 veces es el cuerpo en bruto (tu framework re-serializó el JSON antes de la verificación) o el secreto de firma equivocado (el whsec_ de la CLI en lugar del del endpoint). El depurador de abajo distingue las dos cosas con un solo pegado.
Depura tu firma de Stripe
Stripe está seleccionado por defecto. El HMAC se calcula en tu navegador con WebCrypto: tu whsec_ nunca sale de la pestaña.
Anatomía de la cabecera Stripe-Signature
Cada webhook de Stripe llega con una cabecera como esta. Cada campo separado por comas tiene su función:
Stripe-Signature: t=1614556800, v1=5257a869e7ecebeda32affa62cdca3fa51cad7414a563a8c6e3c4b41c2b7f2c8, v0=6ffbb59b2300aae63f272406069a9788598b792a944a07aba816edb039989a39
| Anatomía de la cabecera Stripe-Signature | |
|---|---|
| t= | Timestamp Unix (en segundos) del momento en que Stripe generó la firma. Forma parte de lo que se firma — y es el valor con el que se compara la comprobación de replay de 5 minutos. |
| v1= | La firma que verificas de verdad: HMAC-SHA256 de la cadena t + "." + rawBody, codificado en hex, con el whsec_ de tu endpoint como clave. Es el único esquema que comprueba constructEvent. |
| v0= | Un esquema de prueba heredado. Stripe lo sigue enviando, pero la librería lo ignora para la verificación: no intentes comparar con v0. |
| , (coma) | Durante una rotación de secreto, Stripe firma con el secreto antiguo y con el nuevo, así que puedes ver dos valores v1=. constructEvent pasa si coincide cualquiera de ellos. |
La cadena firmada es `${t}.${rawBody}`, no el cuerpo solo. Olvida el prefijo t. en un verificador hecho a mano y todas las firmas fallan, incluso con el secreto correcto.
Las 6 causas de fondo, en el orden en que comprobarlas
Ordenadas según la frecuencia con que son las culpables. Cada una tiene una pista que la distingue de las demás.
| # | Causa | Síntoma distintivo | Solución |
|---|---|---|---|
| 1 | El cuerpo no son los bytes en bruto | Falla con eventos reales pero el HMAC calculado se parece; pasa si pegas la cadena en bruto exacta en el depurador de arriba. | Pasa a constructEvent el cuerpo de la petición intacto, antes de que lo toque ningún parser JSON. |
| 2 | Secreto de firma incorrecto | Todos los eventos fallan, el HMAC no se parece en nada al v1. A menudo es el whsec_ de la CLI usado con el tráfico del dashboard. | Copia el secreto de la página del endpoint en el Dashboard (Developers → Webhooks), no de stripe listen. |
| 3 | Un middleware parseó el cuerpo antes | Funciona con stripe trigger pero da 400 en producción; app.use(express.json()) de Express se ejecuta antes que tu ruta. | Monta express.raw({type:'application/json'}) solo en la ruta del webhook, por encima del parser JSON global. |
| 4 | Secreto de modo test en modo live (o al revés) | La firma solo falla con pagos reales y pasa con eventos de prueba. El whsec_ parece correcto pero es del otro modo. | Usa el whsec_ del endpoint live para el tráfico live; test y live tienen cada uno el suyo. |
| 5 | Cabecera ausente o truncada | sig es undefined o está vacío; un proxy o CDN eliminó Stripe-Signature o la pasó a minúsculas. | Lee req.headers['stripe-signature'] (en minúsculas en Node) y asegúrate de que tu proxy la reenvía. |
| 6 | Secreto de otro endpoint | Un endpoint verifica y otro da 400: reutilizaste un único whsec_ en dos URL de endpoint. | Cada endpoint tiene su propio secreto de firma. Asocia el secreto a la URL a la que Stripe está llamando de verdad. |
El arreglo del cuerpo en bruto, framework por framework
La causa n.º 1 es casi siempre esta: tu framework web leyó, parseó y re-serializó el JSON antes de que constructEvent lo viera. El JSON re-serializado tiene otros espacios, así que el HMAC deja de coincidir. Así se le pasan los bytes intactos a cada framework.
| Framework | Por qué se modifica el cuerpo | La solución |
|---|---|---|
| Express | express.json() parsea y descarta el texto en bruto en toda la app. | express.raw({type:'application/json'}) en la ruta del webhook, antes de express.json(). |
| Next.js App Router | Los route handlers no parsean el cuerpo por ti. | const body = await request.text() — esa cadena ya está en bruto. |
| Next.js Pages Router | El bodyParser integrado reescribe el cuerpo. | export const config = { api: { bodyParser: false } }, y luego lee el stream en un buffer. |
| Flask | request.json / request.form consumen el stream y lo vuelven a codificar. | payload = request.get_data() — bytes, antes de tocar .json. |
| Django | Acceder a request.POST bloquea el cuerpo. | request.body — léelo primero, antes de cualquier acceso al formulario. |
| AWS Lambda / API Gateway | API Gateway puede codificar en base64, o una plantilla de mapeo puede reescribir el cuerpo. | Usa la integración proxy de Lambda; si event.isBase64Encoded, decodifica event.body antes de verificar. |
// La ruta del webhook DEBE ir antes de express.json()
app.post(
'/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['stripe-signature'];
const event = stripe.webhooks.constructEvent(
req.body, sig, process.env.STRIPE_WEBHOOK_SECRET
);
res.json({ received: true });
}
);
app.use(express.json()); // todo lo demásexport async function POST(req: Request) {
const body = await req.text(); // en bruto, intacto
const sig = req.headers.get('stripe-signature')!;
const event = stripe.webhooks.constructEvent(
body, sig, process.env.STRIPE_WEBHOOK_SECRET!
);
return Response.json({ received: true });
}import { buffer } from 'micro';
export const config = { api: { bodyParser: false } };
export default async function handler(req, res) {
const raw = await buffer(req);
const sig = req.headers['stripe-signature'];
const event = stripe.webhooks.constructEvent(
raw, sig, process.env.STRIPE_WEBHOOK_SECRET
);
res.json({ received: true });
}Pega los bytes exactos en el depurador de arriba: si la firma solo coincide cuando quitas un salto de línea final o un campo recodificado, el cuerpo se modificó por el camino — no es tu secreto.
Modo test frente a modo live: el secreto que parece correcto pero no lo es
Stripe te da tres valores whsec_ distintos y es fácil cruzarlos. Primero, el secreto del endpoint de test, que se muestra en el Dashboard cuando estás en modo test. Segundo, el secreto del endpoint live: un valor distinto en el mismo endpoint pasado a live. Tercero, el secreto de la CLI que imprime stripe listen, distinto otra vez y válido solo para los eventos reenviados por esa sesión. Los tres producen un HMAC de aspecto válido, así que el error es el mismo No signatures found genérico: nada dice modo equivocado. La pista: los pagos de prueba verifican y los reales dan 400. Solución: lee el secreto del endpoint exacto, en el modo exacto, que recibe el tráfico.
La ventana de replay de 5 minutos que todo el mundo olvida
constructEvent no solo comprueba el HMAC: rechaza cualquier evento cuyo timestamp t= tenga más de 300 segundos, lanzando Timestamp outside the tolerance zone. Te muerde cuando reenvías un evento capturado horas después o cuando el reloj de tu servidor se ha desviado. La firma es perfectamente válida; lo que falla es la antigüedad. El depurador de arriba muestra la antigüedad del timestamp para que distingas un replay caducado de una discrepancia real de firma. Si de verdad necesitas una ventana más larga, constructEvent acepta un cuarto argumento tolerance en segundos — pero arregla antes el reloj.
¿Es tu handler o tu secreto? Genera un evento que sabes que es válido
Cuando el depurador dice que el secreto es correcto pero producción sigue dando 400, el bug está en cómo tu handler lee el cuerpo. Aíslalo: nuestro Signer genera un payload firmado para Stripe con un secreto que controlas tú, así que puedes hacer POST de un evento garantizado como válido directamente a tu handler local. Si pasa, tu código de verificación está bien y el problema es el secreto o el middleware. Si falla, el handler está modificando el cuerpo: vuelve a la tabla del cuerpo en bruto de arriba.
Lista de diagnóstico
Recórrela en orden la próxima vez que constructEvent lance una excepción.
- Pega el whsec_, el cuerpo en bruto y el Stripe-Signature que fallan en el depurador de arriba: te dice la causa antes de que sigas leyendo.
- Confirma que el cuerpo que llega a constructEvent es la cadena en bruto intacta, no un objeto parseado y re-serializado.
- Copia el secreto de firma directamente de la página del endpoint en el Dashboard: no de stripe listen, ni de otro endpoint.
- Comprueba que usas el secreto live para el tráfico live y el secreto de test para el tráfico de test.
- Registra req.headers['stripe-signature'] y confirma que está presente y que ningún proxy lo ha truncado.
- Comprueba que el timestamp t= del evento no tiene más de 5 minutos (replay caducado o reloj desviado).
Captura y reenvía el evento real que falla
¿Sigues atascado? Apunta Stripe a un Relay de Webhook Toolkit o a una URL de captura, atrapa la petición exacta que falla e inspecciona el cuerpo en bruto y las cabeceras byte a byte — luego reenvíala a tu handler tantas veces como necesites sin esperar a otro evento real.
Preguntas frecuentes
¿Por qué mi webhook de Stripe funciona en local pero no en producción?
Casi siempre es el cuerpo en bruto. En local con stripe listen, la CLI reenvía los bytes intactos, pero tu middleware de producción (express.json() de Express, un bodyParser del Pages Router, una plantilla de mapeo de API Gateway) parsea y re-serializa el JSON antes de la verificación. Pasa a constructEvent el cuerpo en bruto de la petición y funciona.
¿Qué significa «No signatures found matching the expected signature for payload»?
El HMAC que calculaste no coincide con ninguno de los v1= que envió Stripe. Dos causas habituales: el cuerpo se modificó antes de la verificación, o el secreto de firma es incorrecto (a menudo el whsec_ de la CLI en lugar del del endpoint). Pega los tres en el depurador de arriba y te dirá cuál es.
¿Puedo usar el mismo secreto de firma en modo test y en modo live?
No. Cada endpoint tiene un whsec_ distinto para test y para live, y la CLI imprime un tercero. Un secreto de test produce un HMAC de aspecto válido que aun así falla con eventos live, con el mismo error genérico. Usa el secreto del modo que recibe el tráfico.
¿Cómo arreglo «Timestamp outside the tolerance zone»?
La firma es válida, pero el t= del evento tiene más de 300 segundos: normalmente una captura reenviada o un reloj de servidor desviado. Arregla el reloj o reenvía un evento nuevo. Como último recurso, pasa una tolerancia mayor (en segundos) como cuarto argumento de constructEvent.
¿Se usa el v0 de la cabecera Stripe-Signature para la verificación?
No. constructEvent solo comprueba v1= (HMAC-SHA256 de t.rawBody). v0= es un esquema de prueba heredado que Stripe sigue emitiendo pero que la librería ignora. Si tu verificador hecho a mano compara con v0, ese es tu bug.
¿Este depurador de firmas de Stripe envía mi secreto a alguna parte?
No. El HMAC se calcula en tu navegador con WebCrypto. Tu whsec_, el cuerpo y la cabecera nunca salen de la pestaña: no se sube, registra ni guarda nada. Desconéctate de la red y sigue funcionando.