Verificar a assinatura dos webhooks
O seu endereço de webhook é público: qualquer um que o descubra pode enviar um POST para ele. A
assinatura é o que separa o evento que a iPORTO enviou de um que alguém montou.
Antes de começar
Seção intitulada “Antes de começar”- O segredo de assinatura, gerado na tela de configuração do webhook com o botão Gerar novo segredo. Ele aparece uma única vez.
- Acesso ao corpo cru da requisição no seu framework — o texto como ele chegou, antes de qualquer interpretação de JSON.
Como a assinatura é montada
Seção intitulada “Como a assinatura é montada”Todo envio leva o cabeçalho:
X-iPORTO-Signature: t=1760313600,v1=5a6f...c3té o instante do envio em segundos (epoch);v1é oHMAC-SHA256de"{t}.{corpo cru}", com o seu segredo como chave, em hexadecimal.
A conferência tem três partes: recalcular o HMAC, comparar com o que chegou, e recusar envio
antigo pela diferença entre t e a hora atual.
function webhookAssinaturaValida(string $header, string $secret, string $body, int $tolerancia = 300): bool{ $t = null; $assinaturas = [];
foreach (explode(',', $header) as $par) { [$chave, $valor] = array_pad(explode('=', trim($par), 2), 2, null);
if ($chave === 't') { $t = $valor; }
// ⚠️ LISTA, não um valor só: durante a rotação o cabeçalho traz DOIS `v1`. if ($chave === 'v1') { $assinaturas[] = $valor; } }
if ($t === null || $assinaturas === []) { return false; }
// Recusa envio antigo: sem isto, quem capturar um envio pode reenviá-lo depois. if (abs(time() - (int) $t) > $tolerancia) { return false; }
$esperado = hash_hmac('sha256', $t.'.'.$body, $secret);
foreach ($assinaturas as $candidata) { // `hash_equals` e não `===`: comparação de tempo constante. if (is_string($candidata) && hash_equals($esperado, $candidata)) { return true; } }
return false;}No Laravel, o corpo cru é $request->getContent().
Node.js
Seção intitulada “Node.js”const crypto = require('crypto');
function webhookAssinaturaValida(header, secret, body, tolerancia = 300) { let t = null; const assinaturas = [];
for (const par of header.split(',')) { const [chave, valor] = par.trim().split('='); if (chave === 't') t = valor; // ⚠️ LISTA, não um valor só: durante a rotação o cabeçalho traz DOIS `v1`. if (chave === 'v1') assinaturas.push(valor); }
if (!t || assinaturas.length === 0) return false; if (Math.abs(Date.now() / 1000 - Number(t)) > tolerancia) return false;
const esperado = crypto.createHmac('sha256', secret).update(`${t}.${body}`).digest('hex');
return assinaturas.some((candidata) => { const a = Buffer.from(esperado); const b = Buffer.from(candidata);
// `timingSafeEqual` exige tamanhos iguais; diferente já é inválido. return a.length === b.length && crypto.timingSafeEqual(a, b); });}No Express, use express.raw({ type: 'application/json' }) para ter o corpo cru.
Trocar o segredo sem perder evento
Seção intitulada “Trocar o segredo sem perder evento”- Na tela de configuração, clique em Gerar novo segredo e copie o valor.
- Publique o segredo novo no seu sistema, mantendo o anterior aceito — o código acima já faz
isso, porque compara contra todos os
v1do cabeçalho. - Depois de 24 horas, o anterior deixa de ser enviado e você pode removê-lo da sua configuração.
Se der errado
Seção intitulada “Se der errado”| Sintoma | Causa provável | O que fazer |
|---|---|---|
| Nenhuma assinatura confere, em todos os eventos | O corpo foi interpretado e serializado de novo antes do cálculo | Use o corpo cru, como ele chegou |
| Todos os eventos passam a falhar logo depois de girar o segredo | O verificador lê um v1 só, e pegou o anterior |
Trate v1 como lista e aceite se qualquer um conferir |
| Falha intermitente, sem padrão | Relógio do seu servidor fora de hora, contra a tolerância de 5 minutos | Sincronize o relógio por NTP |
O cabeçalho X-iPORTO-Signature não chega |
O endereço foi configurado antes de existir segredo na conta | Clique em Gerar novo segredo na tela de configuração |
| A assinatura confere, mas o evento parece repetido | É retentativa, e isso é esperado | Descarte repetição pelo X-iPORTO-Delivery — ver Receber eventos por webhook |
