Skip to main content

Segurança dos Webhooks

Visão Geral

Todos os webhooks que a 3X Pay envia para a URL de notificação configurada na sua conta são assinados criptograficamente com HMAC-SHA256. A assinatura permite que você confirme, do seu lado, que a requisição:
  1. Foi realmente enviada pela 3X Pay (autenticidade)
  2. Não foi alterada no caminho (integridade)
  3. Não é uma reentrega maliciosa de uma notificação antiga (proteção contra replay)
A URL de webhook é pública: qualquer pessoa que a descubra pode enviar requisições forjadas para ela. Sempre valide a assinatura antes de processar o payload — nunca confie no corpo de um webhook sem verificar o X-Webhook-Signature.

Chave de assinatura

O segredo usado para gerar a assinatura é o seu api_secret — a mesma credencial usada para autenticar as chamadas à API. Você a encontra no painel da 3X Pay em Integrações > Credenciais de API (veja Credenciais de API).
Trate o api_secret como uma senha. Ele assina os seus webhooks e autentica a sua API. Em caso de vazamento, revogue e gere novas credenciais imediatamente no painel.

Headers de segurança

Toda requisição de webhook inclui os seguintes headers:
HeaderExemploDescrição
X-Webhook-Signaturesha256=9f86d081884c7d65...Assinatura HMAC-SHA256 em hexadecimal, prefixada por sha256=.
X-Webhook-Timestamp1747526400Momento do envio (Unix timestamp, em segundos). Usado no cálculo da assinatura.
X-Webhook-Event-Idevt_10293Identificador único do evento. Estável entre retentativas — use para idempotência.
X-Webhook-Attempt1Número da tentativa de entrega (começa em 1 e incrementa a cada retentativa).

Como a assinatura é gerada

A 3X Pay monta uma string chamada signed_payload e calcula o HMAC-SHA256 dela usando o seu api_secret como chave:
signed_payload = X-Webhook-Timestamp + "." + corpo_bruto_da_requisição

assinatura = HMAC_SHA256(signed_payload, api_secret)  // resultado em hexadecimal
O valor enviado em X-Webhook-Signature é sha256= + essa assinatura em hexadecimal.
Use o corpo bruto (raw body) da requisição. A assinatura é calculada sobre os bytes exatos do JSON enviado. Se você desserializar o JSON e serializá-lo novamente antes de calcular o HMAC, a ordem das chaves e os espaços podem mudar e a assinatura não vai bater. Capture o corpo cru antes de qualquer parse.

Como validar (passo a passo)

1

Capture o corpo bruto

Leia o corpo da requisição como string/bytes antes de fazer o parse do JSON.
2

Verifique o timestamp (anti-replay)

Rejeite a requisição se X-Webhook-Timestamp estiver fora de uma janela de tolerância (recomendado: ±5 minutos / 300 segundos) em relação ao horário atual.
3

Recalcule o HMAC

Monte signed_payload = timestamp + "." + corpo_bruto e calcule HMAC_SHA256(signed_payload, api_secret) em hexadecimal.
4

Compare em tempo constante

Compare o valor calculado com o recebido em X-Webhook-Signature (sem o prefixo sha256=) usando uma função de comparação em tempo constante (timingSafeEqual, hmac.compare_digest, hash_equals). Nunca use == simples.
5

Garanta idempotência

Use o X-Webhook-Event-Id para descartar entregas duplicadas (retentativas reenviam o mesmo Event-Id). Só então processe o payload.

Exemplos de implementação

const crypto = require('crypto');

// IMPORTANTE: capture o corpo BRUTO. Ex.:
// app.use('/webhook', express.raw({ type: 'application/json' }));

function verificarWebhook(req, apiSecret) {
  const header = req.headers['x-webhook-signature']; // "sha256=<hex>"
  const timestamp = req.headers['x-webhook-timestamp'];
  if (!header || !timestamp) return false;

  // 1. Anti-replay: rejeita timestamps fora de ±5 min
  const agora = Math.floor(Date.now() / 1000);
  if (Math.abs(agora - Number(timestamp)) > 300) return false;

  // 2. Recalcula o HMAC sobre `${timestamp}.${rawBody}`
  const rawBody = req.body.toString('utf8'); // Buffer -> string crua
  const signedPayload = `${timestamp}.${rawBody}`;
  const esperado = crypto
    .createHmac('sha256', apiSecret)
    .update(signedPayload)
    .digest('hex');

  // 3. Comparação em tempo constante
  const recebido = header.replace(/^sha256=/i, '');
  const a = Buffer.from(esperado, 'hex');
  const b = Buffer.from(recebido, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post('/webhook', (req, res) => {
  if (!verificarWebhook(req, process.env.API_SECRET)) {
    return res.status(401).send('assinatura inválida');
  }
  const evento = JSON.parse(req.body.toString('utf8'));
  // ... idempotência via req.headers['x-webhook-event-id'] e processamento
  res.status(200).send('OK');
});

Idempotência e retentativas

  • Se a sua aplicação não responder com HTTP 2xx, a 3X Pay reenvia o webhook automaticamente: até 5 tentativas, com backoff exponencial (intervalo inicial de ~5 segundos).
  • Todas as tentativas do mesmo evento carregam o mesmo X-Webhook-Event-Id. Persista esse identificador e ignore eventos já processados para evitar processamento duplicado (ex.: creditar o mesmo pagamento duas vezes).
  • A cada tentativa o X-Webhook-Timestamp é renovado e a assinatura é recalculada. Por isso, valide a assinatura sempre com o X-Webhook-Timestamp da própria requisição recebida, não com um valor armazenado.

Rotação do api_secret

Ao gerar novas credenciais no painel, a assinatura dos webhooks pode continuar usando o segredo anterior por um curto período (até ~1 hora, devido a cache interno). Durante uma rotação planejada, aceite temporariamente assinaturas válidas com o segredo antigo ou com o novo até confirmar que apenas o novo está em uso.

Boas práticas de segurança

1
Valide a assinatura HMAC em todos os webhooks antes de processar o payload
2
Use sempre o corpo bruto da requisição no cálculo do HMAC
3
Faça a comparação da assinatura em tempo constante
4
Aplique a janela de tolerância de timestamp (±5 min) para mitigar replay
5
Garanta idempotência pelo X-Webhook-Event-Id
6
Sirva o endpoint de webhook somente via HTTPS
7
Nunca registre o api_secret em logs; mantenha-o em variável de ambiente / cofre de segredos
8
Responda rápido (200 OK) e processe o evento de forma assíncrona