Documentação

Verificar Assinatura

Como verificar a assinatura HMAC-SHA256 dos webhooks. Exemplos em curl, Node.js e Python com proteção anti-replay de ±5 minutos.

Formato da assinatura

O header X-MinhaCNH-Signature segue o formato Stripe-style:

t=<unix_timestamp>,v1=<hmac-sha256-hex>
  • t — timestamp Unix (segundos) do momento da entrega.
  • v1 — HMAC-SHA256 do payload com o segredo da organização.

A mensagem assinada é: <timestamp>.<corpo-json-raw>.

Janela anti-replay de ±5 minutos

Rejeite entregas cujo t esteja a mais de 5 minutos do horário atual (passado ou futuro). Isso protege contra ataques de replay de pacotes capturados.

Implementação

import crypto from 'node:crypto';

function verifyWebhookSignature(rawBody, signatureHeader, secret) {
  const parts = Object.fromEntries(
    signatureHeader.split(',').map(p => p.split('='))
  );
  const timestamp = parseInt(parts.t, 10);
  const receivedSig = parts.v1;

  // Anti-replay: rejeitar se delta > 5 minutos
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > 300) {
    throw new Error('Webhook timestamp out of tolerance window');
  }

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(receivedSig, 'hex'),
    Buffer.from(expected, 'hex')
  );
}

// Uso (Express) — note express.raw para preservar o corpo cru:
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['x-minhacnh-signature'];
  if (!verifyWebhookSignature(req.body.toString(), sig, process.env.WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }
  const event = JSON.parse(req.body);
  // processar event.status, event.reportId, event.diagnosis ...
  res.status(200).send('ok');
});

Webhooks do ambiente de teste

Entregas de relatórios criados com uma chave cnh_test_ chegam na mesma URL, com livemode: false no corpo e o header X-MinhaCNH-Livemode: false, mas não são assinadas com o secret da organização. A assinatura usa um secret de teste derivado dele:

secret_de_teste = HMAC-SHA256(secret, "sandbox")   // em hex, 64 caracteres

O secret de teste aparece nas configurações da API junto com o de produção, sempre que você gera um novo. Como ele sai do secret de produção por uma fórmula pública, quem tem o secret de produção consegue calcular o de teste — e qualquer pessoa com uma chave cnh_test_ consegue disparar eventos de teste, com o externalRef que quiser. Por isso a decisão sobre o ambiente vem sempre do corpo assinado, nunca do header.

O header não é autenticado

X-MinhaCNH-Livemode é só uma indicação de roteamento: fica fora da assinatura e pode ser forjado. Use o campo livemode do corpo, e só depois de verificar a assinatura.

Em produção: só o secret de produção

O servidor de produção verifica apenas com o secret de produção e, depois de verificar, recusa qualquer evento com livemode: false no corpo:

if (!verifyWebhookSignature(req.body.toString(), req.headers['x-minhacnh-signature'], process.env.WEBHOOK_SECRET)) {
  return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
if (event.livemode !== true) {
  return res.status(400).send('Test event rejected');
}

Eventos de teste falham na assinatura e nunca chegam ao JSON.parse; a checagem de livemode é a segunda barreira, e não custa nada.

Em desenvolvimento ou homologação: os dois secrets

Só num ambiente que deve receber eventos de teste, aceite as duas assinaturas e decida pelo livemode do corpo verificado — nunca pelo header:

// Apenas em dev/homologação. Nunca em produção.
const testSecret = crypto.createHmac('sha256', process.env.WEBHOOK_SECRET)
  .update('sandbox')
  .digest('hex');

const signature = req.headers['x-minhacnh-signature'];
const signedLive = verifyWebhookSignature(req.body.toString(), signature, process.env.WEBHOOK_SECRET);
const signedTest = !signedLive && verifyWebhookSignature(req.body.toString(), signature, testSecret);
if (!signedLive && !signedTest) {
  return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
// Assinado com o secret de teste precisa dizer livemode: false no corpo, e vice-versa.
if (event.livemode !== signedLive) {
  return res.status(400).send('Environment mismatch');
}

Usar X-MinhaCNH-Delivery para idempotência no receptor

O header X-MinhaCNH-Delivery contém um id único por entrega. Armazene ids já processados para evitar efeitos duplicados em caso de retry:

const deliveryId = req.headers['x-minhacnh-delivery'];
if (await isAlreadyProcessed(deliveryId)) {
  return res.status(200).send('already processed');
}
await markAsProcessed(deliveryId);
// ... processar evento