docs

Webhooks

Verificação HMAC, retry com backoff exponencial, DLQ, replay manual e boas práticas.

Eventos são entregues via POST ao endpoint que você cadastrou em Integrações → Webhooks, assinados com HMAC-SHA256 e o whsec_* do endpoint.

Headers

Zhex-Signature: t=1714060000,v1=a3f2d1e8b...
Content-Type: application/json
Zhex-Event-Id: evt_3MtwBwLkdI
  • t — Unix timestamp do envio. Anti-replay (max 5min).
  • v1 — HMAC-SHA256 de ${t}.${rawBody} usando whsec_* como chave.
  • Zhex-Event-Id — ID único do evento. Use como dedup key.

Verificar assinatura (Node)

import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(payload: Buffer, header: string | undefined, secret: string) {
  if (!header) throw new Error('Missing Zhex-Signature');

  const parts = Object.fromEntries(
    header.split(',').map((kv) => kv.split('=')),
  ) as { t?: string; v1?: string };
  if (!parts.t || !parts.v1) throw new Error('Malformed header');

  const age = Math.abs(Date.now() / 1000 - parseInt(parts.t));
  if (age > 300) throw new Error('Timestamp too old (>5min)');

  const signed = `${parts.t}.${payload.toString('utf8')}`;
  const expected = createHmac('sha256', secret).update(signed).digest('hex');

  const a = Buffer.from(parts.v1, 'hex');
  const b = Buffer.from(expected, 'hex');
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    throw new Error('Signature mismatch');
  }
  return JSON.parse(payload.toString('utf8'));
}

app.post('/webhooks/zhex', express.raw({ type: 'application/json' }), (req, res) => {
  let event;
  try {
    event = verify(
      req.body,
      req.header('zhex-signature'),
      process.env.ZHEX_WEBHOOK_SECRET!,
    );
  } catch (err) {
    return res.status(400).send(`Invalid signature: ${(err as Error).message}`);
  }

  switch (event.type) {
    case 'payment_intent.succeeded':
      handleSuccess(event.data.object);
      break;
    case 'charge.dispute.created':
      handleDispute(event.data.object);
      break;
  }
  res.json({ received: true });
});

Usar timingSafeEqual é crítico — === em string vaza tempo e permite ataque de oráculo. Raw body (express.raw) também é obrigatório — express.json() muda os bytes e quebra a assinatura.

Retry e DLQ

A Zhex faz retry automático até 7 tentativas com backoff exponencial:

TentativaAtrasoAcumulado
1imediata0s
25s5s
330s35s
45min~6min
51h~1h
66h~7h
724h~31h

Após a 7ª, vai para DLQ. Você pode replayar manualmente:

curl -X POST https://prometheus.zhex.io/v1/webhook_endpoints/we_abc/deliveries/evt_3MtwBwLkdI/replay \
  -H "Authorization: Bearer $ZHEX_SECRET_KEY"

Ou via dashboard: Integrações → Webhooks → endpoint → Events → Resend.

O que conta como sucesso

  • HTTP 2xx em < 5s.
  • Resposta vazia ou JSON é OK.

O que dispara retry

  • HTTP 4xx (exceto 410 Gone) ou 5xx
  • Timeout (> 5s)
  • Connection error / DNS fail
  • TLS error

410 Gone (parar retry)

Retorne 410 Gone quando o evento é de recurso deletado ou fora de escopo:

case 'customer.deleted':
  if (!await db.customers.findById(event.data.object.id)) {
    return res.status(410).send('Customer no longer tracked');
  }

A Zhex marca o evento como aborted e não tenta de novo.

Idempotência de evento

Mesmo event.id pode chegar duas vezes (race em retry com sucesso parcial). Use event.id como dedup key:

async function processEvent(event) {
  const exists = await db.processed_events.findById(event.id);
  if (exists) return; // já tratado, skip

  await db.processed_events.create({ id: event.id, type: event.type });

  switch (event.type) { /* ... */ }
}

Tabela processed_events deve ter (id PRIMARY KEY, processed_at, type). Garbage collect a cada 90 dias.

Endpoints separados por ambiente

Cadastre um endpoint para test, outro para live com URLs diferentes:

https://meusite.com/webhooks/zhex/test    ← whsec_test_*
https://meusite.com/webhooks/zhex/live    ← whsec_live_*

Cada um com seu whsec_* próprio. Misturar é vetor de bug — um evento de test mode disparando lógica de produção.

Local development

Pra dev local, use ngrok ou cloudflared pra expor o localhost e cadastre um endpoint apontando pro tunnel:

# 1. Tunnel
ngrok http 3000
# → https://abc123.ngrok-free.app

# 2. Cadastra endpoint Zhex
curl https://prometheus.zhex.io/v1/webhook_endpoints \
  -H "Authorization: Bearer $ZHEX_TEST_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://abc123.ngrok-free.app/webhooks/zhex",
    "events": ["*"]
  }'
# → "secret": "whsec_test_..." → exporte como ZHEX_WEBHOOK_SECRET

Quando terminar a sessão, delete o endpoint pra não ficar recebendo eventos órfãos.

Boas práticas

  • Responda 2xx em < 5s. Trabalho pesado vai para fila (BullMQ, SQS). Senão a Zhex considera timeout.
  • Verifique a assinatura ANTES de qualquer outra coisa. Não logue body, não parseie JSON, não toque banco.
  • Use event.id como dedup key numa tabela processed_events.
  • Tolerância de timestamp = 5min anti-replay. Se rejeitar muito, sincronize NTP no seu servidor.
  • Endpoint dedicado. Não compartilhe path com webhook de outras integrações.
  • Monitore taxa de retry. Spike em retry = seu endpoint está lento ou caindo.
Esta página foi útil?

Atualizado em

Nesta página