Los webhooks entregan los eventos de identificación a su servidor en tiempo real.
Cada vez que se identifica a un visitante, TRACIO envía una petición HTTP POST a la
URL de webhook que haya configurado. El cuerpo de la petición es el payload del
evento.
Son además el único canal que entrega los veredictos tardíos: aquellos en los que el comportamiento de un visitante demostró que estaba automatizado después de que la página ya se hubiera cargado.
Se configuran en el panel, en Settings → Webhooks. Los webhooks requieren el plan Pro o superior.
| Evento | Cuándo | Plan |
|---|---|---|
identification | En cada visita — las fases primary, late y correction | Todos |
account_takeover | El comportamiento bajo una cuenta ya no coincide con el perfil de su titular | Business+ |
attack_detected | Un pico de bots en su sitio | Business+ |
reputation_changed | La reputación de la persona que hay detrás de un dispositivo ha cambiado | Business+ |
Los nombres de evento usan guiones bajos, nunca puntos: no existe visitor.created ni
session.created. reputation_changed requiere la capa de persona, así que solo se
dispara en los workspaces donde está habilitada la resolución de identidad entre
dispositivos.
Un webhook se suscribe a tipos concretos; el valor aparte * significa «todos los
tipos, incluidos los que se añadan más adelante». Un tipo desconocido se rechaza con
400 al crear o editar una suscripción, de modo que una errata no puede dejarle un
webhook que nunca se dispara en silencio.
identificationUna sola visita produce hasta tres entregas que comparten el mismo requestId:
primary — el veredicto inicial, en la carga de la página.late — enriquecimiento unos nueve segundos después, una vez han llegado las comprobaciones lentas.correction — una corrección basada en el comportamiento (puntero, teclado, desplazamiento).Correlaciónelas por requestId y distíngalas por phase. La fase posterior tiene
prioridad: si primary dijo human y correction dice bot, la respuesta correcta
es la segunda.
No se fíe del orden de llegada. Cada fase se entrega de forma independiente y con
su propio calendario de reintentos: si primary entró en reintento mientras late
tuvo éxito al primer intento, las recibirá en orden inverso. Determine la prioridad a
partir del campo phase, no de la hora de recepción.
Esas tres son las únicas fases de un evento identification. Un valor más le llega:
account_takeover lleva phase: "beacon", porque una alerta de secuestro de cuenta
solo se levanta desde un beacon de comportamiento.
Fíjese en el desajuste que esto crea, porque afecta a la idempotencia. Una entrega
identification de producción tiene un eventId que es exactamente
<requestId>:<phase>, pero dos entregas rompen esa fórmula. Un account_takeover es
<requestId>:ato — el sufijo es el literal ato, no el valor del campo phase. Una
entrega de prueba enviada desde el panel es <requestId>:test, mientras que el phase
de su cuerpo de esquema 2 sigue indicando primary; y un cuerpo de esquema 1 no tiene
campo phase en absoluto, así que el encabezado es el único sitio donde aparece ese
sufijo. Use eventId directamente como clave de idempotencia y nunca lo reconstruya a
partir de requestId y phase. Compare con los valores que trata y pase por alto
cualquier otro en lugar de rechazar la entrega.
attack_detected es un evento a nivel de workspace: no tiene requestId ni
visitorId, ni ninguno de los bloques browser, geo, bot o decision — esas
claves sencillamente no están. account_takeover lo produce una visita concreta y
lleva el cuerpo de identificación completo correspondiente a su plan más un bloque
accountAlert. Si procesa todos los eventos en un mismo manejador, compruebe event
antes de tocar campos de visita.
| Versión | Para quién | Cómo cambiar |
|---|---|---|
1 | Webhooks creados antes de que existiera v2 | Sigue siendo el valor por defecto para ellos |
2 | Webhooks nuevos | El conmutador en la tarjeta del webhook en el panel |
El esquema v1 está congelado: ninguno de sus campos cambia, de modo que las integraciones existentes siguen funcionando sin retoques. Todo lo nuevo vive en v2, que es lo que emiten los webhooks nuevos.
{ "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}Los valores cero y vacíos se omiten. Los campos de cadena y numéricos con valor
cero (bot.type para un humano, por ejemplo) no aparecen en el JSON: no los haga
obligatorios en sus esquemas y lea los bloques anidados de forma defensiva.
bot.score y decision.riskScore son decimales en escala 0..100 con un dígito
tras la coma decimal, exactamente los números que el panel informa para esa misma
visita. (En el esquema congelado v1 usan otras unidades: una fracción 0..1 y
0..255, respectivamente.)
bot.type es o el nombre de un bot reconocido o una etiqueta de familia. Consulte
Tipos de bot para conocer el vocabulario: los nombres
internos de las comprobaciones no se exponen nunca, en ningún plan.
| Campo | Tipo | Descripción |
|---|---|---|
version | number | Versión del esquema del payload (2) |
event | string | Tipo de evento |
eventId | string | Identificador de la entrega — la clave de idempotencia |
requestId | string | Identificador de la visita (UUID), compartido por todas sus fases |
phase | string | primary, late, correction; account_takeover lleva beacon |
visitorId | string | Identificador de visitante estable |
linkedId | string | Identificador vinculado proporcionado por el cliente |
tag | string | Etiqueta personalizada proporcionada por el cliente |
timestamp | string | Hora del evento (RFC 3339) |
url | string | URL de la página donde se capturó el evento |
ip | string | Dirección IP del cliente |
userAgent | string | Cadena de user-agent sin procesar del cliente |
browser.name / .version | string | Navegador detectado |
os.name / .version | string | Sistema operativo detectado |
device | string | Clase de dispositivo (p. ej. desktop, mobile) |
geo | object | Geolocalización por IP: country, city, lat, lon, timezone |
network | object | vpn, proxy, tor, datacenter (booleanos) y connectionType |
bot.result | string | human, bot o uncertain |
bot.type | string | Nombre del bot o etiqueta de familia cuando se detecta un bot |
bot.score | number | Puntuación de bot (0–100) |
identification.confidence | number | Confianza de la identificación (0.0–1.0) |
identification.incognito | boolean | Contexto de navegación privada/incógnito |
decision.action | string | real, fake o suspicious |
decision.riskScore | number | Puntuación de riesgo agregada (0–100) |
Pro y superiores — cómo se comporta el visitante a lo largo del tiempo:
{ "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 y superiores — por qué el veredicto salió como salió:
{ "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}Consulte Detección de bots para conocer
el vocabulario de códigos de motivo y qué significa severity.
guidance lleva recomendaciones listas de «qué hacer» por punto de integración, para
que no tenga que derivar una política a partir de puntuaciones en bruto:
{ "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 }}Cada escenario empieza en allow y solo puede subir por la escala:
allow → challenge → review → deny. Dentro de un escenario gana el eje más
estricto que se active, y overall es el más estricto de los cuatro escenarios.
| Recomendación | Pago | Registro | Login | Afiliado |
|---|---|---|---|---|
allow | Procéselo | Créela | Déjele entrar | Abone la conversión |
challenge | 3-D Secure / confirmación | Captcha, confirmación por email o móvil | 2FA reforzado, reautenticación | Márquela como dudosa hasta ver actividad |
review | Procéselo, pero póngalo en cola de revisión | Créela con restricciones | Déjele entrar y genere una alerta | Retenga el pago hasta la revisión |
deny | No procese la transacción | Rechace la creación de la cuenta | No le deje entrar | No abone la conversión |
version es la versión del conjunto de reglas: se incrementa a medida que la lógica
mejora. Guidance es aditivo: los escenarios nuevos llegan como claves nuevas sin
romper el contrato. Gana la fase posterior, salvo en el caso de las recomendaciones
parciales: una entrega calculada sobre un conjunto incompleto de entradas se marca
con "partial": true, y una recomendación parcial no sustituye a una
recomendación completa recibida antes para el mismo requestId. En una entrega
ordinaria el campo partial no aparece en absoluto.
Los umbrales exactos no se documentan deliberadamente. Una recomendación que puede someterse a ingeniería inversa hasta obtener una puntuación deja de ser una defensa.
account_takeoverSolo Business y Enterprise. El cuerpo es el sobre de identificación completo
correspondiente a su plan más un bloque accountAlert, entregado como máximo una vez
por visita:
{ "version": 2, "event": "account_takeover", "eventId": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31:ato", "requestId": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31", "phase": "beacon", // la alerta se levanta desde un beacon; solo el eventId dice "ato" "accountAlert": { "type": "behavior-drift", "accountId": "user-42", // your linkedId for the account "driftScore": 0.83 } // …the remaining identification fields}En v1 este bloque lleva type, linkedId y drift; en v2 se renombran dos campos:
linkedId → accountId y drift → driftScore. Actualice su manejador cuando
cambie payloadVersion, o su lógica de robo de cuentas dejará de ver los datos en
silencio.
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 }}Cada entrega incluye un encabezado X-Tracio-Signature:
X-Tracio-Signature: t=1710432000,v1=5257a869e7ecebed…t es la marca de tiempo Unix (en segundos) en que se firmó la petición.v1 es el HMAC-SHA256 codificado en hexadecimal de "<t>.<rawRequestBody>", con su
secreto de webhook como clave.La marca de tiempo forma parte del contenido firmado, lo que aporta protección frente a replay.
Dos cosas que debe hacer bien, o la verificación fallará en producción:
v1=. Durante una rotación del
secreto, el encabezado lleva dos firmas, y un parser que conserve solo una de
ellas rechazará entregas válidas durante toda la ventana de rotación.// 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")})Las entregas con esquema 2 llevan además X-Tracio-Signature-Ed25519
(t=<unix>,kid=<id>,v1=<base64>). Ambas partes conocen el secreto HMAC, así que el
HMAC demuestra que quien envía conoce el secreto, pero no que la petición se origine
en TRACIO; la firma asimétrica sí. Las claves públicas se publican en
https://api.tracio.ai/.well-known/webhook-keys, indexadas por kid.
Las entregas de prueba enviadas desde el panel se firman solo con HMAC: la clave
privada de plataforma vive en los nodos de entrega y deliberadamente no está
disponible para el panel. Un verificador que exija Ed25519 de forma estricta debe
dejar pasar las entregas de prueba (llevan el sufijo :test en eventId); de lo
contrario, las pruebas desde el panel fallan mientras producción está sana. La misma
precaución vale para las comprobaciones de formato: una entrega de prueba lleva el
requestId con la forma test_<hex> y el literal test_visitor como visitorId, de
modo que un manejador que los valide contra las formas de producción rechazará una
entrega que por lo demás está bien formada.
Tras una rotación, ambos secretos siguen siendo válidos durante 24 horas y el encabezado lleva las dos firmas, de modo que puede actualizar su configuración sin perder entregas. La acción Revoke now acorta la ventana. Actualice el secreto de su lado dentro de esas 24 horas: cuando la ventana se cierra, el secreto antiguo deja de coincidir, y si su endpoint responde a una firma no válida con un 4xx, cinco respuestas así seguidas desactivan el webhook.
| Encabezado | Descripción |
|---|---|
Content-Type | application/json |
X-Tracio-Signature | t=<unix>,v1=<hmac_sha256_hex> — dos v1= durante una ventana de rotación |
X-Tracio-Signature-Ed25519 | Firma de plataforma, t=<unix>,kid=<id>,v1=<base64> (solo v2) |
X-Tracio-Event-Id | Identificador de la entrega — la clave de idempotencia |
X-Tracio-Request-Id | Identificador de la visita (v2, solo eventos de visita) |
X-Tracio-Event-Type | El tipo de evento (solo v2) |
X-Tracio-Delivery-Attempt | Número de intento, empezando en 1 (solo v2) |
X-Tracio-Payload-Version | 2 (solo v2) |
X-Tracio-Webhook-Id | Identificador del webhook que produjo esta entrega |
Las entregas pueden reintentarse, y un reintento lleva el mismo
X-Tracio-Event-Id. Deduplique por él:
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")})Tenga en cuenta que eventId es único por evento, no por webhook: si varios
webhooks del workspace están suscritos al mismo evento, cada uno recibe una entrega
con el mismo identificador. Se construye como <requestId>:<phase>, y por eso las tres
fases de una visita se deduplican de forma independiente en lugar de fundirse en una.
Responda con 2xx: es la única señal de que una entrega ha sido aceptada.
| Respuesta | Qué ocurre |
|---|---|
2xx | Entrega completada |
429 Too Many Requests | No cuenta como fallo ni gasta un intento; se respeta un Retry-After más largo |
408, 425, 5xx, conexión cortada | Se reintenta con una pausa creciente |
410 Gone | El endpoint se considera eliminado — el webhook se desactiva de inmediato |
Otros 4xx | Se reintenta, pero cinco seguidos desactivan el webhook — 400/401/404 no se curan reintentando |
Calendario de reintentos: 5 s → 30 s → 2 min → 10 min → 30 min → 2 h → 6 h (8 intentos). Los primeros reintentos caben dentro de un minuto, así que un reinicio breve de su servicio no le cuesta una notificación. Cada pausa se aleatoriza entre la mitad y el valor completo para que los reintentos no se disparen todos a la vez tras una caída.
La desactivación automática requiere a la vez un umbral (20 fallos consecutivos, o 5 errores de configuración) y al menos 15 minutos consecutivos de fallos: un reinicio breve no puede matar la integración, ni siquiera con muchas entregas en cola. Un intervalo de más de 15 minutos reinicia la cuenta. El panel muestra el motivo, con el código de respuesta y el texto del error, y un botón Re-enable que pone los contadores a cero.
| Plan | Webhooks por workspace |
|---|---|
| Free | No disponible |
| Pro | 5 |
| Business | 20 |
| Enterprise | 100 |
Los endpoints deben ser https con IP pública —las direcciones privadas y de loopback
se rechazan, también en una redirección— y no encadenar más de dos redirecciones.
Solo se siguen las redirecciones 307 y 308. 301, 302 y 303 indican al
cliente que cambie a GET y descarte el cuerpo, así que una entrega no las sigue y el
intento cuenta como fallido. Si su balanceador de carga normaliza la URL (añadiendo
www o una barra final), apunte el webhook directamente a la URL final.
Los webhooks se gestionan en el panel. El panel se apoya en una API de gestión con
alcance de workspace, servida en el host de la aplicación (por ejemplo
https://app.tracio.ai/api/v1), y los endpoints de abajo son justo los que llama.
Todos los endpoints de webhook viven bajo /workspaces/{wsId}.
Esta no es una superficie servidor a servidor. La API de gestión solo acepta el JWT de su sesión del panel, verificado contra su rol de workspace (RBAC); aquí se rechaza una clave secreta
tracio_sk_…. Como esa sesión vive en el navegador y caduca con él, tome las llamadas de abajo como una descripción de lo que hace el panel y no como una integración que automatizar. Para el acceso programático desde su propio backend, use la Server API de solo lectura.
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": [] }'El secreto de firma lo genera TRACIO y se devuelve una sola vez en la creación (y
en la rotación) bajo signingSecret. Guárdelo de forma segura: es la clave que usa
para verificar las firmas.
{ "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" }}En lecturas posteriores el signingSecret aparece enmascarado (null): solo lo
revelan la creación y la rotación del secreto.
| Método | Ruta | Descripción |
|---|---|---|
GET | /workspaces/{wsId}/webhooks | Listar webhooks |
PATCH | /workspaces/{wsId}/webhooks/{webhookId} | Actualizar url / events / status |
DELETE | /workspaces/{wsId}/webhooks/{webhookId} | Eliminar un webhook |
POST | /workspaces/{wsId}/webhooks/{webhookId}/test | Enviar una entrega de prueba firmada |
POST | /workspaces/{wsId}/webhooks/{webhookId}/secret/rotate | Rotar el secreto de firma |
GET | /workspaces/{wsId}/webhooks/{webhookId}/deliveries | Listar los intentos de entrega recientes |
Devuelva un 2xx lo antes posible y procese el payload de forma así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 la acción Test de un webhook (o POST .../webhooks/{webhookId}/test) para
enviar un payload de ejemplo firmado a su endpoint y confirmar que es alcanzable y que
verifica las firmas correctamente.
Para el desarrollo local, exponga su servidor con un túnel como ngrok:
ngrok http 3000# Use the generated URL as your webhook endpoint