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": "webhook_event",
"type": "payment_intent.succeeded",
"created": 1714060123,
"data": {
"object": {
"id": "pi_3MtwBwLkdI",
"amount": 49700,
"currency": "BRL",
"status": "approved",
"approvedAt": "2026-04-29T18:30:00Z",
"createdAt": "2026-04-29T18:29:55Z",
"customer": { "email": "...", "name": "...", "phone": "..." },
"products": { "names": ["..."], "ids": ["prod_..."] },
"paymentMethod": { "type": "PIX" },
"utm": { "utm_source": "google", "utm_medium": "cpc" },
"fees": { "fee": 297, "netAmount": 49403, "grossAmount": 49700 }
}
}
}Campos críticos:
id— chave de idempotência do evento. Salve no DB e use para deduplicar.type— namespace pontuado:<recurso>.<ação>. Mapeia 1-1 do evento canônico interno (PURCHASE_APPROVED → payment_intent.succeeded, etc).data.object— snapshot do recurso no momento da emissão.
Todo evento traz tanto eventos de cartão (síncronos no paymentIntents.confirm) quanto de PIX/Boleto (assíncronos via webhook do acquirer) pelo mesmo caminho.
Cadastrar endpoint
Webhook é uma integração no dashboard, igual Utmify/Cademi/etc.
- Dashboard → Integrações → Webhooks → Adicionar
- Cole sua URL pública (
https://api.meusite.com/webhooks/zhex) - Selecione os eventos que quer receber (ou marque "todos")
- Copie o secret gerado (
whsec_...) — mostrado uma vez só. Use ele pra verificar a assinatura. - Salvar.
Pronto. Não tem API headless pra cadastrar webhook — é uma decisão de produto: o secret é o ponto frágil e queremos que passe pelo dashboard com confirmação visual.
Você pode editar URL/eventos/secret depois. Rotacionar o secret invalida o anterior — faça em janela de manutenção.
Verificar assinatura
A Zhex envia o header x-zhex-signature: <hmac_hex> em todos os webhooks. 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, // x-zhex-signature
secret: string, // whsec_*
) {
if (!header) {
throw new SignatureVerificationError('header ausente');
}
const expected = createHmac('sha256', secret).update(body).digest('hex');
const a = Buffer.from(header, '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);
}Duas 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.- 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['x-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('x-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['x-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
No dashboard: Integrações → Webhooks → Entregas → clique na entrega que falhou → Reenviar.
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 marcar "todos os eventos" pra receber tudo (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 no dashboard em test mode: Integrações → Webhooks → Adicionar → URL = https://abc123.ngrok-free.app/webhooks/zhex.
Quando terminar o desenvolvimento, desativa o webhook no dashboard ou apaga.
Checklist de produção
Verifica x-zhex-signature em todos os payloads
Sem exceção. Inclusive em test.
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.
Configure endpoints separados pra test mode e live
Use webhook configs distintas no dashboard pra test e live; secret diferente em cada. 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