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": "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.
  • livemodefalse se gerado em test mode; filtre antes de side effects.
  • api_version — major da API que gerou o evento. Hoje sempre v1.

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:

  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. Tolerância maior que 5 minutos. Anti-replay quebra. 300s é o máximo seguro — abaixo disso pode falhar com clock skew em containers.
  3. 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['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:

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

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

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 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:3000

Pegue 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

Esta página foi útil?

Atualizado em

Nesta página