Os webhooks entregam eventos de identificação ao seu servidor em tempo real. Toda vez
que um visitante é identificado, a TRACIO envia uma requisição HTTP POST para a URL
de webhook que você configurou. O corpo da requisição é o payload do evento.
Eles também são o único canal que entrega vereditos tardios — aqueles em que o comportamento de um visitante provou que ele era automatizado depois que a página já havia carregado.
Configure-os no painel, em Settings → Webhooks. Webhooks exigem o plano Pro ou superior.
| Evento | Quando | Plano |
|---|---|---|
identification | Em toda visita — as fases primary, late e correction | Todos |
account_takeover | O comportamento sob uma conta não corresponde mais ao perfil do titular | Business+ |
attack_detected | Um pico de bots no seu site | Business+ |
reputation_changed | A reputação da pessoa por trás de um dispositivo mudou | Business+ |
Os nomes dos eventos usam sublinhados, nunca pontos — não existe visitor.created nem
session.created. reputation_changed exige a camada de pessoa, então só dispara para
workspaces em que a resolução de identidade entre dispositivos está habilitada.
Um webhook assina tipos específicos; o valor separado * significa "todo tipo,
incluindo os adicionados depois". Um tipo desconhecido é rejeitado com 400 quando
uma assinatura é criada ou editada, de modo que um erro de digitação não deixe você
com um webhook que silenciosamente nunca dispara.
identificationUma única visita produz até três entregas que compartilham o mesmo requestId:
primary — o veredito inicial, no carregamento da página.late — enriquecimento cerca de nove segundos depois, quando as verificações lentas chegam.correction — uma correção baseada no comportamento (ponteiro, teclado, rolagem).Correlacione-as pelo requestId e diferencie-as pelo phase. A fase posterior tem
precedência: se primary disse human e correction diz bot, a segunda é a
resposta correta.
Não confie na ordem de chegada. Cada fase é entregue de forma independente e com
seu próprio cronograma de retry — se primary entrou em retry enquanto late teve
sucesso na primeira tentativa, você as receberá em ordem inversa. Determine a
precedência pelo campo phase, não pelo horário de recebimento.
Essas três são as únicas fases de um evento identification. Um outro valor chega até
você: account_takeover carrega phase: "beacon", porque um alerta de roubo de conta
só é levantado a partir de um beacon comportamental.
Repare no descompasso que isso cria, porque ele afeta a idempotência. Uma entrega de
identification em produção tem eventId exatamente igual a <requestId>:<phase>,
mas duas entregas quebram essa fórmula. Um account_takeover é <requestId>:ato — o
sufixo é o literal ato, e não o valor do campo phase. Uma entrega de teste enviada
pelo painel é <requestId>:test, enquanto o phase no corpo em esquema 2 ainda diz
primary — e um corpo em esquema 1 não tem campo phase nenhum, de modo que o
cabeçalho é o único lugar onde esse sufixo aparece. Use o eventId diretamente como
chave de idempotência e nunca o remonte a partir de requestId e phase. Case pelos
valores que você trata e ignore o resto, em vez de rejeitar a entrega.
attack_detected é um evento em nível de workspace: ele não tem requestId, nem
visitorId, nem nenhum dos blocos browser, geo, bot ou decision — essas
chaves simplesmente não existem. account_takeover é produzido por uma visita
específica e carrega o corpo de identificação completo do seu plano mais um bloco
accountAlert. Se você processa todos os eventos em um único handler, verifique
event antes de tocar em campos de visita.
| Versão | Para quem | Como alternar |
|---|---|---|
1 | Webhooks criados antes de a v2 existir | Continua sendo o padrão para eles |
2 | Webhooks novos | O botão no cartão do webhook, dentro do painel |
O esquema v1 está congelado — nenhum de seus campos muda, de modo que integrações existentes continuam funcionando sem edições. Tudo o que é novo vive na v2, que é o que os webhooks novos emitem.
{ "version": 2, "event": "identification", "eventId": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31:primary", // "<requestId>:<phase>" — the idempotency key "requestId": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31", // visit identifier, shared by all phases "phase": "primary", "visitorId": "X7fh2Hg9LkMn3pQr5tBvQw3xZa9mK2pL4nR8dT6y", "linkedId": "user-42", // your ?lid=, if you passed one "tag": "checkout", "timestamp": "2026-07-30T12:00:00Z", "url": "https://shop.example.com/checkout", "ip": "203.0.113.44", "userAgent": "Mozilla/5.0 …", "browser": { "name": "Chrome", "version": "138" }, "os": { "name": "macOS", "version": "15.5" }, "device": "desktop", "geo": { "country": "DE", "city": "Berlin", "lat": 52.52, "lon": 13.405, "timezone": "Europe/Berlin" }, "network": { "vpn": false, "proxy": true, "tor": false, "datacenter": true, "connectionType": "DCH" }, "bot": { "result": "human", "score": 3.2 }, // human | bot | uncertain "identification": { "confidence": 0.97, "incognito": false }, "decision": { "action": "real", "riskScore": 12.2 } // real | fake | suspicious}Valores zero e vazios são omitidos. Campos de string e numéricos com valor zero
(bot.type para um humano, por exemplo) estão ausentes do JSON — não os torne
obrigatórios nos seus esquemas e leia os blocos aninhados de forma defensiva.
bot.score e decision.riskScore são decimais em escala 0..100 com uma casa
após a vírgula — exatamente os números que o painel reporta para a mesma visita. (No
esquema congelado da v1 eles usam unidades diferentes: uma fração 0..1 e 0..255,
respectivamente.)
bot.type é ou o nome de um bot reconhecido ou um rótulo de família. Veja
Tipos de bot para o vocabulário — nomes internos de
verificações nunca são expostos, em nenhum plano.
| Campo | Tipo | Descrição |
|---|---|---|
version | number | Versão do esquema do payload (2) |
event | string | Tipo do evento |
eventId | string | Identificador da entrega — a chave de idempotência |
requestId | string | Identificador da visita (UUID), compartilhado por todas as fases dela |
phase | string | primary, late, correction; account_takeover traz beacon |
visitorId | string | Identificador de visitante estável |
linkedId | string | Identificador vinculado fornecido pelo cliente |
tag | string | Tag personalizada fornecida pelo cliente |
timestamp | string | Horário do evento (RFC 3339) |
url | string | URL da página onde o evento foi capturado |
ip | string | Endereço IP do cliente |
userAgent | string | String de user-agent bruta do cliente |
browser.name / .version | string | Navegador detectado |
os.name / .version | string | Sistema operacional detectado |
device | string | Classe do dispositivo (p. ex. desktop, mobile) |
geo | object | Geolocalização por IP: country, city, lat, lon, timezone |
network | object | vpn, proxy, tor, datacenter (booleanos) e connectionType |
bot.result | string | human, bot ou uncertain |
bot.type | string | Nome do bot ou rótulo de família quando um bot é detectado |
bot.score | number | Pontuação de bot (0–100) |
identification.confidence | number | Confiança da identificação (0.0–1.0) |
identification.incognito | boolean | Contexto de navegação privada/anônima |
decision.action | string | real, fake ou suspicious |
decision.riskScore | number | Pontuação de risco agregada (0–100) |
Pro e acima — como o visitante se comporta ao longo do tempo:
{ "identification": { "matchType": "exact", // exact | fuzzy | new — how the visitor was recognized "matchConfidence": 0.93, "visits": 42, "incognitoVisits": 3 }, // Present when visitor counters are available at event time (usually primary). // A missing block means "no data", not "zeros". "velocity": { "events5m": 7, "uniqueIps": 2, "uniqueLocations": 1 }, "bot": { "antidetectScore": 0 }, // antidetect indicators, 0..100 "session": { "durationSeconds": 95 } // where the visit duration is already known}Business e acima — por que o veredito saiu do jeito que saiu:
{ "reasons": [ // at most 8, sorted by importance { "code": "headless_browser", "severity": "high" }, { "code": "privacy_hardening", "severity": "low" } ], // Behavioral biometrics — present only when behavioral scoring ran for the // visit. A missing block means "no data", never "nothing suspicious". "behavior": { "score": 87, "verdict": "human", "confidence": 0.92 }, "identification": { "driftScore": 0.31 }, // divergence from the account profile "deviceInfo": { "deviceId": "…", // the physical device across browsers on it "crossBrowser": true, "confidence": 0.88, "linkedBrowsers": 3 }, "network": { "isp": "Deutsche Telekom", "asn": 3320 }, "decision": { "suspectScore": 55 }, "guidance": { "version": 1, "overall": "review" } // see below}Veja Detecção de bots para o vocabulário dos
códigos de motivo e o que severity significa.
guidance carrega recomendações prontas de "o que fazer" por ponto de integração,
para que você não precise derivar uma política a partir de pontuações brutas:
{ "guidance": { "version": 1, "overall": "review", // the strictest advice across the scenarios "payment": "review", // whether to accept the payment "registration": "challenge", // whether to create the account "login": "challenge", // whether to let them into the account "affiliate": "review", // whether to credit the conversion to the partner "basis": ["risk", "network"] // the axes that determined the advice }}Todo cenário começa em allow e só se move para cima na escada:
allow → challenge → review → deny. Dentro de um cenário vence o eixo mais
rigoroso que disparar, e overall é o mais rigoroso entre os quatro cenários.
| Recomendação | Pagamento | Cadastro | Login | Afiliado |
|---|---|---|---|---|
allow | Processe | Crie | Deixe entrar | Credite a conversão |
challenge | 3-D Secure / confirmação | Captcha, confirmação por e-mail ou telefone | 2FA reforçado, reautenticação | Marque como duvidosa até haver atividade |
review | Processe, mas coloque na fila de revisão | Crie com restrições | Deixe entrar e gere um alerta | Segure o repasse até a revisão |
deny | Não processe a transação | Recuse a criação da conta | Não deixe entrar | Não credite a conversão |
version é a versão do conjunto de regras — ela é incrementada conforme a lógica
melhora. Guidance é aditivo: novos cenários chegam como novas chaves sem quebrar o
contrato. A fase posterior vence, exceto no caso de recomendações parciais: uma
entrega calculada sobre um conjunto incompleto de entradas é marcada com
"partial": true, e uma recomendação parcial não sobrepõe uma recomendação
completa recebida antes para o mesmo requestId. Em uma entrega comum, o campo
partial está totalmente ausente.
Os limiares exatos deliberadamente não são documentados. Uma recomendação que pode ser submetida a engenharia reversa até virar uma pontuação deixa de ser uma defesa.
account_takeoverApenas Business e Enterprise. O corpo é o envelope de identificação completo do seu
plano mais um bloco accountAlert, entregue no máximo uma vez por visita:
{ "version": 2, "event": "account_takeover", "eventId": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31:ato", "requestId": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31", "phase": "beacon", // o alerta é levantado a partir de um beacon; só o eventId diz "ato" "accountAlert": { "type": "behavior-drift", "accountId": "user-42", // your linkedId for the account "driftScore": 0.83 } // …the remaining identification fields}Na v1 este bloco carrega type, linkedId e drift; na v2 dois campos são
renomeados — linkedId → accountId e drift → driftScore. Atualize seu handler
quando trocar o payloadVersion, ou sua lógica de roubo de conta vai parar
silenciosamente de enxergar os dados.
attack_detected{ "version": 2, "event": "attack_detected", "eventId": "c0a8e1f2-…", "timestamp": "2026-07-30T12:00:00Z", "attack": { "kind": "bot_spike", "severity": "critical", // info | warning | critical "windowMinutes": 15, "recentBots": 4210, "recentTotal": 5100, "expected": 180.5 // the baseline expected over a window this size }}Toda entrega inclui um cabeçalho X-Tracio-Signature:
X-Tracio-Signature: t=1710432000,v1=5257a869e7ecebed…t é o timestamp Unix (em segundos) de quando a requisição foi assinada.v1 é o HMAC-SHA256 codificado em hexadecimal de "<t>.<rawRequestBody>", com o
seu segredo de webhook como chave.O timestamp faz parte do conteúdo assinado, o que dá proteção contra replay.
Duas coisas para acertar, ou a verificação falha em produção:
v1=. Durante uma rotação de
segredo o cabeçalho carrega duas assinaturas, e um parser que mantenha apenas
uma delas vai rejeitar entregas válidas durante toda a janela de rotação.// Express.js exampleimport express from "express"import crypto from "crypto"
const app = express()
// Capture the raw body so the signature can be verified byte-for-byte.app.use( express.json({ verify: (req, _res, buf) => { ;(req as any).rawBody = buf }, }),)
function verifySignature(rawBody: Buffer, header: string, secret: string): boolean { if (!header) return false
const parts = header.split(",").map((p) => p.trim()) const ts = parts.find((p) => p.startsWith("t="))?.slice(2) if (!ts) return false
// Replay protection: reject timestamps more than five minutes old. if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
// Sign the raw bytes: the "<t>." prefix plus the raw request body. const signed = Buffer.concat([Buffer.from(`${ts}.`, "utf8"), rawBody]) const expected = crypto.createHmac("sha256", secret).update(signed).digest("hex") const exp = Buffer.from(expected, "hex")
// During a rotation window the header carries several v1= — any may match. return parts.some((p) => { if (!p.startsWith("v1=")) return false const got = Buffer.from(p.slice(3), "hex") // Compare lengths BEFORE timingSafeEqual: it throws on differing lengths, // and one junk header would turn the handler into a 500. return got.length === exp.length && crypto.timingSafeEqual(got, exp) })}
app.post("/webhook/tracio", (req, res) => { const header = req.headers["x-tracio-signature"] as string if (!verifySignature((req as any).rawBody, header, WEBHOOK_SECRET)) { return res.status(401).json({ error: "Invalid signature" }) }
const event = req.body console.log(`Visitor: ${event.visitorId}`) console.log(`Bot: ${event.bot?.result}`) // "human" | "bot" | "uncertain"
res.status(200).send("OK")})Entregas com esquema 2 carregam adicionalmente X-Tracio-Signature-Ed25519
(t=<unix>,kid=<id>,v1=<base64>). Ambos os lados conhecem o segredo HMAC, então o
HMAC prova que quem enviou conhece o segredo, mas não que a requisição se originou na
TRACIO; a assinatura assimétrica prova. As chaves públicas são publicadas em
https://api.tracio.ai/.well-known/webhook-keys, indexadas por kid.
Entregas de teste enviadas pelo painel são assinadas apenas com HMAC — a chave privada
da plataforma fica nos nós de entrega e deliberadamente não está disponível para o
painel. Um verificador que exija Ed25519 de forma rígida precisa deixar as entregas
de teste passarem (elas trazem o sufixo :test no eventId); caso contrário, os
testes pelo painel falham enquanto a produção está saudável. O mesmo cuidado vale para
checagens de formato: uma entrega de teste traz requestId no formato test_<hex> e o
literal test_visitor como visitorId, de modo que um handler que valide esses
valores contra os formatos de produção vai rejeitar uma entrega que, no resto, está bem
formada.
Após uma rotação, ambos os segredos permanecem válidos por 24 horas e o cabeçalho carrega as duas assinaturas, então você pode atualizar sua configuração sem perder entregas. A ação Revoke now encurta a janela. Atualize o segredo do seu lado dentro de 24 horas: assim que a janela fecha, o segredo antigo para de corresponder, e se o seu endpoint responder a uma assinatura inválida com 4xx, cinco respostas assim seguidas desativam o webhook.
| Cabeçalho | Descrição |
|---|---|
Content-Type | application/json |
X-Tracio-Signature | t=<unix>,v1=<hmac_sha256_hex> — dois v1= durante uma janela de rotação |
X-Tracio-Signature-Ed25519 | Assinatura de plataforma, t=<unix>,kid=<id>,v1=<base64> (somente v2) |
X-Tracio-Event-Id | Identificador da entrega — a chave de idempotência |
X-Tracio-Request-Id | Identificador da visita (v2, apenas eventos de visita) |
X-Tracio-Event-Type | O tipo do evento (somente v2) |
X-Tracio-Delivery-Attempt | Número da tentativa, começando em 1 (somente v2) |
X-Tracio-Payload-Version | 2 (somente v2) |
X-Tracio-Webhook-Id | Identificador do webhook que produziu esta entrega |
Entregas podem ser repetidas, e um retry carrega o mesmo X-Tracio-Event-Id.
Deduplique por ele:
app.post("/webhook/tracio", async (req, res) => { const eventId = req.headers["x-tracio-event-id"] as string
const existing = await db.webhooks.findOne({ eventId }) if (existing) return res.status(200).send("Already processed")
await db.webhooks.insert({ eventId, processedAt: new Date() }) await processWebhookEvent(req.body)
res.status(200).send("OK")})Note que eventId é único por evento, não por webhook: se vários webhooks do
workspace assinam o mesmo evento, cada um recebe uma entrega com o mesmo
identificador. Ele é montado como <requestId>:<phase>, e é por isso que as três fases
de uma mesma visita são deduplicadas de forma independente, em vez de colapsarem em uma
só.
Responda com 2xx — é o único sinal de que uma entrega foi aceita.
| Resposta | O que acontece |
|---|---|
2xx | Entrega concluída |
429 Too Many Requests | Não conta como falha e não gasta uma tentativa; um Retry-After mais longo é respeitado |
408, 425, 5xx, conexão perdida | Repetido com uma pausa crescente |
410 Gone | O endpoint é tratado como removido — o webhook é desativado imediatamente |
Outros 4xx | Repetido, mas cinco seguidos desativam o webhook — 400/401/404 não se curam com retry |
Cronograma de retries: 5s → 30s → 2min → 10min → 30min → 2h → 6h (8 tentativas). Os primeiros retries cabem dentro de um minuto, então um reinício breve do seu serviço não lhe custa uma notificação. Cada pausa é randomizada entre metade e o valor completo, para que os retries não disparem todos de uma vez após uma indisponibilidade.
A desativação automática exige tanto um limiar (20 falhas consecutivas, ou 5 erros de configuração) quanto pelo menos 15 minutos consecutivos de falhas — um reinício breve não consegue matar a integração, mesmo que muitas entregas estivessem enfileiradas. Um intervalo maior que 15 minutos reinicia a contagem. O painel mostra o motivo, com o código de resposta e o texto do erro, e um botão Re-enable que zera os contadores.
| Plano | Webhooks por workspace |
|---|---|
| Free | Não disponível |
| Pro | 5 |
| Business | 20 |
| Enterprise | 100 |
Os endpoints precisam ser https com IP público — endereços privados e de loopback
são rejeitados, inclusive em um redirecionamento — e não podem ter mais de dois
redirecionamentos de profundidade.
Apenas redirecionamentos 307 e 308 são seguidos. 301, 302 e 303 mandam o
cliente mudar para GET e descartar o corpo, então uma entrega não os segue e a
tentativa conta como falha. Se o seu balanceador de carga normaliza a URL (adicionando
www ou uma barra final), aponte o webhook direto para a URL final.
Os webhooks são gerenciados no painel. O painel aciona uma API de gerenciamento com
escopo de workspace, servida no host da aplicação (por exemplo
https://app.tracio.ai/api/v1), e os endpoints abaixo são exatamente os que ele chama.
Todo endpoint de webhook fica sob /workspaces/{wsId}.
Esta não é uma superfície servidor-a-servidor. A API de gerenciamento aceita apenas o JWT da sua sessão do painel, verificado contra o seu papel no workspace (RBAC); uma chave secreta
tracio_sk_…é rejeitada aqui. Como essa sessão vive no navegador e expira junto com ele, trate as chamadas abaixo como uma descrição do que o painel faz, e não como uma integração a ser automatizada. Para acesso programático a partir do seu próprio backend, use a Server API, que é somente leitura.
curl -X POST https://app.tracio.ai/api/v1/workspaces/{wsId}/webhooks \ -H "Authorization: Bearer <session-jwt>" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-server.com/webhook/tracio", "events": [] }'O segredo de assinatura é gerado pela TRACIO e retornado uma única vez na criação
(e na rotação) sob signingSecret. Guarde-o com segurança — é a chave que você usa
para verificar assinaturas.
{ "ok": true, "data": { "id": "b3d4f8a1-2c67-4e9b-8f05-7a1d3c9e2b48", "workspaceEnvironmentId": "b201f2ba-…", "url": "https://your-server.com/webhook/tracio", "events": [], "signingSecret": "f3a9…<hex>", "status": "active", "successRate": 100, "createdAt": "2026-07-30T12:00:00Z" }}Em leituras posteriores o signingSecret vem mascarado (null) — ele só é revelado
pela criação e pela rotação do segredo.
| Método | Caminho | Descrição |
|---|---|---|
GET | /workspaces/{wsId}/webhooks | Listar webhooks |
PATCH | /workspaces/{wsId}/webhooks/{webhookId} | Atualizar url / events / status |
DELETE | /workspaces/{wsId}/webhooks/{webhookId} | Excluir um webhook |
POST | /workspaces/{wsId}/webhooks/{webhookId}/test | Enviar uma entrega de teste assinada |
POST | /workspaces/{wsId}/webhooks/{webhookId}/secret/rotate | Rotacionar o segredo de assinatura |
GET | /workspaces/{wsId}/webhooks/{webhookId}/deliveries | Listar as tentativas de entrega recentes |
Retorne um 2xx o mais rápido possível e processe o payload de forma assíncrona para
evitar timeouts:
app.post("/webhook/tracio", async (req, res) => { res.status(200).send("OK") processWebhookEvent(req.body).catch(console.error)})
async function processWebhookEvent(event: WebhookPayload) { await db.events.insert(event)
if (event.decision?.riskScore > 50) { await alertFraudTeam(event) }
if (event.bot?.result === "bot") { await blockVisitor(event.visitorId) }}Use a ação Test em um webhook (ou POST .../webhooks/{webhookId}/test) para
enviar um payload de exemplo assinado ao seu endpoint e confirmar que ele está
acessível e verificando assinaturas corretamente.
Para desenvolvimento local, exponha seu servidor com um túnel como o ngrok:
ngrok http 3000# Use the generated URL as your webhook endpoint