Webhooks deliver identification events to your server in real time. Every time a
visitor is identified, TRACIO sends an HTTP POST request to your configured
webhook URL. The request body is the event payload.
They are also the only channel that delivers late verdicts — the ones where a visitor's behavior proved they were automated after the page had already loaded.
Set them up in the dashboard under Settings → Webhooks. Webhooks require the Pro plan or higher.
| Event | When | Plan |
|---|---|---|
identification | On every visit — the primary, late and correction phases | All |
account_takeover | Behavior under an account no longer matches the owner's profile | Business+ |
attack_detected | A spike of bots on your site | Business+ |
reputation_changed | The reputation of the person behind a device has changed | Business+ |
Event names use underscores, never dots — there is no visitor.created or
session.created. reputation_changed requires the person layer, so it only fires
for workspaces where cross-device identity resolution is enabled.
A webhook subscribes to specific types; the separate value * means "every type,
including those added later". An unknown type is rejected with 400 when a
subscription is created or edited, so a typo can't leave you with a webhook that
silently never fires.
identification eventA single visit produces up to three deliveries that share the same requestId:
primary — the initial verdict, at page load.late — enrichment roughly nine seconds later, once the slow checks have landed.correction — a correction based on behavior (pointer, keyboard, scrolling).Correlate them by requestId and tell them apart by phase. The later phase
takes precedence: if primary said human and correction says bot, the
second one is the right answer.
Do not rely on arrival order. Each phase is delivered independently and on its
own retry schedule — if primary went into retry while late succeeded on the
first attempt, you will receive them in reverse order. Determine precedence from
the phase field, not from the time of receipt.
Those three are the only phases of an identification event. One other value
reaches you: account_takeover carries phase: "beacon", because an account
takeover alert is only ever raised from a behavioural beacon.
Note the mismatch this creates, because it affects idempotency. A production
identification delivery has an eventId of exactly <requestId>:<phase>, but
two deliveries break that formula. An account_takeover is <requestId>:ato —
the suffix is the literal ato, not the value of the phase field. A test
delivery sent from the dashboard is <requestId>:test, while the phase in its
schema 2 body still reads primary — and a schema 1 body has no phase field at
all, so the header is the only place that suffix appears. Use eventId as the
idempotency key directly and never reassemble it from requestId and phase.
Match on the values you handle and ignore anything else rather than rejecting the
delivery.
attack_detected is a workspace-level event: it has no requestId, no visitorId
and none of the browser, geo, bot or decision blocks — those keys are
simply absent. account_takeover is produced by a specific visit and carries the
full identification body for your plan plus an accountAlert block. If you parse
every event in one handler, check event before touching visit fields.
| Version | For whom | How to switch |
|---|---|---|
1 | Webhooks created before v2 existed | Remains the default for them |
2 | New webhooks | The toggle on the webhook card in the dashboard |
Schema v1 is frozen — none of its fields change, so existing integrations keep working without edits. Everything new lives in v2, which is what new webhooks emit.
{ "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}Zero and empty values are omitted. String and numeric fields with a zero value
(bot.type for a human, for example) are absent from the JSON — do not make them
required in your schemas, and read nested blocks defensively.
bot.score and decision.riskScore are decimals on a 0..100 scale with one
digit after the decimal point — exactly the numbers the dashboard reports for the
same visit. (In the frozen v1 schema they use different units: a 0..1 fraction
and 0..255 respectively.)
bot.type is either the name of a recognized bot or a family label. See
Bot Types for the vocabulary — internal check
names are never exposed, on any plan.
| Field | Type | Description |
|---|---|---|
version | number | Payload schema version (2) |
event | string | Event type |
eventId | string | Delivery identifier — the idempotency key |
requestId | string | Visit identifier (UUID), shared by all phases of a visit |
phase | string | primary, late, correction; account_takeover carries beacon |
visitorId | string | Stable visitor identifier |
linkedId | string | Linked identifier supplied by the client |
tag | string | Custom tag supplied by the client |
timestamp | string | Event time (RFC 3339) |
url | string | Page URL where the event was captured |
ip | string | Client IP address |
userAgent | string | Raw client user-agent string |
browser.name / .version | string | Detected browser |
os.name / .version | string | Detected operating system |
device | string | Device class (e.g. desktop, mobile) |
geo | object | IP geolocation: country, city, lat, lon, timezone |
network | object | vpn, proxy, tor, datacenter (booleans) and connectionType |
bot.result | string | human, bot or uncertain |
bot.type | string | Bot name or family label when a bot is detected |
bot.score | number | Bot score (0–100) |
identification.confidence | number | Identification confidence (0.0–1.0) |
identification.incognito | boolean | Private/incognito browsing context |
decision.action | string | real, fake or suspicious |
decision.riskScore | number | Aggregate risk score (0–100) |
Pro and above — how the visitor behaves over time:
{ "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 and above — why the verdict came out the way it did:
{ "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}See Bot Detection for the reason-code
vocabulary and what severity means.
guidance carries ready-made "what to do" recommendations per integration point,
so you don't have to derive a policy from raw scores:
{ "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 }}Every scenario starts at allow and only ever moves up the ladder:
allow → challenge → review → deny. Within a scenario the strictest firing
axis wins, and overall is the strictest across all four scenarios.
| Advice | Payment | Registration | Login | Affiliate |
|---|---|---|---|---|
allow | Process it | Create it | Let them in | Credit the conversion |
challenge | 3-D Secure / confirmation | Captcha, email or phone confirmation | Step-up 2FA, re-authenticate | Mark as doubtful until activity shows |
review | Process, but queue for review | Create with restrictions | Let them in, raise an alert | Hold the payout until reviewed |
deny | Do not process the transaction | Refuse to create the account | Do not let them in | Do not credit the conversion |
version is the version of the rule set — it is bumped as the logic improves.
Guidance is additive: new scenarios arrive as new keys without breaking the
contract. The later phase wins, except for partial advice: a delivery computed
on an incomplete set of inputs is marked "partial": true, and partial advice does
not override complete advice received earlier for the same requestId. In an
ordinary delivery the partial field is absent entirely.
Exact thresholds are deliberately not documented. Advice that can be reverse-engineered into a score stops being a defence.
account_takeover eventBusiness and Enterprise only. The body is the full identification envelope for your
plan plus an accountAlert block, delivered at most once per visit:
{ "version": 2, "event": "account_takeover", "eventId": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31:ato", "requestId": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31", "phase": "beacon", // the alert is raised from a beacon; only the eventId says "ato" "accountAlert": { "type": "behavior-drift", "accountId": "user-42", // your linkedId for the account "driftScore": 0.83 } // …the remaining identification fields}In v1 this block carries type, linkedId and drift; in v2 two fields are
renamed — linkedId → accountId and drift → driftScore. Update your handler
when you switch payloadVersion, or your account-takeover logic will silently stop
seeing the data.
attack_detected event{ "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 }}Every delivery includes an X-Tracio-Signature header:
X-Tracio-Signature: t=1710432000,v1=5257a869e7ecebed…t is the Unix timestamp (seconds) when the request was signed.v1 is the hex-encoded HMAC-SHA256 of "<t>.<rawRequestBody>", keyed with your
webhook secret.The timestamp is part of the signed content, which gives replay protection.
Two things to get right, or verification fails in production:
v1= value. During a secret rotation the header
carries two signatures, and a parser that keeps only one of them will reject
valid deliveries for the whole rotation window.// 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")})Schema 2 deliveries additionally carry X-Tracio-Signature-Ed25519
(t=<unix>,kid=<id>,v1=<base64>). Both sides know the HMAC secret, so HMAC proves
the sender knows the secret but not that TRACIO originated the request; the
asymmetric signature does. Public keys are published at
https://api.tracio.ai/.well-known/webhook-keys, keyed by kid.
Test deliveries sent from the dashboard are signed with HMAC only — the platform
private key lives on the delivery nodes and is deliberately not available to the
dashboard. A verifier that hard-requires Ed25519 must let test deliveries
through (they carry a :test suffix on eventId), otherwise testing from the
dashboard fails while production is healthy. The same caution applies to format
checks: a test delivery carries requestId in the form test_<hex> and the
literal test_visitor as visitorId, so a handler that validates those against
the production shapes will reject a delivery that is otherwise well formed.
After a rotation both secrets stay valid for 24 hours and the header carries both signatures, so you can update your configuration without losing deliveries. The Revoke now action cuts the window short. Update the secret on your side within 24 hours: once the window closes the old secret stops matching, and if your endpoint answers an invalid signature with a 4xx, five such responses in a row disable the webhook.
| Header | Description |
|---|---|
Content-Type | application/json |
X-Tracio-Signature | t=<unix>,v1=<hmac_sha256_hex> — two v1= during a rotation window |
X-Tracio-Signature-Ed25519 | Platform signature, t=<unix>,kid=<id>,v1=<base64> (v2 only) |
X-Tracio-Event-Id | Delivery identifier — the idempotency key |
X-Tracio-Request-Id | Visit identifier (v2, visit events only) |
X-Tracio-Event-Type | The event type (v2 only) |
X-Tracio-Delivery-Attempt | Attempt number, starting at 1 (v2 only) |
X-Tracio-Payload-Version | 2 (v2 only) |
X-Tracio-Webhook-Id | Identifier of the webhook that produced this delivery |
Deliveries may be retried, and a retry carries the same X-Tracio-Event-Id.
Deduplicate on it:
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 that eventId is unique per event, not per webhook: if several webhooks
in the workspace subscribe to the same event, each receives a delivery with the
same identifier. It is built as <requestId>:<phase>, which is why the three
phases of one visit deduplicate independently instead of collapsing into one.
Respond with 2xx — it is the only sign that a delivery was accepted.
| Response | What happens |
|---|---|
2xx | Delivery complete |
429 Too Many Requests | Not counted as a failure and does not spend an attempt; a longer Retry-After is honored |
408, 425, 5xx, dropped connection | Retried with a growing pause |
410 Gone | The endpoint is treated as removed — the webhook is disabled immediately |
Other 4xx | Retried, but five in a row disable the webhook — 400/401/404 are not cured by retrying |
Retry schedule: 5s → 30s → 2min → 10min → 30min → 2h → 6h (8 attempts). The first retries fit inside a minute, so a brief restart of your service does not cost you a notification. Each pause is randomized between half and the full value so retries do not fire in a single volley after an outage.
Auto-disable requires both a threshold (20 consecutive failures, or 5 configuration errors) and at least 15 consecutive minutes of failures — a brief restart cannot kill the integration even if many deliveries were queued. A gap longer than 15 minutes restarts the count. The dashboard shows the reason, with the response code and error text, and a Re-enable button that resets the counters.
| Plan | Webhooks per workspace |
|---|---|
| Free | Not available |
| Pro | 5 |
| Business | 20 |
| Enterprise | 100 |
Endpoints must be https with a public IP — private and loopback addresses are
rejected, including on a redirect — and no more than two redirects deep.
Only 307 and 308 redirects are followed. 301, 302 and 303 instruct
the client to switch to GET and drop the body, so a delivery does not follow them
and the attempt counts as failed. If your load balancer normalizes the URL (adding
www or a trailing slash), point the webhook straight at the final URL.
Webhooks are managed in the dashboard. The dashboard drives a workspace-scoped
management API, served on the application host (for example
https://app.tracio.ai/api/v1), and the endpoints below are what it calls. Every
webhook endpoint lives under /workspaces/{wsId}.
This is not a server-to-server surface. The management API accepts only your dashboard session JWT, checked against your workspace role (RBAC); a
tracio_sk_…secret key is rejected here. Since that session lives in the browser and expires with it, treat the calls below as a description of what the dashboard does rather than as an integration to automate. For programmatic access from your own backend, use the read-only Server API.
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": [] }'The signing secret is generated by TRACIO and returned once on creation (and on
rotate) under signingSecret. Store it securely — it is the key you use to verify
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" }}On subsequent reads the signingSecret is masked (null) — it is revealed only by
create and secret-rotate.
| Method | Path | Description |
|---|---|---|
GET | /workspaces/{wsId}/webhooks | List webhooks |
PATCH | /workspaces/{wsId}/webhooks/{webhookId} | Update url / events / status |
DELETE | /workspaces/{wsId}/webhooks/{webhookId} | Delete a webhook |
POST | /workspaces/{wsId}/webhooks/{webhookId}/test | Send a signed test delivery |
POST | /workspaces/{wsId}/webhooks/{webhookId}/secret/rotate | Rotate the signing secret |
GET | /workspaces/{wsId}/webhooks/{webhookId}/deliveries | List recent delivery attempts |
Return a 2xx as fast as possible and process the payload asynchronously to avoid
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 the Test action on a webhook (or POST .../webhooks/{webhookId}/test) to
send a signed sample payload to your endpoint and confirm it is reachable and
verifying signatures correctly.
For local development, expose your server with a tunnel such as ngrok:
ngrok http 3000# Use the generated URL as your webhook endpoint