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):
- Validation rápida (HMAC, parse JSON) — síncrono
- Trabalho real — enfileira em background (BullMQ, Sidekiq, Celery)
- 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:
Quer testar Tokia com R$ 10 via PIX?
Criar conta grátis →