Skip to main content
Seu endpoint de webhook é público — qualquer um poderia tentar enviar requisições falsas para ele. Por isso, valide a assinatura HMAC de todo evento antes de processá-lo.

Assinatura HMAC

Cada webhook é assinado com HMAC-SHA256 usando o signatureSecret que você recebeu ao cadastrar o webhook. A assinatura acompanha o evento no formato:
t=<timestamp_ms>,v1=<hmac_sha256_hex>
O HMAC é calculado sobre a string `${timestamp}.${corpo_raw}`. Para validar, recalcule a assinatura com o seu segredo e compare — usando uma comparação time-safe.
import crypto from "node:crypto"

export function validarAssinatura(header: string, secret: string, rawBody: string): boolean {
  const [tsPart, v1Part] = header.split(",")
  const timestamp = tsPart.split("=")[1]
  const recebida = v1Part.split("=")[1]

  const esperada = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex")

  const a = Buffer.from(esperada)
  const b = Buffer.from(recebida)
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}
Use sempre timingSafeEqual / hmac.compare_digest / hmac.Equal ao comparar assinaturas — nunca === ou ==. Comparações diretas são vulneráveis a timing attacks.
Calcule o HMAC sobre o corpo raw (a string exata recebida), antes de qualquer JSON.parse. Re-serializar o JSON pode reordenar campos e invalidar a assinatura.

Proteção contra replay

Compare o timestamp da assinatura com o horário atual e rejeite entregas muito antigas (ex.: mais de 5 minutos). Isso evita que um evento legítimo capturado seja reenviado mais tarde por um atacante.

Idempotência

A mesma entrega pode chegar mais de uma vez (retentativa ou reenvio). Registre o id do evento e descarte duplicatas antes de processar.
app.post("/webhooks/ephra", async (req, res) => {
  const evento = req.body

  // 1. valide a assinatura (HMAC) — veja acima
  // 2. idempotência:
  const jaProcessado = await db.eventos.findOne({ id: evento.id })
  if (jaProcessado) return res.sendStatus(200)

  if (evento.event === "transaction_paid" && evento.transaction?.status === "paid") {
    // libere o pedido referente a evento.transaction.id
  }

  await db.eventos.insert({ id: evento.id, processadoEm: new Date() })
  return res.sendStatus(200)
})

Retentativas

Se seu endpoint não responder 2xx, a Ephra reenvia o evento automaticamente:
  • Até 3 retentativas por entrega.
  • Backoff crescente: aproximadamente 8 min, 15 min e 30 min.
  • São retentadas apenas entregas das últimas 48 horas; depois disso, a entrega é marcada como failed.
  • Timeout de 60 segundos por tentativa.
Se o seu processamento for pesado, responda 200 imediatamente e processe em segundo plano (enfileire).

Checklist de segurança

  • Use HTTPS — nunca HTTP em produção.
  • Valide a assinatura HMAC do header com comparação time-safe.
  • Rejeite entregas com timestamp muito antigo (replay).
  • Responda 2xx somente após concluir (ou enfileirar) o processamento.
  • Implemente idempotência usando o id do evento.
  • Não valide o payload inteiro com schemas rígidos — campos novos não devem quebrar seu endpoint.
Para valores altos, confirme o estado real com GET /v1/transactions/{id} antes de liberar o pedido.