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 podem vazar por logs de proxy, screenshots,
histórico de navegador e tickets de suporte. 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 que a
requisição veio da InfraIO Pay.
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.
Para mais segurança (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 não são retentados. Para 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. Assine timestamp + "." + body no lugar. |
| Header totalmente ausente | O endpoint está registrado para um ambiente diferente. Endpoints de modo de teste só recebem eventos environment=test. |