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. A few routes also write — account records, custom fields and list entries — so your backend can enrich what TRACIO knows.
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/v2This 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.
The /v1 routes have been removed. Use /v2: the routes are the same, and every
JSON key and query parameter is in snake_case (visitor_id, risk_score,
min_risk_score). Keys inside your own fields and labels dictionaries are your
data and keep their original spelling. The JavaScript SDK keeps its camelCase
interface.
Every request carries your secret key as a bearer token:
curl -H "Authorization: Bearer tracio_sk_XXXX...XXXX" \ "https://api.tracio.ai/v2/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.404, exactly as for data that does not exist.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.
| Method | Path | Returns |
|---|---|---|
GET | /v2/visitors/{visitor_id} | Aggregated history for one visitor, plus their latest session |
GET | /v2/visitors/{visitor_id}/sessions | Paginated list of that visitor's sessions |
GET | /v2/visitors/{visitor_id}/sessions/latest | The single most recent session |
GET | /v2/visitors/{visitor_id}/velocity | Activity counters over a short window |
GET | /v2/sessions/{request_id} | One session by its request identifier |
POST | /v2/accounts/metadata | Attach display names and emails to your account IDs |
POST | /v2/events/fields | Attach custom fields to a visit after page load |
POST | /v2/events/fields/delete | Erase custom fields from a visit — the visit itself is not touched |
GET | /v2/lists | Your workspace lists: id, name, type (the action), mode, entry count |
POST | /v2/lists/{list_id}/entries | Add a value to a list, optionally with a TTL |
POST | /v2/lists/{list_id}/entries/delete | Remove a value from a list |
GET | /.well-known/webhook-keys | Public keys for the webhook platform signature (no auth) |
The same key and host also serve the decision APIs, all in snake_case:
| Method | Path | Purpose |
|---|---|---|
POST | /v2/transactions | A synchronous APPROVE / REVIEW / DECLINE decision on a business event — Business and above, except an affiliate conversion (account_register or deposit with affiliate_id), available on every plan with API access |
GET | /v2/transactions/{transaction_id} | Re-read a decision with its label and effective state — Business and above, except a transaction attributed to an affiliate |
PUT, DELETE | /v2/transaction-labels | Set or remove the outcome label of up to 50 transactions — Business and above |
GET | /v2/transaction-labels/value-set | The accepted label values with their polarity — Business and above |
POST | /v2/logins/assess | Assess a login: allow / challenge / review / deny with reasons |
POST, GET | /v2/accounts/{user_id}/trusted-devices | Trust the device of a visit for an account after your step-up, or list the account's trusted devices |
DELETE | /v2/accounts/{user_id}/trusted-devices/{device_ref} | Revoke the trust of a device |
GET | /v2/affiliates/{affiliate_id} | The quality report of an affiliate over 90 days, with a hold / review recommendation |
POST, GET | /v2/self-exclusions | Set or extend a self-exclusion on an account, or list them |
DELETE | /v2/self-exclusions/{user_id} | Lift a self-exclusion early |
Their outcomes also reach you as webhook events:
transaction_state_changed, login_risk, trusted_device_enrolled,
trusted_device_revoked and affiliate_threshold_crossed.
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/v2/visitors/X7fh2Hg9LkMn3pQr5tBvQw3xZa9mK2pL4nR8dT6y"The response carries the aggregate history and embeds the latest session, so the common case needs one request rather than two:
{ "visitor_id": "X7fh2Hg9LkMn3pQr5tBvQw3xZa9mK2pL4nR8dT6y", "first_seen_at": "2026-05-02T10:11:12Z", "last_seen_at": "2026-07-25T08:00:00Z", "visits": 42, "incognito_visits": 3, "unique_ips": 5, "unique_countries": 2, "browsers": ["Chrome"], "os": ["macOS"], "devices": ["desktop"], "risk": { "max_risk_score": 63, "avg_bot_score": 12.5, "bot_sessions": 7, "last_decision": "real" }, "network": { "vpn_seen": false, "proxy_seen": false, "tor_seen": false, "datacenter_seen": true, "proxy_detected_seen": true, "last_isp": "Deutsche Telekom", "last_real_ip": { "address": "203.0.113.7", "country": "NL", "isp": "KPN" } }, "last_session": { "request_id": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31", "visitor_id": "X7fh2Hg9LkMn3pQr5tBvQw3xZa9mK2pL4nR8dT6y", "account_id": "user_8842", "account": { "id": "user_8842", "email": "ann@example.com", "display_name": "Ann Lee" }, "timestamp": "2026-07-25T08:00:00Z", "tag": "checkout", "fields": { "order_ref": "A-1029" }, "url": "https://shop.example.com/checkout", "ip": "203.0.113.42", "user_agent": "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, "connection_type": "residential", "proxy_detected": true, "real_ip": { "address": "203.0.113.7", "country": "NL", "isp": "KPN" }, "asn": 3320 }, "screen": { "width": 2560, "height": 1600, "color_depth": 30, "pixel_ratio": 2 }, "locale": { "languages": ["en-US", "de"], "timezone": "Europe/Berlin" }, "client_hints": { "architecture": "arm", "bitness": "64", "platform_version": "14.5.0" }, "extensions": [ { "slug": "ublock-origin", "name": "uBlock Origin", "category": "adblock", "risky": false, "store_url": "https://chromewebstore.google.com/detail/cjpalhdlnbpafiamejdnhcphjbkeiagm" } ], "bot": { "result": "human", "score": 4.5, "antidetect_score": 2.1 }, "identification": { "confidence": 0.97, "incognito": false, "match_type": "exact", "match_confidence": 0.99 }, "device_info": { "device_id": "d_4f9c2e", "cross_browser": true, "confidence": 0.88, "linked_browsers": 2 }, "decision": { "action": "real", "risk_score": 12, "suspect_score": 3 } }, "meta": { "plan": "business", "retention_days": 90, "from": "2026-04-26T00:00:00Z", "to": "2026-07-25T12:00:00Z" }}| Field | Meaning |
|---|---|
visits, incognito_visits | Total visits in the window, and how many were in a private window |
unique_ips, unique_countries | Distinct addresses and countries seen in the window |
browsers, os, devices | The distinct environments this visitor has appeared in |
risk.max_risk_score | The highest risk score recorded in the window, 0..100 |
risk.last_decision | The decision recorded for the most recent visit |
risk.avg_bot_score, risk.bot_sessions | 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.proxy_detected_seen | Whether at least one visit in the window left through a proxy or VPN in front of the browser — see network.proxy_detected under Device facts |
network.last_isp | The most recent ISP — Business and above |
network.last_real_ip | The most recent address observed behind a proxy or VPN (address, country, isp) — Business and above; absent when no visit brought one |
last_session | 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…), a verified AI agent (ai_agent:<vendor>), or a family when the tool is not named — automation, headless, antidetect, extension, privacy_browser, impersonation, other |
decision.action | real, fake, suspicious |
bot.score and decision.risk_score 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.
Values and scales match the v3 webhook body for the same visit, and guidance is
computed by the same rules.
curl -H "Authorization: Bearer tracio_sk_XXXX...XXXX" \ "https://api.tracio.ai/v2/visitors/X7fh2Hg9LkMn3pQr5tBvQw3xZa9mK2pL4nR8dT6y/sessions?limit=100&min_risk_score=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 |
bot_result | — | Keep only sessions with this bot verdict |
min_risk_score | — | Keep only sessions at or above this risk score, 0..100 |
Sessions come back newest first:
{ "items": [ { "request_id": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31", "visitor_id": "X7fh2Hg9LkMn3pQr5tBvQw3xZa9mK2pL4nR8dT6y", "timestamp": "2026-07-25T08:00:00Z" } ], "next_cursor": "MTcyMTg5NDQwMDAwMDphYmMxMjM", "has_more": true, "meta": { "plan": "business", "retention_days": 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
next_cursor you received back as cursor, and keep going while has_more is true.
The last page may not include next_cursor at all, so do not make that field
required.
async function allSessions(visitorId: string, secretKey: string) { const sessions = [] let cursor: string | undefined
do { const url = new URL(`https://api.tracio.ai/v2/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.has_more ? page.next_cursor : undefined } 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.proxy_detected | 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; not raised when the addresses cannot be compared (iCloud Private Relay, IPv4 next to IPv6) |
network.real_ip.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) |
device_info.device_id, .cross_browser, .confidence, .linked_browsers | 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 |
os_environment | The desktop environment measured on a Linux machine (Mint 22+, Ubuntu 25.04+, Ubuntu, GNOME, KDE) — Business and above; absent when not determined |
spoofing | What the visit claimed versus what independent checks measured (claimed, real, spoofed_axes of os, gpu, screen, network, browser; anonymous_browser with product names) — Business and above; present only when a spoof was detected |
screen.width, .height, .color_depth, .pixel_ratio | 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 |
client_hints.architecture, .bitness, .model, .device_name, .platform_version | 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 (device_name, e.g. Samsung Galaxy A55 5G) and the exact platform version; Chromium-based browsers only — Business and above |
environment.virtual_machine, 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 — Pro 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 |
store_url | 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.
fields holds the custom key-value pairs your integration attached to the visit —
an order reference, a plan name, a payment outcome. Keys and values are always
strings: a number or boolean you attached comes back in its text form. Like the rest
of the session, it is available on every plan with API access (Pro and above), and
it is absent when the visit carries no fields.
fields is read live on every request, not copied at visit time. Fields attached
after the visit (see Attaching fields after the visit)
show up here too, so for the same visit fields may contain more than the webhook
delivered.
Do not put personal data such as names, email addresses or phone numbers into custom fields — attach a reference that your own systems can resolve instead, and send who the account is as an account record (below).
account is your record for the account behind the visit — what you last sent for
that account_id through POST /v2/accounts/metadata:
{ "account": { "id": "user-42", "email": "ann@example.com", "display_name": "Ann Lee" }}| Field | Description |
|---|---|
id | Your account ID — the same value as account_id |
email | The email address you sent for the account; absent when empty |
display_name | The display name you sent for the account; absent when empty |
The record is read live on every request, so a rename shows up on old visits too: the block describes the account as it is now, not as it was at visit time. It is available on every plan with API access (Pro and above). Labels you sent with the record are not returned.
The block is absent when the visit has no linked account, when there is no record for it (or you cleared it by sending empty values), and — rarely — when the record could not be read: the session is still returned in full rather than failing the request, so treat a missing block as "unknown", not as "this account has no email". Webhooks do not carry this block.
The most recent one:
curl -H "Authorization: Bearer tracio_sk_XXXX...XXXX" \ "https://api.tracio.ai/v2/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 request_id, the identifier that also appears in the webhook payload:
curl -H "Authorization: Bearer tracio_sk_XXXX...XXXX" \ "https://api.tracio.ai/v2/sessions/9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31?visitor_id=X7fh2Hg9LkMn3pQr5tBvQw3xZa9mK2pL4nR8dT6y"visitor_id is optional here, but passing it when you know it makes the lookup
markedly faster.
identification.drift_score — the smoothed behavior score the account-takeover
verdict was made on, Business and above — is returned only by this single-session
read. Behavior drift is measured during the visit, after the initial identification,
so the list and latest-session routes omit it. A visit whose behavior was never
judged has no drift_score. The same value reaches you through the
account_takeover webhook.
A visit can be delivered more than once by webhook: primary first, then late or
correction as slower signals and behavioral evidence arrive — and a later phase
can overturn the earlier verdict. The Data API answers from the primary
record. Later phases are not merged back into it, so for a corrected visit the
last webhook is the newer answer. If you act minutes after the visit, prefer the
phase you received last over a fresh read here.
One thing runs the other way: once the account-takeover detector has fired for a
visit, guidance here reflects it, while identification webhook bodies of the same
visit do not carry that axis — it rides the dedicated account_takeover delivery.
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/v2/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, "unique_ips": 9, "unique_countries": 3, "unique_accounts": 12, "bot_events": 4, "meta": { "plan": "business", "retention_days": 90, "from": "2026-07-25T11:00:00Z", "to": "2026-07-25T12:00:00Z" }}unique_accounts counts the distinct linkedId values you sent for this device — see
Account Linking. bot_events is Business and above; the key
is absent on Pro.
TRACIO knows your accounts only by the ID your site passes as linkedId — an opaque
string like usr_8412. Send the human labels once, and every dashboard screen that
shows that account shows the name or email instead of the raw ID, and the session
object returns them in its account block. Nothing here affects
detection or scoring; it is a display layer.
curl -s -X POST "https://api.tracio.ai/v2/accounts/metadata" \ -H "Authorization: Bearer tracio_sk_XXXX...XXXX" \ -H "Content-Type: application/json" \ -d '{"accounts":[ {"account_id":"usr_8412","email":"ann@example.com","display_name":"Ann Lee","labels":{"tier":"vip"}}, {"account_id":"usr_9130","display_name":"Bob Ray"} ]}'{ "updated": 2 }fields. An email
or a name sent here lives only in your workspace's account record: it is not stored
per visit, not written into the page, and one send updates every visit of that
account.400 invalid_request, and one bad entry
rejects the whole batch — the error names its position. Duplicate account IDs in
one batch are fine: the last one wins.Limits: 1000 accounts per request, 1 MiB body, account_id ≤ 256 characters,
email ≤ 320, display_name ≤ 256, at most 16 labels with keys ≤ 64 and values
≤ 256 characters.
The browser can only send what the page knows at render time — an order ID, never the outcome of a payment check or anything else that resolves later. This route fills that gap: find the visit by a field it already carries, and attach more fields to it server-side.
curl -s -X POST "https://api.tracio.ai/v2/events/fields" \ -H "Authorization: Bearer tracio_sk_XXXX...XXXX" \ -H "Content-Type: application/json" \ -d '{"match":{"key":"order_ref","value":"A-1029"},"fields":{"amount":"120.00"}}'{ "updated": 1 }match finds the visit by a field it already has, not by request_id, within
your key's workspace and environment.404 not_found — write
the field soon after the outcome resolves.updated counts distinct visits. It goes above 1 only when your match hits
more than one visit — a key/value pair that is not unique to one order or cart. A
match that hits more than 100 events is refused with 400 invalid_request rather
than applied to an arbitrary subset.server, and take
priority over a value with the same key written by the browser.404 not_found, not an empty 200.Limits match the browser channel: 50 fields per call, keys ≤ 40 bytes, values ≤ 500
bytes — bytes, not characters — key charset A-Za-z0-9_.:-, 1 MiB body.
Erases custom fields — never the visit itself. Use it for values you no longer need attached to a visit, such as an order total. Webhooks already sent and exports already taken are not changed.
# by request_id — clears every field on this one visitcurl -s -X POST "https://api.tracio.ai/v2/events/fields/delete" \ -H "Authorization: Bearer tracio_sk_XXXX...XXXX" \ -H "Content-Type: application/json" \ -d '{"request_id":"9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31"}'
# by match — clears "order_ref" from every visit currently holding "A-1029"curl -s -X POST "https://api.tracio.ai/v2/events/fields/delete" \ -H "Authorization: Bearer tracio_sk_XXXX...XXXX" \ -H "Content-Type: application/json" \ -d '{"match":{"key":"order_ref","value":"A-1029"}}'{ "deleted": 1 }request_id and match are mutually exclusive — sending both, or neither, is
400 invalid_request. match uses the same 24-hour window and the same 100-event
ceiling as attaching.deleted counts the fields removed from the visit (by request_id) or the
visits the field was removed from (by match).404 not_found.Tracio.load().get({ fields }) (and so the SDK) passes fields after the
visit's first request has already left, the page sends them in a separate
follow-up request — up to 3 per visit, while the visit's session is still
alive (minutes). Such a follow-up that arrives after your delete writes the
field again. The window is the life of the browser session, not of the
visit record: delete fields once the visitor has left the page, not while
it is still open.The same lists your operators manage in the dashboard, reachable from your own back
office. A list's type is its action on a matching visit — block,
manual_review, challenge, allow — and its mode decides whether the action is
applied: observe only counts matches, enforce applies it to guidance (the
list axis). Lists and their mode are created in the dashboard; the Data API reads
lists and adds and removes entries.
curl -s "https://api.tracio.ai/v2/lists" \ -H "Authorization: Bearer tracio_sk_XXXX...XXXX"[ { "id": "7f3a9d2e-4c1b-4e8f-9a6d-1b2c3d4e5f60", "name": "Chargeback accounts", "type": "block", "mode": "enforce", "entries_count": 128 }]# ban an account for 30 dayscurl -s -X POST "https://api.tracio.ai/v2/lists/7f3a9d2e-4c1b-4e8f-9a6d-1b2c3d4e5f60/entries" \ -H "Authorization: Bearer tracio_sk_XXXX...XXXX" \ -H "Content-Type: application/json" \ -d '{"value":"usr_8412","value_type":"linked_id","ttl_minutes":43200}'{ "id": "02b4c8d1-6e7f-4a90-b1c2-d3e4f5a6b7c8", "value": "usr_8412", "value_type": "linked_id", "expires_at": "2026-10-19T12:00:00Z", "propagation": "immediate"}Remove an entry with the same body at POST /v2/lists/{list_id}/entries/delete; the
answer is { "removed": true, "propagation": "immediate" }.
value_type is what the value is matched against: linked_id, visitor_id,
device_id, ip, cidr or country. A value that does not parse for its type is
400 invalid_request.linked_id →
visitor_id → device_id → ip → cidr → country — and within it the strictest
action wins.ttl_minutes is optional. Omitted, the list's default TTL applies; with none,
the entry never expires (expires_at is null). Re-adding an entry renews it.propagation says when the change is live: immediate, or ttl — within
about five minutes.200 with removed: false; 404 is
reserved for an unknown list.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
match_confidence, a clean visit has no antidetect_score or suspect_score. 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. API access starts at Pro, and Pro gets the
base session — identifiers, timestamp, URL, IP, user agent, browser, OS, device, geo,
network, bot, identification and decision — together with
identification.match_type, identification.match_confidence, bot.score,
bot.type, bot.antidetect_score, gpu, network.proxy_detected, extensions,
fields and account. Business and Enterprise add geo.isp, network.asn, network.real_ip, decision.suspect_score,
identification.drift_score, reasons, behavior, guidance, device_info,
spoofing, os_environment, screen, locale, client_hints and environment.
The person-level fields (person_id, reputation, linked_accounts_count,
linked_visitors_count) 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>", "request_id": "8f14e45fceea167a5a36dedd" }}This request_id is not the visit identifier. Two different values share the
name: inside a session payload request_id 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, or it belongs to another environment |
| 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. Data from another workspace
is indistinguishable from data that does not exist: it yields 404, not 403.
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.
Retry 429 and 503 with a growing pause; retrying other 4xx is pointless — they
mean the request itself needs fixing.
Responses for which the key has been recognized carry your current standing (401
and 402 responses do not, and Enterprise, with no daily quota, gets no quota
headers):
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Your daily quota; on a per-second rejection, the per-second limit |
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 — go by it: it is correct for both limits |
| 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 |