Verificação de assinatura de webhook da Stripe falhou — descubra por quê
O constructEvent da Stripe lança No signatures found matching the expected signature for payload e você não faz ideia de qual de uma dúzia de coisas deu errado. Esta página faz uma coisa só: pega os três artefatos que a Stripe te entrega — o secret whsec_, o corpo bruto exato e o cabeçalho Stripe-Signature —, recalcula o HMAC e aponta a única causa que realmente quebrou a verificação.
Resumindo: 9 em cada 10 vezes é o corpo bruto (o seu framework re-serializou o JSON antes da verificação) ou o signing secret errado (o whsec_ da CLI em vez do secret do endpoint). O depurador abaixo distingue os dois com uma única colagem.
Depure a sua assinatura da Stripe
A Stripe vem selecionada por padrão. O HMAC é calculado no seu navegador com WebCrypto — o seu whsec_ nunca sai da aba.
Anatomia do cabeçalho Stripe-Signature
Todo webhook da Stripe chega com um cabeçalho parecido com este. Cada campo separado por vírgula tem uma função:
Stripe-Signature: t=1614556800, v1=5257a869e7ecebeda32affa62cdca3fa51cad7414a563a8c6e3c4b41c2b7f2c8, v0=6ffbb59b2300aae63f272406069a9788598b792a944a07aba816edb039989a39
| Anatomia do cabeçalho Stripe-Signature | |
|---|---|
| t= | Timestamp Unix (em segundos) de quando a Stripe gerou a assinatura. Faz parte do que é assinado — e é a referência da checagem de replay de 5 minutos. |
| v1= | A assinatura que você realmente verifica: HMAC-SHA256 da string t + "." + rawBody, em hex, com a chave whsec_ do seu endpoint. É o único esquema que o constructEvent verifica. |
| v0= | Um esquema de teste legado. A Stripe ainda o envia, mas a biblioteca o ignora na verificação — não tente comparar com v0. |
| , (vírgula) | Durante uma rotação de secret, a Stripe assina com o secret antigo e com o novo, então você pode ver dois valores v1=. O constructEvent passa se qualquer um deles bater. |
A string assinada é `${t}.${rawBody}` — não o corpo sozinho. Esqueça o prefixo t. em um verificador feito à mão e toda assinatura falha, mesmo com o secret certo.
As 6 causas raiz, na ordem em que verificar
Ordenadas pela frequência com que são as culpadas. Cada uma tem um sinal que a distingue das outras.
| # | Causa | Sintoma característico | Correção |
|---|---|---|---|
| 1 | O corpo não são os bytes brutos | Falha com eventos reais, mas o HMAC calculado chega perto; passa se você colar a string bruta exata no depurador acima. | Passe ao constructEvent o corpo da requisição intacto, antes de qualquer parser JSON tocar nele. |
| 2 | Signing secret errado | Todo evento falha, HMAC nem perto do v1. Muitas vezes é o whsec_ da CLI usado contra o tráfego do dashboard. | Copie o secret da página do endpoint no Dashboard (Developers → Webhooks), não do stripe listen. |
| 3 | Um middleware parseou o corpo antes | Funciona com stripe trigger mas dá 400 em produção; o app.use(express.json()) do Express roda antes da sua rota. | Monte express.raw({type:'application/json'}) apenas na rota do webhook, acima do parser JSON global. |
| 4 | Secret do modo teste no modo live (ou vice-versa) | A assinatura só falha em pagamentos reais, passa nos eventos de teste. O whsec_ parece certo, mas pertence ao outro modo. | Use o whsec_ do endpoint live para o tráfego live; teste e live têm cada um o seu. |
| 5 | Cabeçalho ausente ou truncado | sig é undefined ou vazio; um proxy ou CDN removeu o Stripe-Signature ou o passou para minúsculas. | Leia req.headers['stripe-signature'] (em minúsculas no Node) e garanta que o seu proxy o repasse. |
| 6 | Secret do endpoint errado | Um endpoint verifica, outro dá 400 — você reutilizou um único whsec_ em duas URLs de endpoint. | Cada endpoint tem o seu próprio signing secret. Associe o secret à URL que a Stripe está realmente chamando. |
A correção do corpo bruto, framework por framework
A causa nº 1 é quase sempre esta: o seu framework web leu, parseou e re-serializou o JSON antes de o constructEvent vê-lo. JSON re-serializado tem espaçamento diferente, então o HMAC deixa de bater. Veja como entregar a cada framework os bytes intactos.
| Framework | Por que o corpo é alterado | A correção |
|---|---|---|
| Express | O express.json() parseia e descarta o texto bruto no app inteiro. | express.raw({type:'application/json'}) na rota do webhook, antes do express.json(). |
| Next.js App Router | Os route handlers não parseiam o corpo para você. | const body = await request.text() — essa string já é bruta. |
| Next.js Pages Router | O bodyParser embutido reescreve o corpo. | export const config = { api: { bodyParser: false } }, depois faça buffer do stream. |
| Flask | request.json / request.form consomem e recodificam o stream. | payload = request.get_data() — bytes, antes de tocar em .json. |
| Django | Acessar request.POST trava o corpo. | request.body — leia-o primeiro, antes de qualquer acesso ao formulário. |
| AWS Lambda / API Gateway | O API Gateway pode codificar em base64, ou um mapping template pode reescrever o corpo. | Use a integração proxy do Lambda; se event.isBase64Encoded, decodifique event.body antes de verificar. |
// A rota do webhook PRECISA vir antes do 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 o restoexport async function POST(req: Request) {
const body = await req.text(); // 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 });
}Cole os bytes exatos no depurador acima: se a assinatura só bate depois de remover uma quebra de linha final ou um campo recodificado, o corpo foi alterado no caminho — o problema não é o seu secret.
Modo teste vs modo live: o secret que parece certo, mas não é
A Stripe te entrega três valores whsec_ diferentes, e é fácil confundi-los. Primeiro, o secret do endpoint de teste, mostrado no Dashboard enquanto você está em modo teste. Segundo, o secret do endpoint live — um valor separado no mesmo endpoint passado para live. Terceiro, o secret da CLI que o stripe listen imprime, diferente de novo e válido apenas para os eventos encaminhados por aquela sessão. Os três produzem um HMAC de aparência válida, então o erro é o mesmo No signatures found genérico — nada diz modo trocado. O sinal: pagamentos de teste verificam, os reais dão 400. Correção: leia o secret do endpoint exato, no modo exato, que está recebendo o tráfego.
A janela de replay de 5 minutos que todo mundo esquece
O constructEvent não verifica só o HMAC — ele rejeita qualquer evento cujo timestamp t= tenha mais de 300 segundos, lançando Timestamp outside the tolerance zone. Isso pega quando você reenvia um evento capturado horas depois, ou quando o relógio do servidor saiu de sincronia. A assinatura é perfeitamente válida; o que falha é a idade. O depurador acima mostra a idade do timestamp para você distinguir um replay velho de uma assinatura realmente divergente. Se você precisa mesmo de uma janela maior, o constructEvent aceita um quarto argumento tolerance em segundos — mas corrija o relógio primeiro.
É o seu handler ou o seu secret? Gere um evento sabidamente válido
Quando o depurador diz que o secret está certo mas a produção continua dando 400, o bug está em como o seu handler lê o corpo. Isole-o: o nosso Signer gera um payload assinado no formato da Stripe com um secret que você controla, para você fazer POST de um evento garantidamente válido direto no seu handler local. Se passar, o seu código de verificação está certo e o problema é o secret ou o middleware. Se falhar, o handler está alterando o corpo — volte à tabela do corpo bruto acima.
Checklist de diagnóstico
Siga esta ordem na próxima vez que o constructEvent lançar um erro.
- Cole o whsec_, o corpo bruto e o Stripe-Signature que falham no depurador acima — ele aponta a causa antes de você continuar lendo.
- Confirme que o corpo que chega ao constructEvent é a string bruta intacta, não um objeto parseado e re-serializado.
- Copie o signing secret direto da página do endpoint no Dashboard — não do stripe listen, nem de outro endpoint.
- Verifique se você usa o secret live para o tráfego live e o secret de teste para o tráfego de teste.
- Registre req.headers['stripe-signature'] no log e confirme que ele está presente e não foi truncado por um proxy.
- Verifique se o timestamp t= do evento não tem mais de 5 minutos (replay velho ou relógio fora de sincronia).
Capture e reenvie o evento real que falha
Ainda travado? Aponte a Stripe para um Relay ou uma URL de captura do Webhook Toolkit, pegue a requisição exata que está falhando e inspecione o corpo bruto e os cabeçalhos byte a byte — depois reenvie-a ao seu handler quantas vezes precisar, sem esperar outro evento real.
Perguntas frequentes
Por que o meu webhook da Stripe funciona localmente mas não em produção?
Quase sempre é o corpo bruto. No dev local com stripe listen, a CLI encaminha os bytes intactos, mas o seu middleware de produção (express.json() do Express, um bodyParser do Pages Router, um mapping template do API Gateway) parseia e re-serializa o JSON antes da verificação. Passe ao constructEvent o corpo bruto da requisição e ele passa.
O que significa 'No signatures found matching the expected signature for payload'?
O HMAC que você calculou não bate com nenhum v1= enviado pela Stripe. Duas causas habituais: o corpo foi alterado antes da verificação, ou o signing secret está errado (muitas vezes o whsec_ da CLI em vez do secret do endpoint). Cole os três no depurador acima e ele diz qual.
Posso usar o mesmo signing secret no modo teste e no modo live?
Não. Cada endpoint tem um whsec_ separado para teste e para live, e a CLI imprime um terceiro. Um secret de teste gera um HMAC de aparência válida que ainda assim falha nos eventos live, com o mesmo erro genérico. Associe o secret ao modo que está recebendo o tráfego.
Como corrijo 'Timestamp outside the tolerance zone'?
A assinatura é válida, mas o t= do evento tem mais de 300 segundos — normalmente um replay de uma captura ou o relógio do servidor fora de sincronia. Corrija o relógio ou reenvie um evento novo. Em último caso, passe uma tolerance maior (em segundos) como quarto argumento do constructEvent.
O v0 do cabeçalho Stripe-Signature é usado na verificação?
Não. Só o v1= (HMAC-SHA256 de t.rawBody) é verificado pelo constructEvent. O v0= é um esquema de teste legado que a Stripe ainda emite, mas a biblioteca ignora. Se o seu verificador feito à mão compara com o v0, o bug é esse.
Este depurador de assinatura da Stripe envia o meu secret para algum lugar?
Não. O HMAC é calculado no seu navegador com WebCrypto. O seu whsec_, o corpo e o cabeçalho nunca saem da aba — nada é enviado, registrado ou armazenado. Desconecte da rede e ele continua funcionando.