docs

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.

  1. Dashboard → Integrações → WebhooksAdicionar
  2. Cole sua URL pública (https://api.meusite.com/webhooks/zhex)
  3. Selecione os eventos que quer receber (ou marque "todos")
  4. Copie o secret gerado (whsec_...) — mostrado uma vez só. Use ele pra verificar a assinatura.
  5. 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:

  1. JSON.parse antes de verificar. O parser pode reformatar números (1.01) e quebrar o HMAC. Sempre passe bytes brutos ao verifier.
  2. Comparação não-constante (a === b). Vazamento por timing attack. Use timingSafeEqual sempre.

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:

TentativaQuando
1Imediato
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

EventoQuandoAção típica
payment_intent.succeededCobrança liquidouLiberar produto, enviar nota fiscal
payment_intent.payment_failedÚltima tentativa falhouNotificar cliente, marcar pedido failed
payment_intent.canceledCancelado manualmente ou por timeoutEncerrar fluxo
charge.dispute.createdChargeback aberto pela operadoraReembolso 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:3000

Pegue 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

Esta página foi útil?

Atualizado em

Nesta página