Webhooks
HMAC-SHA256 signing, retry com backoff exponencial, DLQ, replay e idempotência no consumer — o caminho seguro de receber eventos da Zhex.
Webhooks são o único caminho confiável para saber que algo aconteceu na Zhex. Polling é antipattern: aumenta custo de API, atrasa reação a succeeded/failed em segundos críticos, e ainda assim pode perder transições intermediárias.
Cada evento chega como POST no endpoint que você cadastrou, assinado com HMAC-SHA256 e o seu whsec_*. Sua responsabilidade: verificar a assinatura, ser idempotente, responder 2xx em < 30s. Falhar em qualquer um quebra o fluxo.
Anatomia de um evento
{
"id": "evt_3MtwBwLkdI",
"object": "event",
"type": "payment_intent.succeeded",
"api_version": "v1",
"created": 1714060123,
"livemode": false,
"data": {
"object": {
"id": "pi_3MtwBwLkdI",
"object": "payment_intent",
"status": "succeeded",
"amount": 49700,
"currency": "brl"
}
}
}Campos críticos:
id(evt_*) — a chave de idempotência do evento. Salve no DB e use para deduplicar.type— namespace pontuado:<recurso>.<ação>. Use*no cadastro do endpoint para receber todos os eventos.livemode—falsese gerado em test mode; filtre antes de side effects.api_version— major da API que gerou o evento. Hoje semprev1.
Cadastrar endpoint
No body do POST o campo é events; na resposta volta como enabled_events (Stripe-style).
curl https://prometheus.zhex.io/v1/webhook_endpoints \
-H "Authorization: Bearer $ZHEX_SECRET_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.meusite.com/webhooks/zhex",
"events": [
"payment_intent.succeeded",
"payment_intent.payment_failed",
"charge.dispute.created"
],
"description": "Worker de fulfillment + risco"
}'endpoint.secret é mostrado só na criação e em POST /v1/webhook_endpoints/:id/rotate_secret. Se você perder, rotacione — mas isso invalida a key antiga, então faça em janela de manutenção.
A configuração de webhooks também está disponível visualmente no dashboard, em Integrações → Webhooks — criar, ver últimas entregas e replay manual.
Verificar assinatura
A Zhex envia o header Zhex-Signature: t=<timestamp>,v1=<hmac_hex>. Verifique sempre. Sem isso, qualquer um pode disparar evento falso.
import { createHmac, timingSafeEqual } from 'node:crypto';
class SignatureVerificationError extends Error {}
export function constructEvent(
body: string, // raw bytes recebidos, NÃO JSON.parse
header: string, // Zhex-Signature
secret: string, // whsec_*
toleranceSeconds = 300,
) {
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.split('=')),
);
if (!parts.t || !parts.v1) {
throw new SignatureVerificationError('header malformado');
}
const tsMs = Number(parts.t) * 1000;
if (Math.abs(Date.now() - tsMs) > toleranceSeconds * 1000) {
throw new SignatureVerificationError('timestamp fora da tolerância');
}
const signedPayload = `${parts.t}.${body}`;
const expected = createHmac('sha256', secret)
.update(signedPayload)
.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 SignatureVerificationError('hmac não bate');
}
return JSON.parse(body);
}Três armadilhas que aparecem em produção:
JSON.parseantes de verificar. O parser pode reformatar números (1.0→1) e quebrar o HMAC. Sempre passe bytes brutos ao verifier.- Tolerância maior que 5 minutos. Anti-replay quebra. 300s é o máximo seguro — abaixo disso pode falhar com clock skew em containers.
- Comparação não-constante (
a === b). Vazamento por timing attack. UsetimingSafeEqualsempre.
Express
import express from 'express';
app.post(
'/webhooks/zhex',
express.raw({ type: 'application/json' }), // ← bytes crus
(req, res) => {
try {
const event = constructEvent(
req.body.toString('utf8'),
req.headers['zhex-signature'] as string,
process.env.ZHEX_WEBHOOK_SECRET!,
);
// …
res.status(200).end();
} catch (err) {
res.status(400).send(err.message);
}
},
);Next.js App Router
// app/api/webhooks/zhex/route.ts
export const runtime = 'nodejs';
export async function POST(req: Request) {
const body = await req.text(); // ← text(), NUNCA json()
try {
const event = constructEvent(
body,
req.headers.get('zhex-signature')!,
process.env.ZHEX_WEBHOOK_SECRET!,
);
// …
return new Response(null, { status: 200 });
} catch {
return new Response('invalid signature', { status: 400 });
}
}Fastify
import Fastify from 'fastify';
const fastify = Fastify();
fastify.addContentTypeParser(
'application/json',
{ parseAs: 'buffer' },
(_req, body: Buffer, done) => done(null, body),
);
fastify.post('/webhooks/zhex', async (req, reply) => {
try {
const event = constructEvent(
(req.body as Buffer).toString('utf8'),
req.headers['zhex-signature'] as string,
process.env.ZHEX_WEBHOOK_SECRET!,
);
// …
return reply.status(200).send();
} catch {
return reply.status(400).send('invalid signature');
}
});Idempotência no consumer
Webhook pode chegar mais de uma vez. Retries de rede, replays manuais, restart do worker em ack pendente — tudo gera duplicata. Sua tabela de outcomes precisa deduplicar por event.id:
CREATE TABLE processed_events (
event_id text PRIMARY KEY,
type text NOT NULL,
processed_at timestamptz NOT NULL DEFAULT now()
);const event = constructEvent(/* … */);
const { rowCount } = await db.query(
`INSERT INTO processed_events (event_id, type)
VALUES ($1, $2)
ON CONFLICT (event_id) DO NOTHING`,
[event.id, event.type],
);
if (rowCount === 0) {
return res.status(200).end(); // já processei, sai feliz
}
// agora sim, side effect
await fulfillOrder(event.data.object);A regra: insira primeiro, depois aja. Se o seu worker crashar entre os dois, o próximo retry da Zhex vê o INSERT, sai sem agir, e ninguém duplicou.
Retry e DLQ
Se você responder fora de 2xx, a Zhex re-entrega com backoff exponencial:
| Tentativa | Quando |
|---|---|
| 1 | Imediato |
| 2 | +5s |
| 3 | +30s |
| 4 | +5min |
| 5 | +1h |
| 6 | +6h |
| 7 | +24h |
Após a 7ª falha, a entrega é marcada dead_letter e não retentada automaticamente.
Replay manual no dashboard (Integrações → Webhooks → ver entregas) ou via API depois de corrigir o handler — o replay cria uma nova entrega pending e o cron processa no próximo tick.
Replay manual
import { randomUUID } from 'node:crypto';
await fetch(
'https://prometheus.zhex.io/v1/webhook_endpoints/we_abc/deliveries/evt_xyz/replay',
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZHEX_SECRET_KEY}`,
'Idempotency-Key': randomUUID(),
},
},
);Replay é útil para:
- Testar nova lógica em dev contra eventos reais.
- Recuperar de bug que deu 500 mas você já corrigiu.
- Validar mudança em handler antes de virar live.
Eventos disponíveis hoje
| Evento | Quando | Ação típica |
|---|---|---|
payment_intent.succeeded | Cobrança liquidou | Liberar produto, enviar nota fiscal |
payment_intent.payment_failed | Última tentativa falhou | Notificar cliente, marcar pedido failed |
payment_intent.canceled | Cancelado manualmente ou por timeout | Encerrar fluxo |
charge.dispute.created | Chargeback aberto pela operadora | Reembolso compulsório — registrar perda contábil |
No cadastro do endpoint você pode passar ["*"] para receber todos os events (incluindo novos que entrarem em versões futuras), ou listar tipos exatos.
Roadmap de events
Estamos expandindo o vocabulário com customer.*, payment_method.*, customer_subscription.* e refund.* em versões Zhex-Version futuras. Até lá, derive estado consultando os endpoints GET na transição que precisar acompanhar.
Local development
A Zhex precisa de URL pública para entregar webhook. Em dev, use ngrok ou cloudflared:
# instala uma vez
brew install ngrok cloudflared
# expõe localhost:3000
ngrok http 3000
# ou
cloudflared tunnel --url http://localhost:3000Pegue a URL pública (ex: https://abc123.ngrok-free.app) e cadastre como webhook em test mode:
curl https://prometheus.zhex.io/v1/webhook_endpoints \
-H "Authorization: Bearer $ZHEX_TEST_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"url": "https://abc123.ngrok-free.app/webhooks/zhex",
"events": ["*"]
}'Pra desenvolvimento local, exponha o localhost com ngrok ou cloudflared e cadastre um endpoint apontando pra esse tunnel — destrói depois com DELETE /v1/webhook_endpoints/:id.
Checklist de produção
Verifica Zhex-Signature em todos os payloads
Sem exceção. Inclusive em test.
Tolerância de timestamp ≤ 5 minutos
Default 300s. Não aumente para "ser tolerante" — quebra anti-replay.
INSERT … ON CONFLICT DO NOTHING em event.id antes de agir
Idempotência no consumer. Sem isso, refund duplo, email duplo, etc.
Responde 2xx em < 30s
Side effect pesado vai pra fila (BullMQ, SQS, ack imediato). Worker processa async.
Filtra event.livemode === false em ambiente de produção
Test events não devem disparar email real, integração SAP, etc.
Endpoint HTTPS válido
A Zhex recusa cadastrar URL http:// ou cert self-signed.
Monitor do retry rate
Se >5% dos eventos do mesmo type retentam, há bug no handler. Alerta Datadog/Grafana.
Próximos passos
Test mode
Eventos de teste com whsec_test_*
Verificação HMAC — segurança
Por que HMAC > IP allowlist > Bearer token
Helper completo
constructEvent pronto para colar no projeto
Atualizado em