Les webhooks livrent les événements d'identification à votre serveur en temps réel.
Chaque fois qu'un visiteur est identifié, TRACIO envoie une requête HTTP POST à
l'URL de webhook que vous avez configurée. Le corps de la requête est le payload
de l'événement.
Ils sont aussi le seul canal qui livre les verdicts tardifs — ceux où le comportement d'un visiteur a prouvé qu'il était automatisé après le chargement de la page.
Configurez-les dans le tableau de bord, sous Settings → Webhooks. Les webhooks nécessitent l'offre Pro ou supérieure.
| Événement | Quand | Offre |
|---|---|---|
identification | À chaque visite — les phases primary, late et correction | Toutes |
account_takeover | Le comportement sur un compte ne correspond plus au profil de son propriétaire | Business+ |
attack_detected | Un pic de bots sur votre site | Business+ |
reputation_changed | La réputation de la personne derrière un appareil a changé | Business+ |
Les noms d'événements utilisent des tirets bas, jamais des points : il n'existe ni
visitor.created ni session.created. reputation_changed nécessite la couche
personne et ne se déclenche donc que pour les espaces de travail où la résolution
d'identité entre appareils est activée.
Un webhook s'abonne à des types précis ; la valeur distincte * signifie « tous les
types, y compris ceux ajoutés par la suite ». Un type inconnu est rejeté avec un 400
à la création ou à la modification d'un abonnement : une faute de frappe ne peut donc
pas vous laisser un webhook qui ne se déclenche jamais en silence.
identificationUne même visite produit jusqu'à trois livraisons qui partagent le même requestId :
primary — le verdict initial, au chargement de la page.late — enrichissement environ neuf secondes plus tard, une fois les contrôles lents arrivés.correction — une correction fondée sur le comportement (pointeur, clavier, défilement).Corrélez-les par requestId et distinguez-les par phase. La phase la plus tardive
prime : si primary disait human et que correction dit bot, c'est la seconde
qui a raison.
Ne vous fiez pas à l'ordre d'arrivée. Chaque phase est livrée indépendamment et
avec son propre calendrier de nouvelles tentatives — si primary est passé en
nouvelle tentative pendant que late a réussi du premier coup, vous les recevrez dans
l'ordre inverse. Déterminez la priorité à partir du champ phase, pas de l'heure de
réception.
Ces trois-là sont les seules phases d'un événement identification. Une autre valeur
vous parvient : account_takeover porte phase: "beacon", car une alerte de prise de
contrôle de compte n'est jamais levée que depuis un beacon comportemental.
Notez le décalage que cela crée, car il touche à l'idempotence. Une livraison
identification de production a un eventId valant exactement <requestId>:<phase>,
mais deux livraisons brisent cette formule. Un account_takeover vaut
<requestId>:ato — le suffixe est le littéral ato, pas la valeur du champ phase.
Une livraison de test envoyée depuis le tableau de bord vaut <requestId>:test, alors
que la phase de son corps au schéma 2 indique toujours primary — et un corps au
schéma 1 n'a pas de champ phase du tout, si bien que l'en-tête est le seul endroit où
ce suffixe apparaît. Utilisez eventId directement comme clé d'idempotence et ne le
reconstituez jamais à partir de requestId et de phase. Comparez aux valeurs que
vous traitez et ignorez le reste plutôt que de rejeter la livraison.
attack_detected est un événement au niveau de l'espace de travail : il n'a ni
requestId, ni visitorId, ni aucun des blocs browser, geo, bot ou
decision — ces clés sont tout simplement absentes. account_takeover est produit
par une visite précise et porte le corps d'identification complet correspondant à
votre offre, plus un bloc accountAlert. Si vous analysez tous les événements dans un
seul handler, vérifiez event avant de toucher aux champs de visite.
| Version | Pour qui | Comment basculer |
|---|---|---|
1 | Webhooks créés avant l'existence de la v2 | Reste la valeur par défaut pour eux |
2 | Nouveaux webhooks | Le commutateur sur la carte du webhook dans le tableau de bord |
Le schéma v1 est gelé — aucun de ses champs ne change, de sorte que les intégrations existantes continuent de fonctionner sans modification. Tout ce qui est nouveau vit dans la v2, qui est ce qu'émettent les nouveaux webhooks.
{ "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}Les valeurs nulles et vides sont omises. Les champs de type chaîne et numériques
ayant une valeur nulle (bot.type pour un humain, par exemple) sont absents du JSON —
ne les rendez pas obligatoires dans vos schémas et lisez les blocs imbriqués de
manière défensive.
bot.score et decision.riskScore sont des décimaux sur une échelle 0..100
avec un chiffre après la virgule — exactement les nombres que le tableau de bord
rapporte pour la même visite. (Dans le schéma gelé v1, ils utilisent d'autres unités :
une fraction 0..1 et 0..255 respectivement.)
bot.type est soit le nom d'un bot reconnu, soit une étiquette de famille. Voir
Types de bot pour le vocabulaire — les noms internes
des contrôles ne sont jamais exposés, sur aucune offre.
| Champ | Type | Description |
|---|---|---|
version | number | Version du schéma de payload (2) |
event | string | Type d'événement |
eventId | string | Identifiant de la livraison — la clé d'idempotence |
requestId | string | Identifiant de la visite (UUID), partagé par toutes ses phases |
phase | string | primary, late, correction ; account_takeover porte beacon |
visitorId | string | Identifiant de visiteur stable |
linkedId | string | Identifiant lié fourni par le client |
tag | string | Étiquette personnalisée fournie par le client |
timestamp | string | Heure de l'événement (RFC 3339) |
url | string | URL de la page où l'événement a été capturé |
ip | string | Adresse IP du client |
userAgent | string | Chaîne user-agent brute du client |
browser.name / .version | string | Navigateur détecté |
os.name / .version | string | Système d'exploitation détecté |
device | string | Classe d'appareil (p. ex. desktop, mobile) |
geo | object | Géolocalisation par IP : country, city, lat, lon, timezone |
network | object | vpn, proxy, tor, datacenter (booléens) et connectionType |
bot.result | string | human, bot ou uncertain |
bot.type | string | Nom du bot ou étiquette de famille lorsqu'un bot est détecté |
bot.score | number | Score de bot (0–100) |
identification.confidence | number | Confiance de l'identification (0.0–1.0) |
identification.incognito | boolean | Contexte de navigation privée |
decision.action | string | real, fake ou suspicious |
decision.riskScore | number | Score de risque agrégé (0–100) |
Pro et au-delà — comment le visiteur se comporte dans la durée :
{ "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 et au-delà — pourquoi le verdict est ce qu'il est :
{ "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}Voir Détection des bots pour le vocabulaire
des codes de motif et la signification de severity.
guidance porte des recommandations « quoi faire » prêtes à l'emploi par point
d'intégration, pour que vous n'ayez pas à déduire une politique de scores bruts :
{ "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 }}Chaque scénario démarre à allow et ne peut que monter dans l'échelle :
allow → challenge → review → deny. Au sein d'un scénario, l'axe le plus strict
qui se déclenche l'emporte, et overall est le plus strict des quatre scénarios.
| Recommandation | Paiement | Inscription | Connexion | Affiliation |
|---|---|---|---|---|
allow | Traitez-le | Créez-le | Laissez entrer | Créditez la conversion |
challenge | 3-D Secure / confirmation | Captcha, confirmation par e-mail ou SMS | 2FA renforcée, ré-authentification | Marquez comme douteuse jusqu'à voir de l'activité |
review | Traitez, mais mettez en file de revue | Créez avec des restrictions | Laissez entrer et levez une alerte | Retenez le paiement jusqu'à revue |
deny | Ne traitez pas la transaction | Refusez la création du compte | Ne laissez pas entrer | Ne créditez pas la conversion |
version est la version du jeu de règles — elle est incrémentée à mesure que la
logique s'améliore. Guidance est additif : les nouveaux scénarios arrivent sous forme
de nouvelles clés sans casser le contrat. La phase la plus tardive l'emporte, sauf
pour les recommandations partielles : une livraison calculée sur un ensemble
d'entrées incomplet est marquée "partial": true, et une recommandation partielle ne
remplace pas une recommandation complète reçue plus tôt pour le même requestId.
Dans une livraison ordinaire, le champ partial est totalement absent.
Les seuils exacts ne sont délibérément pas documentés. Une recommandation qui peut être rétro-analysée jusqu'à un score cesse d'être une défense.
account_takeoverBusiness et Enterprise uniquement. Le corps est l'enveloppe d'identification complète
correspondant à votre offre, plus un bloc accountAlert, livré au plus une fois par
visite :
{ "version": 2, "event": "account_takeover", "eventId": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31:ato", "requestId": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31", "phase": "beacon", // l'alerte est levée depuis un beacon ; seul l'eventId dit "ato" "accountAlert": { "type": "behavior-drift", "accountId": "user-42", // your linkedId for the account "driftScore": 0.83 } // …the remaining identification fields}En v1, ce bloc porte type, linkedId et drift ; en v2, deux champs sont renommés
— linkedId → accountId et drift → driftScore. Mettez à jour votre handler
lorsque vous basculez payloadVersion, sinon votre logique de prise de contrôle de
compte cessera silencieusement de voir les données.
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 }}Chaque livraison inclut un en-tête X-Tracio-Signature :
X-Tracio-Signature: t=1710432000,v1=5257a869e7ecebed…t est l'horodatage Unix (en secondes) auquel la requête a été signée.v1 est le HMAC-SHA256 encodé en hexadécimal de "<t>.<rawRequestBody>", avec
votre secret de webhook comme clé.L'horodatage fait partie du contenu signé, ce qui apporte une protection contre le rejeu.
Deux points à ne pas manquer, sinon la vérification échoue en production :
v1=. Pendant une
rotation de secret, l'en-tête porte deux signatures, et un parseur qui n'en
conserve qu'une rejettera des livraisons valides pendant toute la fenêtre de
rotation.// 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")})Les livraisons au schéma 2 portent en plus X-Tracio-Signature-Ed25519
(t=<unix>,kid=<id>,v1=<base64>). Les deux parties connaissent le secret HMAC : le
HMAC prouve donc que l'expéditeur connaît le secret, mais pas que la requête émane de
TRACIO ; la signature asymétrique, si. Les clés publiques sont publiées sur
https://api.tracio.ai/.well-known/webhook-keys, indexées par kid.
Les livraisons de test envoyées depuis le tableau de bord sont signées en HMAC
uniquement — la clé privée de plateforme réside sur les nœuds de livraison et n'est
délibérément pas accessible au tableau de bord. Un vérificateur qui exige
strictement Ed25519 doit laisser passer les livraisons de test (elles portent un
suffixe :test sur eventId), faute de quoi les tests depuis le tableau de bord
échouent alors que la production se porte bien. La même prudence vaut pour les
contrôles de format : une livraison de test porte un requestId de la forme
test_<hex> et le littéral test_visitor comme visitorId, si bien qu'un handler qui
les valide contre les formes de production rejettera une livraison par ailleurs
parfaitement formée.
Après une rotation, les deux secrets restent valides pendant 24 heures et l'en-tête porte les deux signatures : vous pouvez donc mettre à jour votre configuration sans perdre de livraisons. L'action Revoke now écourte la fenêtre. Mettez à jour le secret de votre côté dans les 24 heures : une fois la fenêtre fermée, l'ancien secret cesse de correspondre, et si votre endpoint répond à une signature invalide par un 4xx, cinq réponses de ce type d'affilée désactivent le webhook.
| En-tête | Description |
|---|---|
Content-Type | application/json |
X-Tracio-Signature | t=<unix>,v1=<hmac_sha256_hex> — deux v1= pendant une fenêtre de rotation |
X-Tracio-Signature-Ed25519 | Signature de plateforme, t=<unix>,kid=<id>,v1=<base64> (v2 uniquement) |
X-Tracio-Event-Id | Identifiant de la livraison — la clé d'idempotence |
X-Tracio-Request-Id | Identifiant de la visite (v2, événements de visite uniquement) |
X-Tracio-Event-Type | Le type d'événement (v2 uniquement) |
X-Tracio-Delivery-Attempt | Numéro de tentative, à partir de 1 (v2 uniquement) |
X-Tracio-Payload-Version | 2 (v2 uniquement) |
X-Tracio-Webhook-Id | Identifiant du webhook qui a produit cette livraison |
Les livraisons peuvent faire l'objet de nouvelles tentatives, et une nouvelle
tentative porte le même X-Tracio-Event-Id. Dédupliquez dessus :
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")})Notez que eventId est unique par événement, et non par webhook : si plusieurs
webhooks de l'espace de travail sont abonnés au même événement, chacun reçoit une
livraison portant le même identifiant. Il se construit comme <requestId>:<phase>,
c'est pourquoi les trois phases d'une visite se dédupliquent indépendamment au lieu de
se confondre en une seule.
Répondez par un 2xx — c'est le seul signe qu'une livraison a été acceptée.
| Réponse | Ce qui se passe |
|---|---|
2xx | Livraison terminée |
429 Too Many Requests | N'est pas comptée comme un échec et ne consomme pas de tentative ; un Retry-After plus long est respecté |
408, 425, 5xx, connexion coupée | Nouvelle tentative avec une pause croissante |
410 Gone | L'endpoint est considéré comme supprimé — le webhook est désactivé immédiatement |
Autres 4xx | Nouvelle tentative, mais cinq d'affilée désactivent le webhook — 400/401/404 ne se corrigent pas en réessayant |
Calendrier des nouvelles tentatives : 5 s → 30 s → 2 min → 10 min → 30 min → 2 h → 6 h (8 tentatives). Les premières tiennent dans la minute : un redémarrage bref de votre service ne vous coûte donc pas une notification. Chaque pause est randomisée entre la moitié et la valeur complète, afin que les tentatives ne repartent pas toutes en même temps après une panne.
La désactivation automatique exige à la fois un seuil (20 échecs consécutifs, ou 5 erreurs de configuration) et au moins 15 minutes consécutives d'échecs — un redémarrage bref ne peut pas tuer l'intégration, même si de nombreuses livraisons étaient en file. Un intervalle de plus de 15 minutes remet le compteur à zéro. Le tableau de bord affiche la raison, avec le code de réponse et le texte de l'erreur, ainsi qu'un bouton Re-enable qui réinitialise les compteurs.
| Offre | Webhooks par espace de travail |
|---|---|
| Free | Non disponible |
| Pro | 5 |
| Business | 20 |
| Enterprise | 100 |
Les endpoints doivent être en https avec une IP publique — les adresses privées et
de loopback sont rejetées, y compris lors d'une redirection — et ne pas dépasser deux
redirections de profondeur.
Seules les redirections 307 et 308 sont suivies. 301, 302 et 303
demandent au client de basculer en GET et d'abandonner le corps : une livraison ne les
suit donc pas et la tentative est comptée comme échouée. Si votre répartiteur de charge
normalise l'URL (en ajoutant www ou une barre oblique finale), faites pointer le
webhook directement sur l'URL finale.
Les webhooks se gèrent dans le tableau de bord. Celui-ci s'appuie sur une API de
gestion à portée d'espace de travail, servie sur l'hôte de l'application (par exemple
https://app.tracio.ai/api/v1), et les endpoints ci-dessous sont exactement ceux qu'il
appelle. Tous les endpoints de webhook se trouvent sous /workspaces/{wsId}.
Ce n'est pas une surface serveur à serveur. L'API de gestion n'accepte que le JWT de votre session de tableau de bord, contrôlé par rapport à votre rôle dans l'espace de travail (RBAC) ; une clé secrète
tracio_sk_…y est refusée. Comme cette session vit dans le navigateur et expire avec lui, considérez les appels ci-dessous comme la description de ce que fait le tableau de bord, et non comme une intégration à automatiser. Pour un accès programmatique depuis votre propre backend, utilisez la Server API en lecture seule.
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": [] }'Le secret de signature est généré par TRACIO et renvoyé une seule fois à la
création (et à la rotation) sous signingSecret. Conservez-le de manière sécurisée —
c'est la clé qui vous sert à vérifier les signatures.
{ "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" }}Lors des lectures suivantes, le signingSecret est masqué (null) — il n'est révélé
que par la création et la rotation du secret.
| Méthode | Chemin | Description |
|---|---|---|
GET | /workspaces/{wsId}/webhooks | Lister les webhooks |
PATCH | /workspaces/{wsId}/webhooks/{webhookId} | Mettre à jour url / events / status |
DELETE | /workspaces/{wsId}/webhooks/{webhookId} | Supprimer un webhook |
POST | /workspaces/{wsId}/webhooks/{webhookId}/test | Envoyer une livraison de test signée |
POST | /workspaces/{wsId}/webhooks/{webhookId}/secret/rotate | Faire tourner le secret de signature |
GET | /workspaces/{wsId}/webhooks/{webhookId}/deliveries | Lister les tentatives de livraison récentes |
Renvoyez un 2xx le plus vite possible et traitez le payload de façon asynchrone pour
éviter les 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) }}Utilisez l'action Test sur un webhook (ou POST .../webhooks/{webhookId}/test)
pour envoyer un payload d'exemple signé à votre endpoint et confirmer qu'il est
joignable et qu'il vérifie correctement les signatures.
Pour le développement local, exposez votre serveur avec un tunnel tel que ngrok :
ngrok http 3000# Use the generated URL as your webhook endpoint