BETAHubCard beta — feedback bem-vindo.
produto14 de abril de 2026·6 min de leitura

Webhooks tipados na API de cartões: o fim do JSON livre

Como migramos webhooks de JSON livre para eventos esquema-fortes na API de cartões corporativos e tiramos 80% dos tickets de suporte de integradores.

TA
Tiago Andrade
CTO
produto
// webhook receipt
event: "tx.declined"
reason: "limit_exceeded"
→ pause & alert team

Webhooks são o canal silencioso por onde a infraestrutura conta o que aconteceu. Quando funcionam, ninguém percebe. Quando falham — ou pior, quando o esquema muda sem aviso — viram a categoria #1 de tickets de suporte. Esse post conta como migramos os webhooks da nossa API de cartões corporativos de JSON livre para eventos esquema-fortes, e tiramos 80% dos tickets de integração.

O problema original

A v1 da nossa API enviava webhooks assim:

{
  "type": "transaction",
  "card": "card_01HX7P3M9",
  "amount": 5840,
  "status": "ok",
  "ts": "2026-03-14T12:33:08Z"
}

Simples, certo? Errado. Em seis meses:

  • amount virou às vezes string ("58.40") em vez de int (5840).
  • status virou "approved" em alguns casos, "ok" em outros, "authorized" em outros.
  • card às vezes vinha como card_id, às vezes como objeto inteiro.
  • Sem event_id único — retries do nosso lado faziam o webhook chegar 3 vezes sem o cliente conseguir deduplicar.

Cada cliente integrava interpretando o JSON do jeito dele. Cada mudança nossa (mesmo backward compat) quebrava integração de alguém.

A v2 — eventos esquema-fortes

Reescrevemos com 4 princípios:

  1. Cada tipo de evento tem schema próprio versionado.
  2. event_id único, idempotency_key derivado dele.
  3. Versão no envelope, payload separado.
  4. Documentação como contrato OpenAPI publicado.

A nova forma:

{
  "id": "evt_01HX8ABC...",
  "type": "tx.captured",
  "version": "2026-04-01",
  "created_at": "2026-04-14T12:33:08.142Z",
  "idempotency_key": "evt_01HX8ABC...",
  "data": {
    "transaction": {
      "id": "tx_01HX8X...",
      "card_id": "card_01HX7P3M9",
      "amount_cents": 5840,
      "currency": "USD",
      "merchant": {
        "name": "OPENAI *API",
        "mcc": 5734,
        "country": "US"
      },
      "authorized_at": "2026-04-14T12:33:08.000Z",
      "captured_at": "2026-04-14T12:33:08.142Z"
    }
  }
}

Não mudou o conteúdo essencial. Mudou a estrutura.

A taxonomia dos eventos

Aplicamos um padrão recurso.acao. Os 14 eventos principais:

EventoDisparo
card.createdCartão emitido com sucesso
card.activatedCartão saiu de pending para active
card.updatedAtributos do cartão alterados
card.pausedCartão pausado
card.resumedCartão retomado
card.cancelledCartão cancelado
card.expiredCartão atingiu active_until
tx.authorizedTransação autorizada
tx.capturedTransação capturada
tx.declinedTransação recusada
tx.reversedTransação revertida
tx.disputedChargeback aberto
limit.exceededTentativa acima do limit_cents
mcc.blockedTentativa em MCC não permitido

Cada evento tem schema publicado em https://api.hubcard.one/openapi/webhooks.yaml — versionado, com changelog.

Idempotência no cliente, garantida

event_id é único e estável. O cliente armazena event_id recebido e ignora se já viu. Nossa documentação tem snippet exato para Node, Python, Go e Ruby. Antes da v2, idempotência ficava "como você quiser". Agora é regra obrigatória.

async function handleWebhook(req: Request) {
  const event = await req.json() as Event;
  if (await alreadyProcessed(event.id)) return new Response("ok", { status: 200 });
  await processEvent(event);
  await markProcessed(event.id);
  return new Response("ok", { status: 200 });
}

Assinatura HMAC com timestamp

Cada webhook leva header:

HubCard-Signature: t=1745935988,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e7

Validação no cliente:

const [t, v1] = req.headers.get("hubcard-signature")
  .split(",")
  .map(s => s.split("=")[1]);

const expected = crypto
  .createHmac("sha256", webhookSecret)
  .update(`${t}.${rawBody}`)
  .digest("hex");

if (expected !== v1) throw new Error("invalid signature");
if (Date.now() - Number(t) * 1000 > 5 * 60 * 1000) throw new Error("stale");

O t (timestamp) evita replay attacks. A janela de 5 minutos cobre clock skew sem deixar buraco.

Retries com backoff exponencial

Quando o cliente não responde 2xx em 10s, retentamos:

  • Tentativa 2: 30s depois
  • Tentativa 3: 2 min depois
  • Tentativa 4: 10 min depois
  • Tentativa 5: 1 hora depois
  • Tentativa 6: 4 horas depois
  • Tentativa 7: 12 horas depois (última)

Total de janela: ~17 horas. Depois disso o webhook vira "permanently_failed" no log e o admin recebe alerta. Eventos críticos (tx.captured, card.cancelled) também ficam acessíveis via GET /v2/events?card_id=... por 30 dias.

A migração

Não fizemos breaking change. Mantivemos a v1 ativa por 6 meses, com header de deprecation:

HubCard-Webhook-Deprecation: this format is deprecated, see https://docs.hubcard.one/webhooks/v2

Clientes podiam optar pelo formato novo via header Accept-Webhook-Version: 2026-04-01 no endpoint registrado. Em 4 meses, 87% migrou voluntariamente. Os 13% restantes receberam comunicação direta antes do sunset.

O resultado

Em métricas operacionais, comparando o trimestre antes vs depois:

  • Tickets de suporte sobre webhook: -82%.
  • Tempo médio de integração de webhook (cliente novo): de 6h para 90 min.
  • Eventos perdidos por erro de schema: zerou.
  • PRs de upgrade em SDKs externos quando lançamos novo evento: 8 PRs em 2 dias (vs nenhum antes).

O ganho menos óbvio: clientes começaram a tratar webhook como source of truth. Antes, muitos clientes faziam POST e depois GET para confirmar — desconfiavam do webhook. Agora, com schema garantido + assinatura + idempotência, o webhook chegou ao status de evento confiável.

Por que isso importa para gestão de despesas em tempo real

Gestão de despesas em tempo real só funciona se cada tx.captured chegar no seu ERP em segundos, com schema garantido. Sem isso, fechamento volta a ser exercício mensal de cruzar PDF. Com isso, a conciliação roda à medida que as transações acontecem.

O fim do JSON livre não é estética. É a única forma de fazer integração financeira em escala.

TA

Tiago Andrade

CTO

Escreve sobre cartões corporativos, API de emissão e gestão de despesas em tempo real.

Falar com o time

Continue lendo

Todos os posts
Webhooks tipados na API de cartões: o fim do JSON livre · HubCard