Skip to Content
WebhooksVerificação de assinatura

Verificação de assinatura

Toda entrega de webhook inclui dois headers usados em conjunto:

X-Signature: sha256=9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b X-Timestamp: 1729536000

A string em hex depois de sha256= é HMAC-SHA256(secret, timestamp + "." + raw_body). O ponto é um byte literal; o timestamp é unix-segundos em ASCII.

Por que verificar

URLs de webhook vazam. Elas aparecem em logs de proxy, screenshots, histórico de navegador, tickets de suporte de parceiros. Sem uma checagem de assinatura, qualquer um que descobrir a sua URL pode fazer POST de um payment.settled falso e te enganar para entregar pedidos não pagos. A verificação prova criptograficamente que a requisição veio da InfraIO.

Incluir o timestamp dentro do payload assinado também te dá proteção contra replay: um atacante que captura uma entrega não consegue reenviá-la depois sem que a assinatura se torne detectavelmente velha.

O algoritmo

signed_payload = timestamp + "." + raw_body expected_header = "sha256=" + lowercase_hex( HMAC_SHA256(secret, signed_payload) ) constant_time_compare(expected_header, x_signature_header)

Depois confira que o timestamp está recente (tolerância típica: ±5 minutos).

Sempre passe os bytes brutos do body da requisição. Frameworks costumam fazer parse do JSON antes do seu handler rodar; a versão re-stringificada pode diferir do que a gente enviou (ordem de chaves, espaços em branco, formatação numérica), e o HMAC não vai bater. No Next.js App Router use await req.text() antes de JSON.parse. No Express, monte express.raw({ type: 'application/json' }) só na rota de webhook.

Implementações

lib/verify-infraio.ts
import { createHmac, timingSafeEqual } from "node:crypto"; const TOLERANCE_SECONDS = 5 * 60; export function verifyInfraIo({ body, signature, timestamp, secret, }: { body: string; // texto bruto — NÃO JSON parseado signature: string; // valor do header X-Signature timestamp: string; // valor do header X-Timestamp (unix segundos) secret: string; // whsec_… }): boolean { const ts = Number.parseInt(timestamp, 10); if (!Number.isFinite(ts)) return false; if (Math.abs(Math.floor(Date.now() / 1000) - ts) > TOLERANCE_SECONDS) { return false; // muito velho ou muito no futuro } const expected = "sha256=" + createHmac("sha256", secret) .update(`${timestamp}.${body}`) .digest("hex"); const a = Buffer.from(signature); const b = Buffer.from(expected); return a.length === b.length && timingSafeEqual(a, b); }

Proteção contra replay

O timestamp dentro do payload assinado é a primeira linha de defesa — um atacante que captura uma entrega não consegue reenviar depois que a sua janela de tolerância expira.

Cinto e suspensório (recomendado para eventos de alto valor como payment.settled):

  1. Deduplique em X-Delivery numa tabela com uma constraint de unicidade. Replays dentro da janela de tolerância viram no-ops — o seu handler retorna 200 sem fazer o trabalho duas vezes. É a mesma idempotência que você quer para retentativas legítimas. (X-Delivery é estável em todas as retentativas de uma entrega; o payload não traz um campo event_id.)
  2. Use a menor tolerância que a sua defasagem de relógio permite. ±5 minutos é o padrão recomendado e bate com o que a maioria das frotas sincronizadas via NTP consegue sustentar. Mais apertado tudo bem; abaixo de ±30 segundos você vai começar a rejeitar entregas legítimas em redes com NTP upstream lento.

Rotacionando um segredo

  1. Dashboard → Developers → Webhooks → [endpoint] → ⋮ → Rotate Secret.
  2. Um segredo novo é gerado e mostrado exatamente uma vez. Copie antes de fechar a caixa de diálogo.
  3. Atualize a sua env var e redeploye o seu verificador dentro de 24 horas.

Janela de tolerância (assinatura dupla)

Pelas 24 horas depois de uma rotação, toda entrega leva duas assinaturas:

X-Signature: sha256=<hmac(new_secret, ts + "." + body)> X-Signature-Prev: sha256=<hmac(prev_secret, ts + "." + body)> X-Timestamp: 1729536000

Um verificador rodando o segredo anterior combina com X-Signature-Prev; um verificador rodando o segredo novo combina com X-Signature. Qualquer um dos headers passando já é suficiente — o seu handler pode aceitar a entrega durante a migração sem segurar o deploy.

Depois que a janela de tolerância fecha, só X-Signature é enviado. O segredo anterior para de ser aceito e qualquer verificador ainda configurado com ele vai começar a rejeitar entregas — então termine o seu rollout dentro do orçamento de 24 horas.

Padrão sugerido de receptor

// Aceita qualquer das duas assinaturas durante uma janela de tolerância de rotação. const sig = req.headers["x-signature"] ?? ""; const sigPrev = req.headers["x-signature-prev"] ?? ""; const ok = verify(body, sig, ts, CURRENT_SECRET) || (PREV_SECRET && verify(body, sigPrev, ts, PREV_SECRET));

Você pode descartar o ramo X-Signature-Prev assim que a janela de tolerância no seu endpoint tiver expirado e você tiver removido o PREV_SECRET do seu env.

Revogação emergencial

Se um segredo vazou publicamente e você precisa invalidar o segredo anterior imediatamente — ou seja, você não quer que a sobreposição de 24 horas mantenha uma chave conhecidamente comprometida viva — rotacione duas vezes. A primeira rotação move o segredo vazado para o slot anterior; a segunda rotação empurra para fora do slot anterior (substituindo pela chave ainda-nova) para que o valor vazado não seja mais aceito.

Testando o seu wiring

No dashboard, abra Developers → Webhooks e clique em Send Test no endpoint que você quer verificar. A gente assina e faz POST de um envelope sintético para a URL de forma síncrona, e depois mostra o status HTTP, latência e um snippet de 512 bytes da sua resposta. Formato do payload:

{ "event_id": "<uuid>", "event_type": "webhook.test.ping", "created_at": "2026-05-17T12:00:00Z", "test": true, "data": { "merchant_id": "<your-merchant-id>", "webhook_id": "<endpoint-id>", "message": "Test ping from the merchant dashboard..." } }

O test ping usa o mesmo esquema de assinatura que entregas de produção, então um check verde nesse botão confirma que o seu verificador aceita eventos reais também. Test pings contornam a pipeline RMQ de retry — se você quer exercitar retries, dispare um evento real pelo fluxo de API relevante.

Falhas comuns

SintomaCausa provável
Sempre retorna false em devO body foi JSON-parseado antes do HMAC. Leia os bytes brutos primeiro.
Funcionou ontem, falha hojeVocê rotacionou o segredo mas a env var neste servidor ainda tem o antigo. Redeploye com o segredo novo.
Falha para eventos antigos, funciona para novosUma entrega ficou na fila antes da rotação; a assinatura usa o segredo antigo e o seu verificador não aceita mais. Espere o retry derrubar ou replaye pelo dashboard.
Erro de unidade no timestampConfira que você compara unix-segundos com unix-segundos. Date.now() em JS é em milissegundos — divida por 1000.
Funciona local, falha em prodUm proxy (Cloudflare, nginx) está descomprimindo, re-codificando ou retirando uma quebra de linha final. Inspecione os bytes que o seu handler vê.
Test ping diz 401 / signature mismatchO seu verificador está assinando só body (esquema pré-2026). Atualize para assinar timestamp + "." + body.
Header totalmente ausenteO endpoint está registrado para um ambiente diferente. Endpoints de modo de teste só recebem eventos environment=test.