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:
amountvirou às vezes string ("58.40") em vez de int (5840).statusvirou"approved"em alguns casos,"ok"em outros,"authorized"em outros.cardàs vezes vinha comocard_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:
- Cada tipo de evento tem schema próprio versionado.
event_idúnico,idempotency_keyderivado dele.- Versão no envelope, payload separado.
- 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:
| Evento | Disparo |
|---|---|
card.created | Cartão emitido com sucesso |
card.activated | Cartão saiu de pending para active |
card.updated | Atributos do cartão alterados |
card.paused | Cartão pausado |
card.resumed | Cartão retomado |
card.cancelled | Cartão cancelado |
card.expired | Cartão atingiu active_until |
tx.authorized | Transação autorizada |
tx.captured | Transação capturada |
tx.declined | Transação recusada |
tx.reversed | Transação revertida |
tx.disputed | Chargeback aberto |
limit.exceeded | Tentativa acima do limit_cents |
mcc.blocked | Tentativa 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.