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 | Server 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 Server 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 Server 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 Server 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, "lastIsp": "Deutsche Telekom" }, "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", "geo": { "country": "DE", "city": "Berlin", "timezone": "Europe/Berlin", "isp": "Deutsche Telekom" }, "network": { "vpn": false, "proxy": false, "tor": false, "datacenter": false, "asn": 3320 }, "bot": { "result": "human", "score": 4.5, "antidetectScore": 2.1 }, "identification": { "confidence": 0.97, "incognito": false, "matchType": "exact", "matchConfidence": 0.99 }, "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.lastIsp | The most recent ISP — Business and above |
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 |
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(`Server 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.
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 and bot.antidetectScore. Business and Enterprise
add geo.isp, network.asn, decision.suspectScore, identification.driftScore,
reasons, behavior, guidance, deviceInfo and the person-level fields
(personId, reputation, linkedAccountsCount, linkedVisitorsCount). 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 |