Webhooks

Verificando a assinatura

Toda entrega de webhook vem assinada. Verificar a assinatura garante que o evento partiu da Verdin e não foi adulterado no caminho — nunca confie num webhook sem checar.

O cabeçalho verdin-signature

Cada requisição traz o cabeçalho verdin-signature com dois campos: t (timestamp Unix da assinatura) e v1 (o HMAC-SHA256 em hexadecimal):

Cabeçalho
verdin-signature: t=1751630711,v1=3a5f...c9d2

O v1 é o HMAC-SHA256 da string <t>.<body> (o timestamp, um ponto, e o corpo bruto da requisição), usando o secret do endpoint como chave.

Use o corpo BRUTO

A assinatura cobre os bytes exatos recebidos. Se você reserializar o JSON (parse + stringify) antes de verificar, a assinatura não vai bater. Capture o corpo cru — em Express, com express.raw().

Verificando em Node.js

Recalcule o HMAC com o seu secret e compare com o v1 recebido usando timingSafeEqual (comparação em tempo constante):

verifyVerdinSignature.js
import { createHmac, timingSafeEqual } from "node:crypto";

/**
 * Verifica a assinatura de um webhook Verdin.
 * @param rawBody  corpo BRUTO da requisição (string ou Buffer) — sem reparse
 * @param header   valor do cabeçalho "verdin-signature"
 * @param secret   secret do endpoint (mostrado no painel)
 * @param toleranceSec  janela de tolerância do timestamp (padrão 5 min)
 */
export function verifyVerdinSignature(
  rawBody,
  header,
  secret,
  toleranceSec = 300,
) {
  // 1. separe "t=<unix>,v1=<hmac>"
  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.split("=").map((s) => s.trim())),
  );
  const timestamp = Number(parts.t);
  const signature = parts.v1;
  if (!Number.isFinite(timestamp) || !signature) return false;

  // 2. (opcional) rejeite eventos muito antigos — protege contra replay
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > toleranceSec) return false;

  // 3. recalcule o HMAC de "<t>.<body>" com o secret do endpoint
  const body = typeof rawBody === "string" ? rawBody : rawBody.toString("utf8");
  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${body}`)
    .digest("hex");

  // 4. compare em tempo constante (evita timing attack)
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(signature, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

Usando no seu handler

Handler Express
app.post(
  "/webhooks/verdin",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const ok = verifyVerdinSignature(
      req.body, // Buffer bruto
      req.header("verdin-signature") ?? "",
      process.env.VERDIN_WEBHOOK_SECRET,
    );
    if (!ok) return res.sendStatus(400); // assinatura inválida

    const evt = JSON.parse(req.body.toString("utf8"));
    // ... processe evt de forma idempotente
    res.sendStatus(200);
  },
);

Checklist de segurança

  • Compare a assinatura com timingSafeEqual, nunca com ===.
  • Rejeite eventos com timestamp fora de uma janela de tolerância (ex.: 5 minutos) para mitigar replay.
  • Guarde o secret em variável de ambiente; rotacione se suspeitar de vazamento.
  • Só então confie no data — e processe de forma idempotente.