¿Falló la verificación de firma del webhook de Stripe? Cada causa y solución (Node, Next.js, Python, 2026) — Cesar Ayala
← Todos los artículos

¿Falló la verificación de firma del webhook de Stripe? Cada causa y solución (Node, Next.js, Python, 2026)

Casi siempre es una de tres cosas: un secret de endpoint equivocado (test, live y el Stripe CLI tienen su propio whsec_), un body que se parseó y reserializó antes de llegar a constructEvent, o un proxy que lo recodificó. Verifica la firma sobre los bytes crudos exactos, luego deduplica por event.id y responde 2xx rápido.

Qué significa realmente el error

Si llegaste aquí con No signatures found matching the expected signature for payload, la buena noticia es que tu código casi nunca es el problema. Lo digo después de cablear webhooks de Stripe en producción con dinero real moviéndose: cuando la firma falla, falla por los insumos, no por la lógica.

Vamos al modelo mental. Cuando Stripe te manda un evento, también te manda un header Stripe-Signature. Ese header contiene un timestamp (t=) y una o más firmas (v1=). Stripe calculó un HMAC sobre el body crudo exacto que te envió, usando el signing secret de tu endpoint. Tu SDK hace lo mismo del lado tuyo y compara:

// Node
const event = stripe.webhooks.constructEvent(rawBody, sigHeader, endpointSecret);
# Python
event = stripe.Webhook.construct_event(payload, sig_header, endpoint_secret)

Hay exactamente tres insumos: el rawBody, el header Stripe-Signature, y el endpoint secret. Si cualquiera de los tres está mal —aunque sea por un byte— el HMAC no coincide y revienta con ese mensaje, todas las veces, con código perfecto. Por eso no sirve de nada releer tu handler: lo que tienes que auditar son los insumos.

Esta es la base de toda mi disciplina de webhooks, la que explico a fondo en cómo integrar Stripe en tu SaaS: el evento verificado es la única fuente de verdad. La referencia oficial es Resolve webhook signature verification errors.

El diagnóstico en 60 segundos

Antes de tocar una sola línea de código, aísla cuál de las tres causas tienes. Tres pasos:

Paso 1 — el secret coincide con el modo. Test, live y el Stripe CLI tienen cada uno su propio whsec_. Confirma que el secret en tu env var corresponde al modo del que viene el evento.

Paso 2 — los bytes crudos llegan intactos. Antes de verificar, loguea qué tipo de dato estás recibiendo. En Node, Buffer.isBuffer(req.body) debería ser true; si ves un objeto ya parseado, ahí está tu bug.

Paso 3 — revisa el camino. ¿Hay un proxy, un gateway o un middleware de logging delante que reescriba el body?

Dos pistas rápidas que ahorran horas:

  • Si funciona en local con stripe listen pero falla en producción (o al revés), casi seguro es mismatch de secret.
  • Si todos los eventos fallan idéntico, sospecha del manejo del raw body, no del secret.

El diagnóstico en 60 segundos

  1. 1. El secret coincide con el modotest, live y stripe listen tienen cada uno su propio whsec_
  2. 2. Los bytes crudos llegan a constructEventloguea Buffer.isBuffer(req.body) — debe ser true, no un objeto parseado
  3. 3. Revisa proxies y middlewareun gateway o logger delante puede recodificar el body
Aísla la causa antes de tocar código: secret, body, infraestructura.

Causa 1 — secret de endpoint equivocado o cruzado

Cada endpoint de webhook tiene su propio signing secret (whsec_...). Y aquí viene la trampa que genera más falsa confianza:

  • Test y live se firman con secrets DIFERENTES. Copiaste el secret de test a producción “porque funcionaba en pruebas” y ahora falla silenciosamente. Clásico.
  • stripe listen imprime un TERCER whsec_, distinto, solo válido para los eventos que reenvía el CLI. Sirve para tu loop local, no para nada más.

El bug más común que veo: una sola env var STRIPE_WEBHOOK_SECRET compartida entre local, staging y producción. No puede funcionar en los tres a la vez porque cada modo usa un secret distinto.

El fix es de un minuto: ve al Dashboard, abre el endpoint exacto del que vienen los eventos, revela su signing secret y confírmalo contra la env var de ese modo específico. Un secret por endpoint, un secret por modo.

# El whsec_ que imprime stripe listen NO es el del Dashboard.
stripe listen --forward-to localhost:3000/api/webhooks
# > Ready! Your webhook signing secret is whsec_xxxx (solo para este CLI)

Causa 2 — el body fue modificado antes de verificar

Esta es la causa número uno real. constructEvent necesita los bytes UTF-8 exactos que Stripe envió. Cualquier JSON.parse y re-serialización, cualquier cambio de whitespace, cualquier reordenamiento de keys rompe el HMAC. No importa que el JSON signifique lo mismo: la firma es sobre los bytes, no sobre el significado.

El problema casi siempre es que un body parser corrió antes que tu handler y convirtió los bytes en un objeto. Aquí está el fix por stack.

Express — registra express.raw en la ruta del webhook y asegúrate de que express.json() NO corra antes:

import express from "express";
import Stripe from "stripe";

const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;

// raw SOLO en esta ruta; express.json() global no debe tocarla
app.post(
  "/webhooks/stripe",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    let event;
    try {
      // req.body es un Buffer crudo, exactamente lo que Stripe firmó
      event = stripe.webhooks.constructEvent(
        req.body,
        req.headers["stripe-signature"],
        endpointSecret
      );
    } catch (err) {
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    // Idempotencia: encola el trabajo de forma duradera usando event.id
    // como unique key. NO marques "procesado" en memoria antes de encolar:
    // si el proceso muere, perderías el evento sin que Stripe reintente.
    await encolarEvento(event); // inserta con event.id como unique key

    res.status(200).json({ received: true }); // 2xx rápido; el worker hace lo pesado
  }
);

Next.js App Router — lee await req.text(), nunca req.json():

// app/api/webhooks/stripe/route.js
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;

export async function POST(req) {
  const body = await req.text(); // string crudo, sin parsear
  const sig = req.headers.get("stripe-signature");

  let event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, endpointSecret);
  } catch (err) {
    return new Response(`Webhook Error: ${err.message}`, { status: 400 });
  }

  // dedupe por event.id antes de procesar
  return Response.json({ received: true, id: event.id });
}

FastAPI — toma los bytes con await request.body():

from fastapi import FastAPI, Request, Header, HTTPException
import stripe, os

app = FastAPI()
endpoint_secret = os.environ["STRIPE_WEBHOOK_SECRET"]

@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request,
                         stripe_signature: str = Header(None)):
    payload = await request.body()  # bytes crudos, sin tocar
    try:
        event = stripe.Webhook.construct_event(
            payload, stripe_signature, endpoint_secret
        )
    except ValueError:
        raise HTTPException(status_code=400, detail="Invalid payload")
    except stripe.error.SignatureVerificationError:
        raise HTTPException(status_code=400, detail="Invalid signature")

    # dedupe por event["id"], luego procesa async
    return {"received": True}

Para completar el mapa de stacks: en Next.js Pages Router exporta export const config = { api: { bodyParser: false } } y lee el stream crudo; en Flask usa request.get_data() (crudo), nunca request.json. El principio es idéntico en los cuatro: que nadie parsee el body antes que constructEvent.

Y ojo: esta misma disciplina de raw body aplica fuera de Stripe. En integrar Conekta con OXXO, SPEI y webhooks la firma es RSA en vez de HMAC, pero el pecado capital es el mismo: si parseas el body antes de verificar, la firma no valida.

Por qué uno pasa y el otro truena

Raw body intacto

  • express.raw / req.text() / request.body()
  • Los bytes son idénticos a los que Stripe firmó
  • HMAC calculado coincide con v1=
  • constructEvent devuelve el evento

Body parseado y reserializado

  • express.json() o req.json() corrió antes
  • Whitespace y orden de keys cambiaron
  • HMAC ya no coincide aunque el JSON 'sea igual'
  • No signatures found matching...
El HMAC es sobre los bytes exactos, no sobre el significado del JSON.

Causa 3 — un proxy o middleware recodificó el body

Aquí está el caso traicionero que la doc apenas menciona: tu código de aplicación es correcto, pero algo upstream mutó los bytes antes de que llegaran a ti.

Los sospechosos habituales:

  • AWS API Gateway con su manejo de base64 cuando no configuras el binary media type (confirma la config actual de binary/content-handling en la consola de API Gateway, que cambia con el tiempo).
  • Cloudflare o Nginx con transformaciones de body o reescrituras.
  • Middleware de request-logging que hace JSON.parse para loguear “bonito” y luego reenvía el body ya re-serializado.

Cómo confirmarlo: compara la longitud en bytes (o un hash) del body que ve tu handler contra el payload que Stripe muestra en el Dashboard para ese evento. Si los tamaños no coinciden, algo en el camino lo está tocando.

El fix: excluye la ruta del webhook de cualquier middleware de parsing o logging, y deja pasar los bytes intactos. En infra serverless, revisa la config de binary/raw passthrough de tu plataforma.

Lo que la doc omite: idempotencia y protección contra replay

Aquí está el diferenciador. Una vez que la firma valida, la doc de signature se calla, pero quedan dos cosas que en producción te muerden:

Idempotencia. Stripe puede entregar el mismo evento más de una vez —reintentos, redes raras, lo que sea—. Si tu handler le activa el plan Pro al usuario o le cobra cada vez que llega un evento, un duplicado te cobra dos veces o le da el acceso dos veces. La regla: deduplica por event.id, persiste los IDs ya procesados, y haz tus handlers idempotentes. Para esto no uses un Set en memoria en producción: usa una tabla con event.id como unique key (o Redis), de modo que un reintento de Stripe reactive el trabajo si la primera pasada falló, en vez de quedar marcado como hecho sin haberse completado.

Protección contra replay. constructEvent valida el timestamp t= del header con una tolerancia por defecto de 5 minutos (300s) y rechaza eventos fuera de esa ventana. Eso es lo que impide que alguien capture un payload firmado válido y te lo reenvíe mañana. Es configurable, pero nunca lo pongas en 0 —matarías la verificación de tiempo y abrirías la puerta a replays.

Y la disciplina operativa que une todo: responde 2xx rápido y procesa async. Si tu handler tarda porque manda emails, llama a otra API o hace queries pesadas, Stripe interpreta el timeout como fallo y reintenta —generándote justo los duplicados de los que hablábamos—. Verifica, deduplica, responde 200, encola. El trabajo pesado va en un worker.

Sobre la disciplina de confiar solo en el evento verificado: nunca le des acceso por un redirect del navegador. El usuario puede cerrar la pestaña, o alguien puede falsificar la URL de éxito. El webhook firmado es la fuente de verdad; cuando llega, vuelve a consultar el recurso en la API de Stripe para confirmar su status antes de otorgar o revocar acceso. Por qué prefiero esto al polling lo explico en webhooks vs polling vs API. Los eventos concretos que manejas una vez que la firma valida —pagos fallidos, dunning— los desarmo en recuperar pagos fallidos con dunning. La guía oficial de dedupe y async está en Stripe webhooks.

Las tres causas de un vistazo

Causa 1Secret equivocado o cruzado (test ≠ live ≠ stripe listen)
Causa 2Body parseado o reserializado antes de constructEvent (la #1)
Causa 3Proxy, gateway o middleware que recodifica los bytes
Cuando ves 'No signatures found matching...', es una de estas.

Preguntas frecuentes

¿Por qué funciona en el CLI pero falla en producción? Porque stripe listen usa su propio whsec_, distinto del secret del endpoint de producción. Cada modo y cada endpoint tiene el suyo.

¿Puedo hacer JSON.parse y re-stringify a una forma canónica? No. El HMAC es sobre los bytes exactos que Stripe envió, no sobre el significado del JSON. Cualquier reserialización cambia los bytes y rompe la firma, aunque el objeto “sea igual”.

¿Importa el orden de las firmas v1? No. Stripe puede mandar varias firmas en el header; basta con que tu cálculo coincida con cualquiera válida para que pase.

¿Qué hace el timestamp t=? También está firmado, y constructEvent lo rechaza si cae fuera de la tolerancia de 5 minutos. Esa es la protección contra replay.

¿Necesito un secret distinto por endpoint? Sí. Cada endpoint de webhook tiene su propio whsec_. No los mezcles.

El flujo de webhook en producción

Llega el eventocon header Stripe-Signature (t= y v1=)
Verifica sobre raw bodyconstructEvent con los bytes exactos y el secret correcto
Deduplica por event.idsi ya lo procesaste, responde 200 y termina
Responde 2xx rápidopara no disparar reintentos de Stripe
Procesa asyncworker/queue; confirma el recurso en la API antes de otorgar acceso
Verifica, deduplica, responde rápido, procesa async.

En resumen

El secret correcto para el modo correcto, más los bytes crudos intactos hasta constructEvent, es igual a firma que valida. Esa es la solución al 99% de los No signatures found matching the expected signature for payload.

De ahí, la versión grado-producción: deduplica por event.id y responde 2xx rápido procesando async. Y la regla que nunca cambia —ni en Stripe ni en Conekta—: el evento verificado es lo único en lo que confías. Ni un redirect, ni un parámetro de URL, ni un campo del cliente. Solo el evento firmado, confirmado contra la API.