I webhook recapitano al suo server gli eventi di identificazione in tempo reale. Ogni
volta che un visitatore viene identificato, TRACIO invia una richiesta HTTP POST
all'URL di webhook che ha configurato. Il corpo della richiesta è il payload
dell'evento.
Sono inoltre l'unico canale che recapita i verdetti tardivi — quelli in cui il comportamento di un visitatore ha dimostrato che era automatizzato dopo che la pagina si era già caricata.
Li configuri nella dashboard, sotto Settings → Webhooks. I webhook richiedono il piano Pro o superiore.
| Evento | Quando | Piano |
|---|---|---|
identification | A ogni visita — le fasi primary, late e correction | Tutti |
account_takeover | Il comportamento su un account non corrisponde più al profilo del suo titolare | Business+ |
attack_detected | Un picco di bot sul suo sito | Business+ |
reputation_changed | La reputazione della persona dietro un dispositivo è cambiata | Business+ |
I nomi degli eventi usano underscore, mai punti — non esistono visitor.created o
session.created. reputation_changed richiede il livello person, quindi scatta solo
per i workspace in cui è attiva la risoluzione dell'identità cross-device.
Un webhook si iscrive a tipi specifici; il valore separato * significa «ogni tipo,
compresi quelli aggiunti in seguito». Un tipo sconosciuto viene rifiutato con 400
alla creazione o alla modifica di un'iscrizione, così un refuso non può lasciarle un
webhook che in silenzio non scatta mai.
identificationUna singola visita produce fino a tre recapiti che condividono lo stesso
requestId:
primary — il verdetto iniziale, al caricamento della pagina.late — arricchimento circa nove secondi dopo, una volta arrivati i controlli lenti.correction — una correzione basata sul comportamento (puntatore, tastiera, scorrimento).Li correli tramite requestId e li distingua tramite phase. La fase successiva ha
la precedenza: se primary diceva human e correction dice bot, la risposta
giusta è la seconda.
Non faccia affidamento sull'ordine di arrivo. Ogni fase viene recapitata in modo
indipendente e con il proprio calendario di retry: se primary è finito in retry
mentre late è riuscito al primo tentativo, li riceverà in ordine inverso. Determini
la precedenza dal campo phase, non dall'orario di ricezione.
Quelle tre sono le uniche fasi di un evento identification. Un altro valore le
arriva comunque: account_takeover porta phase: "beacon", perché un allarme di
compromissione dell'account viene sollevato sempre e solo da un beacon
comportamentale.
Noti il disallineamento che ne nasce, perché tocca l'idempotenza. In produzione un
recapito identification ha un eventId esattamente pari a <requestId>:<phase>, ma
due recapiti rompono quella formula. Un account_takeover è <requestId>:ato — il
suffisso è il letterale ato, non il valore del campo phase. Un recapito di prova
inviato dalla dashboard è <requestId>:test, mentre il phase nel suo corpo schema 2
riporta ancora primary — e un corpo schema 1 non ha affatto il campo phase, quindi
l'unico posto in cui quel suffisso compare è l'header. Usi eventId direttamente come
chiave di idempotenza e non lo ricomponga mai da requestId e phase. Faccia
corrispondere i valori che gestisce e ignori tutto il resto, invece di rifiutare il
recapito.
attack_detected è un evento a livello di workspace: non ha requestId, né
visitorId, né alcuno dei blocchi browser, geo, bot o decision — quelle
chiavi semplicemente non ci sono. account_takeover è prodotto da una visita
specifica e porta il corpo di identificazione completo previsto dal suo piano più un
blocco accountAlert. Se elabora tutti gli eventi in un unico handler, controlli
event prima di toccare i campi di visita.
| Versione | Per chi | Come passare |
|---|---|---|
1 | Webhook creati prima che esistesse la v2 | Resta l'impostazione predefinita per loro |
2 | Nuovi webhook | L'interruttore sulla scheda del webhook nella dashboard |
Lo schema v1 è congelato: nessuno dei suoi campi cambia, così le integrazioni esistenti continuano a funzionare senza modifiche. Tutto ciò che è nuovo vive nella v2, che è ciò che emettono i nuovi webhook.
{ "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}I valori zero e vuoti vengono omessi. I campi stringa e numerici con valore zero
(bot.type per un umano, per esempio) sono assenti dal JSON: non li renda obbligatori
nei suoi schemi e legga i blocchi annidati in modo difensivo.
bot.score e decision.riskScore sono decimali su scala 0..100 con una cifra
dopo la virgola, esattamente i numeri che la dashboard riporta per la stessa visita.
(Nello schema congelato v1 usano unità diverse: rispettivamente una frazione 0..1 e
0..255.)
bot.type è il nome di un bot riconosciuto oppure un'etichetta di famiglia. Veda
Tipi di bot per il vocabolario: i nomi interni dei
controlli non vengono mai esposti, su nessun piano.
| Campo | Tipo | Descrizione |
|---|---|---|
version | number | Versione dello schema del payload (2) |
event | string | Tipo di evento |
eventId | string | Identificatore del recapito — la chiave di idempotenza |
requestId | string | Identificatore della visita (UUID), condiviso da tutte le sue fasi |
phase | string | primary, late, correction; account_takeover porta beacon |
visitorId | string | Identificatore di visitatore stabile |
linkedId | string | Identificatore collegato fornito dal client |
tag | string | Tag personalizzato fornito dal client |
timestamp | string | Orario dell'evento (RFC 3339) |
url | string | URL della pagina in cui l'evento è stato catturato |
ip | string | Indirizzo IP del client |
userAgent | string | Stringa user-agent grezza del client |
browser.name / .version | string | Browser rilevato |
os.name / .version | string | Sistema operativo rilevato |
device | string | Classe di dispositivo (ad es. desktop, mobile) |
geo | object | Geolocalizzazione da IP: country, city, lat, lon, timezone |
network | object | vpn, proxy, tor, datacenter (booleani) e connectionType |
bot.result | string | human, bot o uncertain |
bot.type | string | Nome del bot o etichetta di famiglia quando viene rilevato un bot |
bot.score | number | Punteggio di bot (0–100) |
identification.confidence | number | Confidenza dell'identificazione (0.0–1.0) |
identification.incognito | boolean | Contesto di navigazione privata/in incognito |
decision.action | string | real, fake o suspicious |
decision.riskScore | number | Punteggio di rischio aggregato (0–100) |
Da Pro in su — come si comporta il visitatore nel 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}Da Business in su — perché il verdetto è quello che è:
{ "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}Veda Rilevamento dei bot per il vocabolario dei
codici motivo e per il significato di severity.
guidance porta raccomandazioni pronte su «che cosa fare» per ciascun punto di
integrazione, così non deve ricavare una policy dai punteggi grezzi:
{ "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 }}Ogni scenario parte da allow e può solo salire lungo la scala:
allow → challenge → review → deny. All'interno di uno scenario vince l'asse
più severo che scatta, e overall è il più severo fra i quattro scenari.
| Indicazione | Pagamento | Registrazione | Login | Affiliazione |
|---|---|---|---|---|
allow | Lo elabori | Lo crei | Lo faccia entrare | Accrediti la conversione |
challenge | 3-D Secure / conferma | Captcha, conferma via e-mail o telefono | 2FA rafforzata, riautenticazione | La segni come dubbia finché non c'è attività |
review | Lo elabori, ma lo metta in coda di revisione | Lo crei con restrizioni | Lo faccia entrare e alzi un alert | Trattenga il pagamento fino alla revisione |
deny | Non elabori la transazione | Rifiuti la creazione dell'account | Non lo faccia entrare | Non accrediti la conversione |
version è la versione dell'insieme di regole: viene incrementata man mano che la
logica migliora. Guidance è additivo: nuovi scenari arrivano come nuove chiavi senza
rompere il contratto. Vince la fase successiva, tranne che per le indicazioni
parziali: un recapito calcolato su un insieme di input incompleto è contrassegnato
con "partial": true, e un'indicazione parziale non sovrascrive un'indicazione
completa ricevuta prima per lo stesso requestId. In un recapito ordinario il campo
partial è del tutto assente.
Le soglie esatte non sono documentate deliberatamente. Un'indicazione che può essere sottoposta a reverse engineering fino a un punteggio smette di essere una difesa.
account_takeoverSolo Business ed Enterprise. Il corpo è l'envelope di identificazione completo
previsto dal suo piano più un blocco accountAlert, recapitato al massimo una volta
per visita:
{ "version": 2, "event": "account_takeover", "eventId": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31:ato", "requestId": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31", "phase": "beacon", // l'allarme nasce da un beacon; solo l'eventId dice "ato" "accountAlert": { "type": "behavior-drift", "accountId": "user-42", // your linkedId for the account "driftScore": 0.83 } // …the remaining identification fields}Nella v1 questo blocco porta type, linkedId e drift; nella v2 due campi sono
rinominati — linkedId → accountId e drift → driftScore. Aggiorni il suo
handler quando cambia payloadVersion, altrimenti la sua logica di furto di account
smetterà silenziosamente di vedere i dati.
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 }}Ogni recapito include un header X-Tracio-Signature:
X-Tracio-Signature: t=1710432000,v1=5257a869e7ecebed…t è il timestamp Unix (in secondi) in cui la richiesta è stata firmata.v1 è l'HMAC-SHA256 codificato in esadecimale di "<t>.<rawRequestBody>", con il
suo secret di webhook come chiave.Il timestamp fa parte del contenuto firmato, il che offre protezione dai replay.
Due cose da fare bene, altrimenti la verifica fallisce in produzione:
v1=. Durante una rotazione
del secret l'header porta due firme, e un parser che ne conserva solo una
rifiuterà recapiti validi per l'intera finestra di rotazione.// 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")})I recapiti con schema 2 portano in aggiunta X-Tracio-Signature-Ed25519
(t=<unix>,kid=<id>,v1=<base64>). Entrambe le parti conoscono il secret HMAC, quindi
l'HMAC dimostra che il mittente conosce il secret, ma non che la richiesta sia
originata da TRACIO; la firma asimmetrica sì. Le chiavi pubbliche sono pubblicate su
https://api.tracio.ai/.well-known/webhook-keys, indicizzate per kid.
I recapiti di prova inviati dalla dashboard sono firmati solo con HMAC: la chiave
privata di piattaforma risiede sui nodi di recapito e deliberatamente non è
disponibile alla dashboard. Un verificatore che richieda tassativamente Ed25519 deve
lasciar passare i recapiti di prova (portano il suffisso :test su eventId),
altrimenti i test dalla dashboard falliscono mentre la produzione è in salute. La
stessa cautela vale per i controlli di formato: un recapito di prova porta requestId
nella forma test_<hex> e il letterale test_visitor come visitorId, quindi un
handler che li convalida rispetto alle forme di produzione rifiuterà un recapito per
tutto il resto perfettamente valido.
Dopo una rotazione entrambi i secret restano validi per 24 ore e l'header porta entrambe le firme, così può aggiornare la configurazione senza perdere recapiti. L'azione Revoke now accorcia la finestra. Aggiorni il secret dalla sua parte entro 24 ore: quando la finestra si chiude, il vecchio secret smette di corrispondere, e se il suo endpoint risponde a una firma non valida con un 4xx, cinque risposte di seguito disattivano il webhook.
| Header | Descrizione |
|---|---|
Content-Type | application/json |
X-Tracio-Signature | t=<unix>,v1=<hmac_sha256_hex> — due v1= durante una finestra di rotazione |
X-Tracio-Signature-Ed25519 | Firma di piattaforma, t=<unix>,kid=<id>,v1=<base64> (solo v2) |
X-Tracio-Event-Id | Identificatore del recapito — la chiave di idempotenza |
X-Tracio-Request-Id | Identificatore della visita (v2, solo eventi di visita) |
X-Tracio-Event-Type | Il tipo di evento (solo v2) |
X-Tracio-Delivery-Attempt | Numero del tentativo, a partire da 1 (solo v2) |
X-Tracio-Payload-Version | 2 (solo v2) |
X-Tracio-Webhook-Id | Identificatore del webhook che ha prodotto questo recapito |
I recapiti possono essere ritentati, e un retry porta lo stesso
X-Tracio-Event-Id. Deduplichi su di esso:
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")})Noti che eventId è univoco per evento, non per webhook: se più webhook del
workspace sono iscritti allo stesso evento, ciascuno riceve un recapito con lo stesso
identificatore. È costruito come <requestId>:<phase>, ed è per questo che le tre
fasi di una stessa visita si deduplicano in modo indipendente invece di collassare in
una sola.
Risponda con 2xx: è l'unico segnale che un recapito è stato accettato.
| Risposta | Che cosa succede |
|---|---|
2xx | Recapito completato |
429 Too Many Requests | Non conta come fallimento e non consuma un tentativo; un Retry-After più lungo viene rispettato |
408, 425, 5xx, connessione caduta | Ritentato con una pausa crescente |
410 Gone | L'endpoint è considerato rimosso — il webhook viene disattivato immediatamente |
Altri 4xx | Ritentato, ma cinque di fila disattivano il webhook — 400/401/404 non si curano ritentando |
Calendario dei retry: 5 s → 30 s → 2 min → 10 min → 30 min → 2 h → 6 h (8 tentativi). I primi retry stanno dentro un minuto, quindi un riavvio breve del suo servizio non le costa una notifica. Ogni pausa è randomizzata fra la metà e il valore pieno, così i retry non partono tutti insieme dopo un'interruzione.
La disattivazione automatica richiede sia una soglia (20 fallimenti consecutivi, o 5 errori di configurazione) sia almeno 15 minuti consecutivi di fallimenti: un riavvio breve non può uccidere l'integrazione neanche con molti recapiti in coda. Un intervallo superiore a 15 minuti azzera il conteggio. La dashboard mostra il motivo, con il codice di risposta e il testo dell'errore, e un pulsante Re-enable che riporta a zero i contatori.
| Piano | Webhook per workspace |
|---|---|
| Free | Non disponibile |
| Pro | 5 |
| Business | 20 |
| Enterprise | 100 |
Gli endpoint devono essere https con IP pubblico — gli indirizzi privati e di
loopback vengono rifiutati, anche su un redirect — e non più profondi di due redirect.
Vengono seguiti solo i redirect 307 e 308. 301, 302 e 303 istruiscono il
client a passare a GET e a scartare il corpo, quindi un recapito non li segue e il
tentativo conta come fallito. Se il suo load balancer normalizza l'URL (aggiungendo
www o una barra finale), punti il webhook direttamente all'URL finale.
I webhook si gestiscono nella dashboard. La dashboard pilota un'API di gestione con
ambito workspace, servita sull'host dell'applicazione (ad esempio
https://app.tracio.ai/api/v1), e gli endpoint qui sotto sono proprio quelli che
chiama. Tutti gli endpoint dei webhook risiedono sotto /workspaces/{wsId}.
Non è una superficie server-to-server. L'API di gestione accetta solo il JWT della sua sessione della dashboard, verificato rispetto al suo ruolo nel workspace (RBAC); una secret key
tracio_sk_…qui viene rifiutata. Poiché quella sessione vive nel browser e scade con esso, consideri le chiamate qui sotto come una descrizione di ciò che fa la dashboard, non come un'integrazione da automatizzare. Per l'accesso programmatico dal suo backend usi la Server API in sola lettura.
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": [] }'Il secret di firma è generato da TRACIO e restituito una sola volta alla creazione
(e alla rotazione) sotto signingSecret. Lo conservi in modo sicuro: è la chiave con
cui verifica le firme.
{ "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" }}Nelle letture successive il signingSecret è mascherato (null): viene rivelato solo
dalla creazione e dalla rotazione del secret.
| Metodo | Percorso | Descrizione |
|---|---|---|
GET | /workspaces/{wsId}/webhooks | Elencare i webhook |
PATCH | /workspaces/{wsId}/webhooks/{webhookId} | Aggiornare url / events / status |
DELETE | /workspaces/{wsId}/webhooks/{webhookId} | Eliminare un webhook |
POST | /workspaces/{wsId}/webhooks/{webhookId}/test | Inviare un recapito di prova firmato |
POST | /workspaces/{wsId}/webhooks/{webhookId}/secret/rotate | Ruotare il secret di firma |
GET | /workspaces/{wsId}/webhooks/{webhookId}/deliveries | Elencare i tentativi di recapito recenti |
Restituisca un 2xx il prima possibile ed elabori il payload in modo asincrono per
evitare i timeout:
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) }}Usi l'azione Test su un webhook (o POST .../webhooks/{webhookId}/test) per
inviare al suo endpoint un payload di esempio firmato e verificare che sia
raggiungibile e che controlli correttamente le firme.
Per lo sviluppo locale, esponga il suo server con un tunnel come ngrok:
ngrok http 3000# Use the generated URL as your webhook endpoint