Oferta de lanzamiento: −50 % en tu primer pago, hasta el 30 de noviembre.Ver la oferta →
webhook·toolkit
Stripe

Falla la verificación de la firma del webhook de Stripe — descubre por qué

Última actualización: julio de 2026

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.

Todo se ejecuta en tu navegador con la Web Crypto API. Tu secreto de firma nunca se envía a nuestros servidores — compruébalo en la pestaña Red.

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:

Una cabecera Stripe-Signature real
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.

#CausaSíntoma distintivoSolución
1El cuerpo no son los bytes en brutoFalla 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.
2Secreto de firma incorrectoTodos 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.
3Un middleware parseó el cuerpo antesFunciona 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.
4Secreto 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.
5Cabecera ausente o truncadasig 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.
6Secreto de otro endpointUn 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.

FrameworkPor qué se modifica el cuerpoLa solución
Expressexpress.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 RouterLos route handlers no parsean el cuerpo por ti.const body = await request.text() — esa cadena ya está en bruto.
Next.js Pages RouterEl bodyParser integrado reescribe el cuerpo.export const config = { api: { bodyParser: false } }, y luego lee el stream en un buffer.
Flaskrequest.json / request.form consumen el stream y lo vuelven a codificar.payload = request.get_data() — bytes, antes de tocar .json.
DjangoAcceder a request.POST bloquea el cuerpo.request.body — léelo primero, antes de cualquier acceso al formulario.
AWS Lambda / API GatewayAPI 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.
Express — cuerpo en bruto solo en la ruta del webhook
// 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ás
Next.js App Router — app/api/webhook/route.ts
export 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 });
}
Next.js Pages Router — desactiva el body parser
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.

Generar un evento de Stripe firmado

Lista de diagnóstico

Recórrela en orden la próxima vez que constructEvent lance una excepción.

  1. 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.
  2. Confirma que el cuerpo que llega a constructEvent es la cadena en bruto intacta, no un objeto parseado y re-serializado.
  3. Copia el secreto de firma directamente de la página del endpoint en el Dashboard: no de stripe listen, ni de otro endpoint.
  4. Comprueba que usas el secreto live para el tráfico live y el secreto de test para el tráfico de test.
  5. Registra req.headers['stripe-signature'] y confirma que está presente y que ningún proxy lo ha truncado.
  6. 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.

Probar webhook StripeValidador de firma de webhookRelay

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.

Oferta de lanzamiento · −50 %

¿Depurando una integración de webhooks esta semana?

El Pass 7 días desbloquea las explicaciones con IA y el código del handler listo para pegar, 25 URL permanentes, 30 días de historial y el túnel Relay a localhost. Un solo pago, sin suscripción.

5 € 2,50 € una vez
Pago único · sin suscripción · reembolso en 14 días · precio de lanzamiento hasta el 30 de noviembre
Falla la verificación de la firma del webhook de Stripe: 6 causas · Webhook Toolkit