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_3MtwBwLkdIt— Unix timestamp do envio. Anti-replay (max 5min).v1— HMAC-SHA256 de${t}.${rawBody}usandowhsec_*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:
| Tentativa | Atraso | Acumulado |
|---|---|---|
| 1 | imediata | 0s |
| 2 | 5s | 5s |
| 3 | 30s | 35s |
| 4 | 5min | ~6min |
| 5 | 1h | ~1h |
| 6 | 6h | ~7h |
| 7 | 24h | ~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_SECRETQuando 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.idcomo dedup key numa tabelaprocessed_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.
Atualizado em