Pular para o conteúdo

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.

  • 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.

Todo envio leva o cabeçalho:

X-iPORTO-Signature: t=1760313600,v1=5a6f...c3
  • t é o instante do envio em segundos (epoch);
  • v1 é o HMAC-SHA256 de "{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().

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.

  1. Na tela de configuração, clique em Gerar novo segredo e copie o valor.
  2. Publique o segredo novo no seu sistema, mantendo o anterior aceito — o código acima já faz isso, porque compara contra todos os v1 do cabeçalho.
  3. Depois de 24 horas, o anterior deixa de ser enviado e você pode removê-lo da sua configuração.
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