Verificação de assinatura
Toda entrega de webhook inclui dois headers usados em conjunto:
X-Signature: sha256=9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b
X-Timestamp: 1729536000A 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
Node / 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):
- Deduplique em
X-Deliverynuma 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 campoevent_id.) - 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
- Dashboard → Developers → Webhooks → [endpoint] → ⋮ → Rotate Secret.
- Um segredo novo é gerado e mostrado exatamente uma vez. Copie antes de fechar a caixa de diálogo.
- 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: 1729536000Um 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
| Sintoma | Causa provável |
|---|---|
| Sempre retorna false em dev | O body foi JSON-parseado antes do HMAC. Leia os bytes brutos primeiro. |
| Funcionou ontem, falha hoje | Você rotacionou o segredo mas a env var neste servidor ainda tem o antigo. Redeploye com o segredo novo. |
| Falha para eventos antigos, funciona para novos | Uma 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 timestamp | Confira que você compara unix-segundos com unix-segundos. Date.now() em JS é em milissegundos — divida por 1000. |
| Funciona local, falha em prod | Um 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 mismatch | O seu verificador está assinando só body (esquema pré-2026). Atualize para assinar timestamp + "." + body. |
| Header totalmente ausente | O endpoint está registrado para um ambiente diferente. Endpoints de modo de teste só recebem eventos environment=test. |