tutorial

Webhooks de IA: pattern enterprise pra notificar gastos sem polling

Como integrar eventos Tokia no backend do seu SaaS com HMAC SHA-256 + retry exponencial + dead letter. Exemplo Node.js Fastify + Python FastAPI completo.

Você cobra clientes pelo uso de IA no seu SaaS. Hora do mês fecha, alguém estourou o cap, vira reclamação. Solução comum mas ruim: cron toda hora batendo em /usage pra checar saldo. Solução boa: webhooks outbound.

Esse post mostra o pattern completo que a Tokia implementa pros clientes — e você pode copiar pro seu SaaS interno também.

Por que webhooks > polling

| Critério | Polling 1x/hora | Webhook outbound | |---|---|---| | Latência detecção | até 60min | ~1s | | Custo API calls | 24/dia/cliente × N clientes | 1 chamada por evento real | | Carga DB | constante | só quando acontece | | Confiabilidade | depende cron | retry exponencial + dead letter |

Pra um SaaS com 100 clientes, polling é 2400 requests/dia. Webhook é ~50/dia (eventos reais). 48x mais eficiente.

Eventos comuns em SaaS de IA

A Tokia dispara 6 eventos pros clientes:

| Evento | Quando | Use case típico | |---|---|---| | balance.low | Saldo cliente cruzou threshold (R$ 20) | Notifica admin Slack | | balance.topped_up | Recarga PIX/cartão confirmou | Atualiza dashboard interno | | key.created | Nova API key criada | Auditoria SOC2 | | key.suspended | Saldo zerou ou admin suspendeu | Avisa user trocar pra key backup | | payment.failed | Auto-topup cartão recusado | Email user atualizar cartão | | key.budget_warning | Key cruzou 80% do cap em 24h | Antecipa upgrade plan |

Pattern HMAC SHA-256 (anti-spoofing)

Sem assinatura, qualquer um que descobrir sua URL pode mandar payload fake. A proteção padrão é HMAC: você compartilha um signing_secret no momento de criar o webhook, e a cada request o sender envia um header com HMAC do body. Você recalcula e compara.

Exemplo Node.js + Fastify

import Fastify from "fastify";
import crypto from "node:crypto";

const SIGNING_SECRET = process.env.TOKIA_WEBHOOK_SECRET!;

const app = Fastify({ logger: true });

// Importante: parse body como Buffer raw (não JSON)
// pra HMAC bater no payload exato que veio
app.addContentTypeParser(
  "application/json",
  { parseAs: "buffer" },
  (_req, body, done) => done(null, body),
);

app.post("/tokia-webhook", async (req, reply) => {
  const signature = req.headers["tokia-signature-sha-256"] as string;
  const rawBody = req.body as Buffer;

  const expected = crypto
    .createHmac("sha256", SIGNING_SECRET)
    .update(rawBody)
    .digest("hex");

  if (
    !signature ||
    signature.length !== expected.length ||
    !crypto.timingSafeEqual(
      Buffer.from(signature, "hex"),
      Buffer.from(expected, "hex"),
    )
  ) {
    req.log.warn({ ip: req.ip }, "invalid webhook signature");
    return reply.code(401).send({ error: "invalid signature" });
  }

  const payload = JSON.parse(rawBody.toString());

  switch (payload.event) {
    case "balance.low":
      await notifySlack(`Cliente ${payload.data.user_id} saldo baixo`);
      break;
    case "balance.topped_up":
      await db.transactions.upsert({ id: payload.delivery_id, ... });
      break;
    case "key.suspended":
      await emailUser(payload.data.user_id, "Sua key foi suspensa");
      break;
  }

  return reply.code(200).send({ ok: true });
});

app.listen({ port: 3000 });

Exemplo Python + FastAPI

import hmac, hashlib, json, os
from fastapi import FastAPI, Request, HTTPException

SIGNING_SECRET = os.environ["TOKIA_WEBHOOK_SECRET"].encode()
app = FastAPI()

@app.post("/tokia-webhook")
async def tokia_webhook(request: Request):
    body = await request.body()
    signature = request.headers.get("tokia-signature-sha-256", "")
    expected = hmac.new(SIGNING_SECRET, body, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(signature, expected):
        raise HTTPException(401, "invalid signature")

    payload = json.loads(body)
    event = payload["event"]

    if event == "balance.low":
        await notify_slack(f"Cliente {payload['data']['user_id']} saldo baixo")
    elif event == "balance.topped_up":
        await update_dashboard(payload["data"])

    return {"ok": True}

Idempotência (CRÍTICO)

A Tokia (como Stripe, Asaas e qualquer provider sério) retenta entrega em falha. Isso significa que o mesmo evento pode chegar 2-5 vezes. Seu endpoint DEVE ser idempotente.

Pattern: cada evento tem delivery_id único. Você guarda IDs já processados:

CREATE TABLE webhook_deliveries (
  delivery_id VARCHAR PRIMARY KEY,
  processed_at TIMESTAMPTZ DEFAULT now()
);
async function processEvent(payload: TokiaWebhookPayload) {
  const exists = await db.webhookDeliveries.findUnique({
    where: { deliveryId: payload.delivery_id },
  });
  if (exists) {
    // Já processado, retornamos OK pra Tokia parar de retentar
    return;
  }

  // Faz o trabalho real
  await handleEvent(payload);

  // Marca como processado (atomic)
  await db.webhookDeliveries.create({
    data: { deliveryId: payload.delivery_id },
  });
}

Retry exponencial + dead letter

A Tokia usa esse schedule de retry:

| Tentativa | Quando | |---|---| | 1 | imediato | | 2 | +1 minuto | | 3 | +5 minutos | | 4 | +30 minutos | | 5 | +4 horas | | ❌ Dead letter | após 5 falhas |

Pra seu webhook responder dentro do timeout (30s na Tokia):

  1. Validation rápida (HMAC, parse JSON) — síncrono
  2. Trabalho real — enfileira em background (BullMQ, Sidekiq, Celery)
  3. Responde 200 OK em menos de 1s
app.post("/tokia-webhook", async (req, reply) => {
  // Validation síncrona
  if (!validHmac(req)) return reply.code(401).send();

  // Enfileira pra background — não bloqueia response
  await queue.add("tokia-event", payload, {
    deduplication: { id: payload.delivery_id },
  });

  // Responde rápido
  return reply.code(200).send({ ok: true });
});

Por quê: se você processar pesado síncrono (DB, email, Slack) e demorar 35s, a Tokia já cancelou + agenda retry. O evento é processado mas marca como falha = retentou desnecessário.

Dead letter UI no dashboard

Se um webhook seu cair por horas, Tokia para de retentar e vai pra dead_letter. No /dashboard/webhooks você vê tab "Tentativas" com status. Pode clicar "Reativar" pra retentar manual após você corrigir o problema.

Recomendação: monitora periodicamente (cron 1x/dia) quantos dead_letter têm.

Local dev com webhooks reais

Webhook precisa endpoint HTTPS público. Local dev:

# Opção 1: ngrok (mais comum)
ngrok http 3000
# Cola URL ngrok.io no /dashboard/webhooks

# Opção 2: cloudflared (free permanent URL)
cloudflared tunnel --url http://localhost:3000

# Opção 3: webhook.site (UI pra inspecionar payload)
# Cola URL deles, vê eventos chegando, monta lógica depois

Testando seu endpoint

A Tokia tem botão "Ping" em cada webhook — dispara evento test webhook.ping imediato pra você validar a integração antes de eventos reais acontecerem.

Erros comuns

| Erro | Causa | Fix | |---|---|---| | 401 invalid signature | HMAC bate mas alguém escreveu tokia-signature-sha256 (sem hyphen) | Header certo: tokia-signature-sha-256 | | Body parse error | Você parsou JSON antes de calcular HMAC | Use rawBody Buffer pro HMAC | | Sempre dead_letter | Endpoint retorna 4xx/5xx | Confere logs, fix bug | | Timeout | Processamento maior que 30s síncrono | Enfileira em background | | Duplicates | Não está validando delivery_id | Implementa idempotência |

Conclusão

Webhooks outbound são 20 linhas de código mas resolvem 3 dores enormes: notificação em tempo real, redução de polling, e UX premium pro user (ex: SMS quando saldo zera em 1s, não em 1h).

Tokia tem isso pronto desde Sprint 12. Se seu SaaS ainda não tem, vale 1 sprint focado.

Configura webhook Tokia em 2min →


Posts relacionados:

#webhooks#hmac#retry#saas#enterprise

Quer testar Tokia com R$ 10 via PIX?

Criar conta grátis →