The Data API (formerly documented here as the Server API) lets your backend read the identification data TRACIO has already collected for your workspace: a visitor's history, individual sessions, and short-window velocity counters.
It complements webhooks rather than replacing them:
| Webhooks | Data API | |
|---|---|---|
| Direction | TRACIO pushes to your endpoint | Your backend pulls on demand |
| Timing | As each identification happens | Any time, over your retention window |
| Best for | Reacting to an event | Looking data up during a decision, backfills, investigations |
Both surfaces are available from the Pro plan and above.
https://api.tracio.ai/v1This is a different host from the browser endpoint (edge.tracio.ai) and from the
dashboard (app.tracio.ai). All three are separate: the browser talks to the edge
with your public key, your backend talks to the Data API with your secret
key.
Every request carries your secret key as a bearer token:
curl -H "Authorization: Bearer tracio_sk_XXXX...XXXX" \ "https://api.tracio.ai/v1/visitors/X7fh2Hg9LkMn3pQr5tBvQw3xZa9mK2pL4nR8dT6y"The Data API is server-to-server only. CORS headers are deliberately not returned, so a browser cannot call it — that is what keeps your secret key out of client-side code. Never ship the secret key to the browser.
Create it in the dashboard under API Keys, choosing the secret type.
tracio_sk_ followed by 43 characters, 53 in total. The
dashboard lists it by its first few characters so you can tell keys apart.Rotating issues a new key and keeps the old one working for 7 days, so you can roll it out without downtime. Deploy the new key, confirm traffic has moved, and let the old one expire. Public keys are not rotatable — they are not secrets and are visible in your page source by design.
Every route is a GET. There are no write operations in the Data API: it reads
data, and your configuration lives in the dashboard.
| Method | Path | Returns |
|---|---|---|
GET | /v1/visitors/{visitorId} | Aggregated history for one visitor, plus their latest session |
GET | /v1/visitors/{visitorId}/sessions | Paginated list of that visitor's sessions |
GET | /v1/visitors/{visitorId}/sessions/latest | The single most recent session |
GET | /v1/visitors/{visitorId}/velocity | Activity counters over a short window |
GET | /v1/sessions/{requestId} | One session by its request identifier |
GET | /.well-known/webhook-keys | Public keys for the webhook platform signature (no auth) |
A trailing slash is accepted and ignored. An unknown path or a wrong method returns the same JSON error envelope as everything else, never an HTML or plain-text page.
Every read is bounded by a time window, controlled by two optional query parameters:
| Parameter | Accepts |
|---|---|
from | YYYY-MM-DD or a full RFC 3339 timestamp |
to | YYYY-MM-DD or a full RFC 3339 timestamp |
to includes that whole day.400 invalid_request and the message
time must be YYYY-MM-DD or RFC3339.meta, so check
meta.from and meta.to instead of assuming your request was honored verbatim.curl -H "Authorization: Bearer tracio_sk_XXXX...XXXX" \ "https://api.tracio.ai/v1/visitors/X7fh2Hg9LkMn3pQr5tBvQw3xZa9mK2pL4nR8dT6y"The response carries the aggregate history and embeds the latest session, so the common case needs one request rather than two:
{ "visitorId": "X7fh2Hg9LkMn3pQr5tBvQw3xZa9mK2pL4nR8dT6y", "firstSeenAt": "2026-05-02T10:11:12Z", "lastSeenAt": "2026-07-25T08:00:00Z", "visits": 42, "incognitoVisits": 3, "uniqueIps": 5, "uniqueCountries": 2, "browsers": ["Chrome"], "os": ["macOS"], "devices": ["desktop"], "risk": { "maxRiskScore": 63, "avgBotScore": 12.5, "botSessions": 7, "lastDecision": "real" }, "network": { "vpnSeen": false, "proxySeen": false, "torSeen": false, "datacenterSeen": true, "proxyDetectedSeen": true, "lastIsp": "Deutsche Telekom", "lastRealIp": "203.0.113.7" }, "lastSession": { "requestId": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31", "visitorId": "X7fh2Hg9LkMn3pQr5tBvQw3xZa9mK2pL4nR8dT6y", "accountId": "user_8842", "timestamp": "2026-07-25T08:00:00Z", "tag": "checkout", "url": "https://shop.example.com/checkout", "ip": "203.0.113.42", "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", "browser": { "name": "Chrome", "version": "126.0" }, "os": { "name": "macOS", "version": "14.5" }, "device": "desktop", "gpu": "Apple M2", "geo": { "country": "DE", "city": "Berlin", "timezone": "Europe/Berlin", "isp": "Deutsche Telekom" }, "network": { "vpn": false, "proxy": false, "tor": false, "datacenter": false, "proxyDetected": true, "realIp": { "address": "203.0.113.7", "country": "NL", "isp": "KPN" }, "asn": 3320 }, "screen": { "width": 2560, "height": 1600, "colorDepth": 30, "pixelRatio": 2 }, "locale": { "languages": ["en-US", "de"], "timezone": "Europe/Berlin" }, "clientHints": { "architecture": "arm", "bitness": "64", "platformVersion": "14.5.0" }, "extensions": [ { "slug": "ublock-origin", "name": "uBlock Origin", "category": "adblock", "risky": false, "storeUrl": "https://chromewebstore.google.com/detail/cjpalhdlnbpafiamejdnhcphjbkeiagm" } ], "bot": { "result": "human", "score": 4.5, "antidetectScore": 2.1 }, "identification": { "confidence": 0.97, "incognito": false, "matchType": "exact", "matchConfidence": 0.99 }, "deviceInfo": { "deviceId": "d_4f9c2e", "crossBrowser": true, "confidence": 0.88, "linkedBrowsers": 2 }, "decision": { "action": "real", "riskScore": 12 } }, "meta": { "plan": "business", "retentionDays": 90, "from": "2026-04-26T00:00:00Z", "to": "2026-07-25T12:00:00Z" }}| Field | Meaning |
|---|---|
visits, incognitoVisits | Total visits in the window, and how many were in a private window |
uniqueIps, uniqueCountries | Distinct addresses and countries seen in the window |
browsers, os, devices | The distinct environments this visitor has appeared in |
risk.maxRiskScore | The highest risk score recorded in the window, 0..100 |
risk.lastDecision | The decision recorded for the most recent visit |
risk.avgBotScore, risk.botSessions | Bot-score average and the number of bot sessions — Business and above |
network.*Seen | Whether a VPN, proxy, Tor exit node or datacenter address was ever seen for this visitor |
network.proxyDetectedSeen | Whether at least one visit in the window left through a proxy or VPN in front of the browser — see network.proxyDetected under Device facts |
network.lastIsp | The most recent ISP — Business and above |
network.lastRealIp | The most recent address observed behind a proxy or VPN — Business and above; absent when none was observed |
lastSession | The full session object for the most recent visit |
meta | The plan, its retention in days, and the window actually applied |
A visitor with no data inside the retention window returns 404 not_found with the
message visitor not found in the retention window — that is not an error in your
integration, it means the visitor is new or has aged out.
The session carries two verdicts, and they answer different questions — whether the client was automated, and what the risk engine concluded overall:
| Field | Values |
|---|---|
bot.result | human, bot, uncertain |
bot.type | Present when bot.result is bot: either a specific tool (playwright, puppeteer, selenium, jsdom, claude_computer_use…) or a family when the tool is not named — automation, headless, antidetect, extension, privacy_browser, other |
decision.action | real, fake, suspicious |
bot.score and decision.riskScore both run 0..100. On Business and above,
guidance turns them into per-scenario advice on the ladder
allow → challenge → review → deny — see
Guidance for what each rung means.
curl -H "Authorization: Bearer tracio_sk_XXXX...XXXX" \ "https://api.tracio.ai/v1/visitors/X7fh2Hg9LkMn3pQr5tBvQw3xZa9mK2pL4nR8dT6y/sessions?limit=100&minRiskScore=50"| Parameter | Default | Notes |
|---|---|---|
limit | 50 | Capped at 500; a larger value is clamped, not rejected |
from, to | Plan retention | The shared time window described above |
cursor | — | Opaque pagination cursor from the previous page |
botResult | — | Keep only sessions with this bot verdict |
minRiskScore | — | Keep only sessions at or above this risk score, 0..100 |
Sessions come back newest first:
{ "items": [ { "requestId": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31", "visitorId": "X7fh2Hg9LkMn3pQr5tBvQw3xZa9mK2pL4nR8dT6y", "timestamp": "2026-07-25T08:00:00Z" } ], "nextCursor": "MTcyMTg5NDQwMDAwMDphYmMxMjM", "hasMore": true, "meta": { "plan": "business", "retentionDays": 90, "from": "2026-04-26T00:00:00Z", "to": "2026-07-25T12:00:00Z" }}Paging is cursor-based. There is no page or offset parameter: pass the
nextCursor you received back as cursor, and keep going while hasMore is true.
async function allSessions(visitorId: string, secretKey: string) { const sessions = [] let cursor: string | undefined
do { const url = new URL(`https://api.tracio.ai/v1/visitors/${visitorId}/sessions`) url.searchParams.set("limit", "500") if (cursor) url.searchParams.set("cursor", cursor)
const res = await fetch(url, { headers: { Authorization: `Bearer ${secretKey}` } }) if (!res.ok) throw new Error(`Data API: ${res.status}`)
const page = await res.json() sessions.push(...page.items) cursor = page.nextCursor } while (cursor)
return sessions}Treat the cursor as opaque — its contents are an implementation detail and may
change. A cursor that has been edited is rejected with 400 invalid_request and the
message malformed cursor.
Alongside the browser and OS taken from the User-Agent, a session carries what the visitor's browser reports about the machine, sanitized on our side. Every field is absent when the visit carried no such data, so treat each as optional.
| Field | Meaning |
|---|---|
gpu | Video adapter model as reported by the browser (WebGL), normalized to a readable name — Intel Iris Xe Graphics, Apple M1 Pro, Qualcomm Adreno 830; Software renderer means no real GPU (a virtual machine or a headless environment); Safari reports Apple GPU |
network.proxyDetected | The visit's HTTP traffic and its raw network paths exit through different networks — a proxy or VPN in front of the browser; two addresses of the same provider (carrier NAT, a second exit of the same VPN) do not count |
network.realIp.address, .country, .isp | The public address observed on the raw network path, i.e. the address behind the proxy or VPN, with its country and ISP — Business and above; absent when no such address was observed (country and isp are absent when they could not be resolved) |
deviceInfo.deviceId, .crossBrowser, .confidence, .linkedBrowsers | Present when a device identity was resolved: a stable id of the physical device across the browsers on it, whether this visit came through a different browser than before, the confidence of that match, and how many distinct visitors (browsers) share the device — above one means one machine under several browser identities — Business and above |
osEnvironment | The desktop environment measured on a Linux machine (Mint 22+, Ubuntu, GNOME, KDE) — Business and above; absent when not determined |
spoofing | What the visit claimed versus what independent checks measured (claimed, real, spoofedAxes of os, gpu, screen, network, browser; anonymousBrowser with product names) — Business and above; present only when a spoof was detected |
screen.width, .height, .colorDepth, .pixelRatio | Screen resolution, color depth and device pixel ratio as reported by the browser — Business and above |
locale.languages, locale.timezone | The browser's own preferred languages and timezone — as opposed to geo.timezone, derived from the IP address; a mismatch between the two is a common sign of a spoofed location — Business and above |
clientHints.architecture, .bitness, .model, .deviceName, .platformVersion | User-Agent Client Hints: CPU architecture and bitness, device model code (Android, e.g. SM-A556B) with its marketing name from the Google Play device list (deviceName, e.g. Samsung Galaxy A55 5G) and the exact platform version; Chromium-based browsers only — Business and above |
environment.virtualMachine, environment.hypervisor | Present only when the video adapter identified itself as a virtual one; hypervisor is a closed dictionary (vmware, virtualbox, parallels, qemu, hyperv, bochs, intel-gvt, vgpu). A missing block means no such evidence — Business and above |
extensions lists the browser extensions detected during the visit — Business and
above. Each entry is an object:
| Field | Meaning |
|---|---|
slug | Stable machine identifier of the extension, the same value the webhook delivers |
name | Human-readable name |
category | Coarse class — adblock, privacy, automation, wallet, vpn, devtools, other, and so on |
risky | true for extensions associated with automation, spoofing or credential theft |
storeUrl | Link to the extension's store listing, when known |
A finding is reported only after it has passed our trust checks — an environment that answers "installed" to every probe, or a batch longer than twelve names, is discarded as unreliable. An empty or missing list therefore means "nothing we could confirm", not "no extensions installed". Read it as evidence, not as an inventory.
The most recent one:
curl -H "Authorization: Bearer tracio_sk_XXXX...XXXX" \ "https://api.tracio.ai/v1/visitors/X7fh2Hg9LkMn3pQr5tBvQw3xZa9mK2pL4nR8dT6y/sessions/latest"This returns a bare session object — not an array, and not wrapped in an envelope. A
visitor with no sessions in the window returns 404 not_found with
no sessions for this visitor in the retention window.
Or by requestId, the identifier that also appears in the webhook payload:
curl -H "Authorization: Bearer tracio_sk_XXXX...XXXX" \ "https://api.tracio.ai/v1/sessions/9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31?visitorId=X7fh2Hg9LkMn3pQr5tBvQw3xZa9mK2pL4nR8dT6y"visitorId is optional here, but passing it when you know it makes the lookup
markedly faster.
Velocity answers "how much has this visitor been doing lately" — the shape of credential stuffing, card testing and bulk signups.
curl -H "Authorization: Bearer tracio_sk_XXXX...XXXX" \ "https://api.tracio.ai/v1/visitors/X7fh2Hg9LkMn3pQr5tBvQw3xZa9mK2pL4nR8dT6y/velocity?window=1h"window accepts 1h, 24h or 7d and defaults to 24h. Any other value is
rejected with 400 invalid_request and window must be one of: 1h, 24h, 7d.
{ "window": "1h", "events": 37, "uniqueIps": 9, "uniqueCountries": 3, "uniqueAccounts": 12, "botEvents": 4, "meta": { "plan": "business", "retentionDays": 90, "from": "2026-07-25T11:00:00Z", "to": "2026-07-25T12:00:00Z" }}uniqueAccounts counts the distinct linkedId values you sent for this device — see
Account Linking. botEvents is Business and above.
A missing field means "no data", never zero. Fields with no value are omitted
entirely rather than sent as 0, "" or null: a brand-new visitor has no
matchConfidence, a clean visit has no antidetectScore or suspectScore. The one
deliberate exception is bot.score, which is always present even when it is zero.
Read fields defensively.
The payload depends on your plan. Every plan with API access gets the base
session — identifiers, timestamp, URL, IP, user agent, browser, OS, device, geo,
network, bot, identification and decision. Pro adds identification.matchType,
identification.matchConfidence, bot.type, bot.antidetectScore, gpu and
network.proxyDetected. Business and Enterprise add extensions, geo.isp,
network.asn, network.realIp, decision.suspectScore, identification.driftScore,
reasons, behavior, guidance, deviceInfo, spoofing, osEnvironment, screen,
locale, clientHints and environment. The person-level fields (personId,
reputation, linkedAccountsCount, linkedVisitorsCount) are reserved for Business
and Enterprise and will appear once the person layer is enabled — today it runs in
observation mode and these fields are not delivered. Absence of a Business field on a
Pro plan is not an error.
Signal-level internals are never returned, on any plan: individual signal names, their weights, the thresholds behind a verdict, raw signal values, and score breakdowns stay on our side. A score that can be reverse-engineered into its inputs stops being useful as a defence.
Every failure uses one envelope:
{ "error": { "code": "unauthorized", "message": "missing Authorization: Bearer <secret key>", "requestId": "8f14e45fceea167a5a36dedd" }}This requestId is not the visit identifier. Two different values share the
name: inside a session payload requestId is the UUID of the visit, the same one
the webhook delivers; inside an error envelope it is a 24-character trace
identifier minted per HTTP call. The trace identifier also comes back in the
X-Request-Id header on every response, successful or not. Include it when you
contact support — it is how we find your exact call.
| HTTP | code | Meaning |
|---|---|---|
| 400 | invalid_request | A parameter is missing or malformed |
| 401 | unauthorized | The key is absent, invalid, revoked or expired |
| 402 | upgrade_required | Your plan does not include API access |
| 404 | not_found | Nothing matched inside the retention window |
| 405 | method_not_allowed | The route exists, but not for that method |
| 429 | rate_limited | Requests per second, or the daily quota, exceeded |
| 500 | internal | Something failed on our side |
| 503 | unavailable | A backing store is temporarily unreachable |
Checks run in a fixed order — key, then plan, then limits — so a request with a bad key always reports the key first, never a quota problem.
Two 401 cases read differently on purpose: missing Authorization: Bearer <secret key>
means the header never arrived, while invalid or revoked API key means it arrived
and did not match. 402 carries Data API requires the Pro plan or higher.
Every authenticated response carries your current standing:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Your daily quota |
X-RateLimit-Remaining | Calls left today |
X-RateLimit-Reset | Unix time of the reset — midnight UTC |
Retry-After | Seconds to wait, sent only with a 429 |
| Plan | Requests per second | Requests per day | History depth |
|---|---|---|---|
| Free | No API access | — | 7 days |
| Pro | 10 | 10,000 | 30 days |
| Business | 50 | 100,000 | 90 days |
| Enterprise | 200 | Unmetered | 365 days |