Engenharia30 de julho de 20267 min de leitura

Seus testes de assinatura de webhook não provam nada

Se seu teste monta a assinatura com a fórmula que seu verificador confere, os dois podem estar errados e seguir verdes. Confira contra o SDK do provedor.

O QUE UM TESTE DE ASSINATURA QUE PASSA REALMENTE COBREseu fixture → seu verificadoros dois feitos da mesma suposiçãoa requisição realcorpo parseado · proxy · timestamp velho · chave rotacionadao teste nunca toca a camada que consome o stream antes de o seu código rodaro único teste que prova algo manda uma entrega assinada real pela stack real
O teste e o código testado costumam compartilhar a mesma suposição errada.

Aqui está um teste de assinatura de webhook que passa, parece responsável e não prova quase nada:

const ts  = Math.floor(Date.now() / 1000).toString();
const sig = await hmacHex(`${ts}.${body}`, secret, "SHA-256");
const headers = new Headers({ "stripe-signature": `t=${ts},v1=${sig}` });

expect(await verifySignature("stripe", headers, body, secret)).toBe(true);

A fixture é montada com a mesma fórmula que o verificador confere. Se você leu errado a documentação da Stripe quando escreveu o verificador (assinou a string errada, juntou com o delimitador errado, usou hex onde deveria ser base64), seu teste codifica o mesmo erro de leitura e os dois continuam verdes. Você não testou "verificamos assinaturas da Stripe". Você testou "concordamos com nós mesmos".

Tínhamos 70 testes exatamente assim, cobrindo 20 provedores. Todos passavam. Então fizemos a única pergunta que importa: uma assinatura produzida pelo próprio código do provedor passaria no nosso verificador?

O HMAC É SOBRE BYTES, NÃO SOBRE SIGNIFICADObytes recebidos{"id":"evt_1","n":2}req.text()hmacconfereparseado + serializado{ "n": 2, "id": "evt_1" }req.json()hmacnão conferemesmos dadosmesmos dados. outra ordem de chaves,outros espaços em branco, e o payloadparece perfeito nos seus logs.
A falha que a fixture não consegue reproduzir: seu framework já consumiu o stream, então os bytes de que você calcula o hash não são os bytes que foram assinados.

Faça a verificação cruzada com o SDK do provedor

Alguns provedores incluem uma rotina de assinatura no SDK oficial, normalmente exatamente para isso. A da Stripe é a função generateTestHeaderString; a classe Webhook do Svix tem o método .sign(). Se esses SDKs já são dependências suas, a verificação cruzada não custa nada:

import Stripe from "stripe";
import { Webhook } from "svix";
import { verifySignature } from "./index";

it("accepts a header generated by stripe-node", async () => {
  const stripe  = new Stripe("sk_test_" + "0".repeat(24));
  const payload = JSON.stringify({ id: "evt_1", type: "charge.succeeded" });
  const secret  = "whsec_" + "s".repeat(32);

  const header = stripe.webhooks.generateTestHeaderString({ payload, secret });

  expect(
    await verifySignature("stripe", new Headers({ "stripe-signature": header }), payload, secret)
  ).toBe(true);
});

it("accepts a signature generated by the svix SDK", async () => {
  const secret  = "whsec_" + Buffer.from("k".repeat(24)).toString("base64");
  const payload = JSON.stringify({ type: "email.received" });
  const msgId   = "msg_crosscheck";
  const ts      = new Date();

  const signature = new Webhook(secret).sign(msgId, ts, payload);
  const headers = new Headers({
    "svix-id": msgId,
    "svix-timestamp": Math.floor(ts.getTime() / 1000).toString(),
    "svix-signature": signature,
  });

  expect(await verifySignature("svix", headers, payload, secret)).toBe(true);
});

Agora a asserção é que concordamos com a Stripe, não que concordamos com nós mesmos. Adicione o caso negativo (assine um payload e depois verifique um corpo diferente) para saber que o teste consegue de fato falhar:

const header   = stripe.webhooks.generateTestHeaderString({ payload, secret });
const tampered = JSON.stringify({ id: "evt_1", amount: 999999 });

expect(
  await verifySignature("stripe", new Headers({ "stripe-signature": header }), tampered, secret)
).toBe(false);

Como o Clerk e o Resend entregam pela infraestrutura do Svix, uma verificação cruzada com o Svix cobre três provedores. Quatro dos nossos vinte verificadores agora têm prova baseada em SDK, e o mesmo trabalho de uma tarde se aplica a qualquer stack.

E os provedores sem rotina de assinatura pública?

Shopify, Discord, PayPal: a maioria dos provedores não oferece uma rotina de assinatura. Para eles, a única evidência honesta é uma entrega real capturada. Aponte um webhook para um endpoint que você controla, grave uma requisição assinada e congele a tripla (headers, body, secret) como fixture. Uma assinatura real reaproveitada no teste é a verdade de referência que nenhuma fixture montada por você consegue ser.

Até fazer isso, seja preciso consigo mesmo sobre o que seus testes mostram. Mantemos esse controle num comentário no fim do arquivo de testes: quais provedores são verificados por SDK, quais são só autoconsistentes e quais não têm nenhum teste positivo. Uma lista honesta com a qual você pode agir vale mais que uma suíte verde em que você não pode confiar.

O bug que essa mentalidade encontrou no mesmo dia

A mesma desconfiança vale para além dos testes unitários. Enquanto verificávamos de ponta a ponta nosso caminho do Resend contra a produção (não a suíte unitária, a coisa real em produção), enviamos três requisições para um endpoint com um segredo de assinatura configurado:

RequisiçãoEsperadoRecebido
Assinatura correta200200
Assinatura errada401401
Sem assinatura nenhuma401200

Uma requisição sem assinatura passou direto por um endpoint que parecia protegido. A causa era uma brecha de política, não um bug de criptografia. Os apps do AnyHook (uma URL de entrada para uma origem) com origem generic pulam a verificação de propósito ("só quero uma URL"), e configurar um segredo não tirava o app de generic. O segredo ficava lá sem verificar nada, enquanto a API respondia sucesso por tê-lo armazenado.

Nenhuma quantidade de testes unitários autoconsistentes encontra isso, porque cada unidade estava correta. A correção foi recusar de vez a configuração enganosa: definir um segredo de assinatura num app generic agora dá um 400 que diz qual campo definir no lugar.

O checklist

  1. Para cada provedor cujo SDK tem uma rotina de assinatura: verificação cruzada nos dois sentidos (a válida passa, a adulterada falha)
  2. Para o resto: capture uma entrega real assinada e congele-a como fixture
  3. Mantenha um registro visível de quais provedores têm qual nível de prova
  4. Teste o sistema em produção com a ausência de assinatura, não só com assinaturas erradas. As brechas moram na política, não na matemática
Todos os posts30 de julho de 2026 · 7 min

Pare de perder webhooks.

Troque uma URL. Tenha novas tentativas, um log de eventos e replay com um clique. Ou veja antes: crie uma conta, rode a simulação de queda de 30 segundos e veja um evento sobreviver ao seu servidor fora do ar. Não precisa de servidor.