POST /v2/logins/assess answers one question at the moment a user signs in:
is this login risky enough to ask for a second factor? You get a
recommendation — allow, challenge, review or deny — with the reasons behind it:
the device is new for this account, the account-takeover detector has raised
an alert on the visit, the account has moved since its previous visit faster
than a person can travel, or the account is self-excluded or looks like the same
person as a self-excluded one. Together with it come trusted devices: after the user
passes your step-up, you mark the device as trusted, and the next login from it
is not challenged for being new.
We recommend; you act. Tracio does not block a login, does not send codes and
does not keep sessions: your backend asks us after it has checked the password,
gets the recommendation, and decides itself. deny appears in one case only: the
account has a current self-exclusion that you set. Every
other reason ends at most with review — a request for more proof, never a
refusal.
The step-by-step integration (assess → challenge → step-up → enroll, revoke on a password change) is in runbook-login-step-up.md.
Base URL: https://api.tracio.ai. The routes live under /v2, the snake_case
version of the Data API: every key in the request, the response and the error
body is snake_case.
The same secret key (tracio_sk_…) and the same Authorization: Bearer header
as the rest of the Data API — see data-api.md, section "Keys".
Calls count against the usual Data API per-second limit and daily quota of your
plan; there is no separate meter.
The endpoints are available on every plan with Data API access (Pro and
up). One part of the answer is plan-gated: the ato block and the ato_alert
reason exist only on Business and higher. On Pro the block is absent and the
recommendation does not depend on it.
A key is bound to an environment: the visit, the account history and the trusted devices are all looked up in the environment of your key.
curl -X POST https://api.tracio.ai/v2/logins/assess \ -H "Authorization: Bearer $TRACIO_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "user_id": "alice", "request_id": "0b6f5c1e-8a1d-4c3e-9f00-1234567890ab", "login_status": "success", "assessment_id": "login-attempt-77120" }'| Field | Type | Required | Meaning |
|---|---|---|---|
user_id | string, ≤ 256 bytes | yes | your account id — the same value you pass to the SDK as linkedId (see below) |
request_id | string, UUID | one of the two | the requestId of the visit on which the user signs in |
session | string | one of the two | the inline_token your frontend received (see inline-verdict.md) |
login_status | string | no | success (default) or failed — see Failed logins |
assessment_id | string, ≤ 255 bytes | no | your idempotency key for this login attempt (see Idempotency); without it we generate one and return it |
The body is at most 64 KiB; an unknown key is rejected with 400.
request_id is the link to the visit: from it we take the device and the
location. The visit is looked up within the last 30 days, in the environment of
your key. A request_id that is not a UUID is rejected with 400.
session is an optional authenticity check. We verify the token's signature and
expiry; if request_id is absent, the token's requestId is used, and if both
are present, they must match. The token lives 2 minutes from the visit — it
fits a login that follows the page load. For anything later, send only
request_id.
user_id must match linkedIdThe account's device history is built from the visits your site tagged with
linkedId through the SDK. user_id is matched against it byte for byte:
case and spaces are significant (Alice, alice and alice are three
different accounts). The only normalization is the one linkedId gets too:
ASCII control characters and invalid UTF-8 are dropped. A user_id with nothing
printable in it is rejected with 400.
If your site does not pass linkedId, the account has no history, and every
login is assessed as the account's first device (allow, first_device).
{ "assessment_id": "snap-1", "user_id": "alice", "request_id": "0b6f5c1e-8a1d-4c3e-9f00-1234567890ab", "login_status": "success", "state": "assessed", "recommendation": "challenge", "reasons": ["new_device"], "device": { "device_ref": "7978151c54e94838", "visitor_id": "v1", "new_device": true, "trusted": false, "first_seen_at": null, "last_seen_at": null, "unique_devices_1d": 1, "unique_devices_7d": 2 }, "ato": { "type": "behavior-drift/2", "state": "not_assessed", "reason": "no_window" }, "location": { "country": "DE", "city": "Berlin" }, "travel": null, "assessed_at": "MASKED", "calculation_time": "MASKED", "idempotent_replay": false}This is the reference body from
examples/login-risk-assess.json — the
snapshot our tests compare the endpoint against. Its values are example values:
assessed_at and calculation_time are masked in the snapshot because they
change on every call. In a real response assessed_at is an RFC 3339 UTC time
(2026-10-17T10:00:00Z) and calculation_time is an integer number of
milliseconds.
| Field | Meaning |
|---|---|
assessment_id | your assessment_id, or the one we generated |
user_id | the account, after the normalization above |
request_id | the visit the assessment was made on (from the body or from the session token) |
login_status | success or failed, as sent |
state | assessed, or not_assessed when we could not assess the login (see reason) |
reason | only with not_assessed: visit_not_found or bot_traffic |
recommendation | allow, challenge, review or deny; null with not_assessed, except for a self-excluded account (see Self-exclusion) |
reasons | the codes behind the recommendation; always an array, possibly empty |
device | the device of the visit; absent with not_assessed |
device.device_ref | a 16-hex reference to the device, stable within your workspace. The raw device id is never returned. It is the reference of the current visit: a trusted-device record matches by device id or by browser id, so on a match by browser id the record can carry another device_ref, and a revoke by this value then answers 404. To revoke, take device_ref from GET /v2/accounts/{user_id}/trusted-devices |
device.visitor_id | the browser id of the visit's own row. It can differ from the visitorId that the SDK and webhooks report: that one may be the id of the person the visit was linked to |
device.new_device | true when neither this device nor this browser appears in the account's history. It describes the history and stays true on a trusted device — the decision itself is in recommendation and reasons. When the account has no history at all, it is false and the reasons carry first_device instead — unless the device is trusted, then they carry trusted_device |
device.trusted | the device has a live trusted-device record for this account |
device.first_seen_at, device.last_seen_at | the first and the last visit of this account from this device or browser within the history window; null when the device is new or the account has no history |
device.unique_devices_1d, device.unique_devices_7d | the number of distinct devices of this account over the last 24 hours and 7 days, the current one included |
ato | Business+ only, and only with state: assessed: the account-takeover verdict of this visit (see The ato block) |
location | the country (ISO 3166-1 alpha-2) and, when we know it, the city of the visit; absent when the country is unknown or the visit was not found |
travel | every plan: the impossible-travel hop from the account's previous location to this visit (see Impossible travel); null when there is no hop, and always null with not_assessed |
self_exclusion | every plan: the self-exclusion behind self_excluded / self_excluded_link, or the explanation why there is no link (see Self-exclusion); null when the account has no mark and no link |
assessed_at | when the assessment was made, RFC 3339 UTC |
calculation_time | milliseconds spent on this call |
idempotent_replay | true when this is a stored answer returned for a repeated assessment_id |
calculation_time and idempotent_replay describe the call, not the decision:
they are not part of the stored assessment and are not sent in the webhook.
The history of an account is every non-bot visit tagged with its linkedId
over the last 90 days, except the current visit and the visits of assessed
failed logins (with the limits in Failed logins). A device is
known when the history contains a visit with the same device id or the
same browser id (device.visitor_id).
| Situation | recommendation | reasons |
|---|---|---|
| the device is trusted | allow | trusted_device |
| the account has no history — its first device | allow | first_device |
| the device is known | allow | — (empty) |
| the device is new and not trusted | challenge | new_device |
the ATO verdict of the visit is assessed_alert (Business+) | challenge | ato_alert, added to the reasons above |
an impossible-travel hop, medium or high | challenge | impossible_travel, added to the reasons above |
an impossible-travel hop, high, on a new device that is not trusted | review | new_device, impossible_travel |
| an impossible-travel hop whose arrival is anonymized (VPN, proxy, datacenter) | allow | impossible_travel_anonymized, added to the reasons above |
| an impossible-travel hop on the same device | allow | — (the hop is in travel with suppressed_by: "same_device") |
| the login is for an account with a current self-exclusion | deny | self_excluded |
| the visit looks like the same person as a self-excluded account | review | self_excluded_link, added to the reasons above |
The strongest outcome wins: deny > review > challenge > allow. A trusted device
lifts only new_device: an ATO alert on a trusted device is still a
challenge, with reasons: ["trusted_device", "ato_alert"], and so is an
impossible-travel hop (["trusted_device", "impossible_travel"]). The first login
of an account is not challenged for its device — there is nothing to compare it
with — but an ATO alert still makes it a challenge.
review comes from two rules: a high impossible-travel hop on a new,
untrusted device — two independent signs that someone else is signing in — and a
link to a self-excluded account. Treat
it at least as strictly as challenge: for example, ask for the second factor
and also hold the session for a manual check or notify the account owner.
reasons codesThe order of the codes in the array is fixed: trusted_device, first_device,
new_device, ato_alert, impossible_travel, impossible_travel_anonymized, self_excluded,
self_excluded_link.
| Code | Meaning |
|---|---|
trusted_device | the device has a live trusted-device record for this account |
first_device | the account has no history: the first device is not flagged |
new_device | neither the device nor the browser appears in the account's history, and the device is not trusted |
ato_alert | Business+: the account-takeover detector raised an alert on this visit |
impossible_travel | the account moved from its previous location to this visit faster than the travel thresholds allow (see Impossible travel) |
impossible_travel_anonymized | the same, but the visit came through a VPN, a proxy or a datacenter: the move is explained by the exit node, so it does not raise the recommendation |
self_excluded | the account has a current self-exclusion: deny. A trusted device does not lift it |
self_excluded_link | the visit looks like the same person as a self-excluded account of your workspace: review. A trusted device does not lift it |
The mark, its term and its endpoints are in self-exclusion.md.
Here is what assess returns. The block self_exclusion is present when a mark or a
link was found, or when we can explain why a link was not made:
"recommendation": "review","reasons": ["self_excluded_link"],"self_exclusion": { "link_type": "device", "excluded_user_id": "alice", "until": "2027-04-19T00:00:00Z", "confidence": "medium"}| Field | Meaning |
|---|---|
link_type | user — the same account as the mark (deny); person, device or person_device — the basis of the link (review); none — no link, the block only explains why |
excluded_user_id | the self-excluded account of your workspace; omitted when there is no such mark |
until | the end of its term, RFC 3339 UTC |
confidence | high for user and person_device, medium for person and device; null with none |
person_excluded | only with a person match that was cut off: poisoned, visitor_quarantine, magnet, device_bridge or person_status_unknown — why there is no link by person (see self-exclusion.md) |
link_unavailable | true when the lookup of the link by device failed; the assessment still goes on without it |
link_type: "none" changes neither the recommendation nor the reasons.
With state: "not_assessed" a mark on the same user_id still applies: the
response keeps its state and reason, and carries recommendation: "deny",
reasons: ["self_excluded"] and the self_exclusion block. A link needs a visit,
so a not_assessed answer never carries self_excluded_link. Without a mark,
recommendation stays null.
deny is not sent as a webhook. A review by a link is sent as login_risk,
and its body carries self_excluded_link and the self_exclusion block.
deny and review here are recommendations, as everywhere in this API: you
enforce them.
reason codes (state: not_assessed)| Code | Meaning | What to do |
|---|---|---|
visit_not_found | there is no visit with this request_id in the environment of your key within 30 days. Most often the visit has not reached us yet — the call came right after the page load | fall back to your own policy, or retry a second later with a new assessment_id (a repeat of the same one returns the stored not_assessed) |
bot_traffic | the visit is a bot | the visit has no person to assess; treat the login by your bot policy |
With not_assessed the response has no device and no ato block, travel
is null, and recommendation is null — not allow — unless the account has a
current self-exclusion (see Self-exclusion). A bot visit still carries location.
ato blockOn Business and higher, the response carries the account-takeover verdict of the
visit — the same construction as the
account_assessment webhook:
| Field | Meaning |
|---|---|
type | the construction version, now behavior-drift/2 |
state | assessed_clear, assessed_alert or not_assessed |
reason | only with not_assessed: one of the reasons of the account_assessment event, or no_window |
no_window means the behavior window of the current visit has not been
collected yet — at the moment of login an ATO verdict is usually absent. The
detector judges windows of mouse, keyboard and scroll behavior that the page
sends while the user works with it; a login form submitted seconds after the
page load usually has none. So most ato blocks you see at login are
not_assessed / no_window, and the recommendation then rests on the device
alone. Do not read no_window as "the account is fine".
On every plan, we compare the visit with the previous location of the
account: the most recent earlier visit tagged with the same linkedId, in the
environment of your key, within the last 90 days, that has a known location and
none of whose events with a known location came through a VPN, a proxy or a
datacenter. Each visit is represented
by its first event with a known location, so two events of the same visit
never make a hop. Not departure points: the visits of assessed failed logins
(the same rule and limits as in Failed logins) and visits
with any bot event. When the current visit has no known location, or the
account has no such previous visit, travel is null.
A pair of visits is a hop when all three thresholds hold:
| Threshold | Value |
|---|---|
| distance | at least 500 km |
| speed | at least 900 km/h |
| interval between the two visits | at least 300 s |
The speed is the distance divided by the interval, with the interval counted as
at least 5 minutes. The hop is high when the speed is at least
2,000 km/h over at least 1,000 km, otherwise medium; when the current
visit came through a VPN, a proxy or a datacenter, the hop is low.
"travel": { "from": { "country": "RU", "city": "Moscow" }, "to": { "country": "US", "city": "New York" }, "distance_km": 7510, "speed_kmh": 7510, "interval_s": 3600, "confidence": "high", "suppressed_by": null}| Field | Meaning |
|---|---|
from, to | the country (ISO 3166-1 alpha-2) and, when we know it, the city of the previous location and of this visit. No coordinates are returned |
distance_km | the great-circle distance, rounded to a whole kilometer |
speed_kmh | the speed, rounded to a whole km/h |
interval_s | the seconds between the two visits |
confidence | high, medium or low (see above) |
suppressed_by | null, same_device or anonymized |
A hop can be suppressed:
same_device — both visits have the same device id or the same browser id
(device.visitor_id); an empty id never matches an empty id. The device did
not change, only its network exit did. The hop is shown in travel, but it
adds no reason and does not change the recommendation.anonymized — the current visit came through a VPN, a proxy or a
datacenter. The hop is low, the recommendation stays allow for it, and the
reasons carry impossible_travel_anonymized.How a hop that is not suppressed shapes the recommendation is in the table of
How the recommendation is made: challenge
with impossible_travel, and review when the hop is high and the device is
new and not trusted. A trusted device does not lift impossible_travel.
The location of a visit is the location of its IP address at the level of a
metropolitan area: country and city, nothing finer. A hop is a reason to ask for
more proof, not proof that someone else is signing in, and it never leads to
deny.
The Impossible travel events of the account-takeover feed in your dashboard
and the travel block of assess use the same thresholds, but they are not built
from the same set of visits. A hop that is in the feed and not in assess (or the
other way round) is expected in these cases:
allow, suppressed_by: "same_device").device.visitor_id). So the visitor of a feed row and the
visitor_id of an assess answer do not match one to one.new_device, which leaves out bot
events, not whole visits.Send failed attempts too, with login_status: "failed". A failed attempt is
assessed like any other (a challenge on it can tell you an attacker is trying
from a new device). Once a failed attempt has been assessed (state: "assessed"), its visit is left out of the account's history, so a device seen
only on such attempts stays new. The exclusion has two limits:
not_assessed with visit_not_found is not excluded: its visit had not
reached us yet, and if it arrives later tagged with this account's linkedId,
it becomes history. Retry such an attempt with a new assessment_id once the
visit is there. (Bot visits never enter the history in any case.)A visit you never sent to assess is not excluded. If your site sets linkedId
before the password is checked (for example, from the typed login), a visit of
someone who failed to sign in becomes history for that account — unless you
reported it with login_status: "failed" and it was assessed. Set linkedId only after a
successful sign-in, or report every failed attempt.
assessment_id is unique within your workspace. Repeating a call with the same
assessment_id:
idempotent_replay: true — nothing is
evaluated again, nothing new is stored, and the session token is not checked
again (so a retry after the token expired still gets its answer);409 assessment_conflict if the repeat differs from the stored call in
user_id, request_id, session or login_status.A replay returns the original assessment, including its assessed_at. A
not_assessed / visit_not_found answer is stored like any other: when the
visit arrives later, a repeat of the same assessment_id still returns
visit_not_found. To assess again, use a new assessment_id.
Without assessment_id, every call is a new assessment — a new record and, for
a challenge, a new webhook. If a call fails with 503, nothing was stored:
retry with the same assessment_id.
A trusted device is a device of a specific account that has passed your
step-up. On it, new_device is lifted. Trust is set only by your explicit
call — after the user passed the second factor; there is no automatic trust by
the number of logins.
| Method | Path | What it does |
|---|---|---|
| POST | /v2/accounts/{user_id}/trusted-devices | trust the device of a visit |
| GET | /v2/accounts/{user_id}/trusted-devices | list the account's trusted devices |
| DELETE | /v2/accounts/{user_id}/trusted-devices/{device_ref} | revoke trust |
{user_id} is your account id, URL-encoded (user%2F42 for user/42), under
the same rules as in user_id must match linkedId.
A user_id longer than 256 bytes, or with nothing printable in it, answers 404 not_found.
A trusted device matches a later login by the same device id or the same browser id — the same rule as "known device" above. Trust belongs to the account and to the environment of the key: the same device is not trusted for another account.
curl -X POST https://api.tracio.ai/v2/accounts/alice/trusted-devices \ -H "Authorization: Bearer $TRACIO_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "request_id": "0b6f5c1e-8a1d-4c3e-9f00-1234567890ab", "ttl_days": 30 }'| Field | Type | Required | Meaning |
|---|---|---|---|
request_id | string, UUID | one of the two | the visit whose device you trust — normally the visit of the login you just challenged |
session | string | one of the two | the inline_token of that visit; it lives 2 minutes, so after a step-up send request_id instead |
ttl_days | integer, 1–365 | no | how long the trust lasts; without it — the workspace setting (30 days unless changed) |
{ "device_ref": "7978151c54e94838", "enrolled_at": "2026-10-17T10:01:12Z", "expires_at": "2026-11-16T10:01:12Z", "source": "api"}201 — the device became trusted; 200 — it already had a record, and the
record was renewed: enrolled_at becomes now and expires_at becomes now
plus the TTL. A renewal can therefore also shorten the trust, if you pass a
smaller ttl_days. A device whose trust expired is renewed the same way; a
revoked device gets a new record (201).
The visit is looked up within the last 30 days. No such visit → 404 visit_not_found; a bot visit → 422 bot_visit.
The default lifetime of a trusted device is 30 days. Change it in the
dashboard, on the Devices & logins tab of an account card (the "Trusted
device TTL, days" field, 1–365); the setting applies to the whole workspace and
only the owner or an admin of the workspace can save it. A new value
applies to the next enroll or renewal; devices already trusted keep their
expires_at.
Expiry sends no webhook. An expired device simply stops matching: the next login from it is assessed as new or known by history.
{ "data": [ { "device_ref": "7978151c54e94838", "enrolled_at": "2026-10-17T10:01:12Z", "expires_at": "2026-11-16T10:01:12Z", "revoked_at": null, "revoked_by": null, "source": "api", "active": true } ]}Every record of the account, newest enrollment first, at most 200 — revoked and
expired ones included. active is true while the record is neither revoked
nor expired. revoked_by is api (revoked through this API) or dashboard
(revoked by a user of your dashboard — who exactly is not disclosed). An account
without records returns {"data": []}.
DELETE /v2/accounts/{user_id}/trusted-devices/{device_ref} answers 204 with
no body. There is no live record for this device_ref (never trusted, already
revoked, another account) → 404 not_found; a repeated revoke is therefore a
404, not an error to retry.
Revoke every trusted device of the account when its password changes, after an account recovery, or when the user reports a device lost — see the runbook.
From the dashboard, the Revoke button on the Devices & logins tab of the account card revokes the device in every environment of the workspace. Owners, admins and members can use it; read-only users cannot.
Three event types, delivered by the same webhooks as every other event — see webhooks.md for setup, signatures, retries and limits:
| Event | When |
|---|---|
login_risk | an assessment ended with challenge or review (not deny) |
trusted_device_enrolled | a device was enrolled or renewed |
trusted_device_revoked | a device was revoked — through the API or from the dashboard |
allow, deny and not_assessed assessments send nothing. A failed login
(login_status: "failed") that ends with challenge does send login_risk.
Only an explicit subscription by name delivers them: * does not include
these three types. Add each one by name to the event types of a webhook
(Settings → Webhooks). They are delivered only to webhooks on payload
version 2 or 3; a webhook on version 1 does not receive them.
A webhook bound to an environment receives these events only for that environment. Events from calls made with a key that has no environment go only to webhooks not bound to an environment.
The events are written together with the assessment or the trust change and are sent by a background relay: expect one up to about 5 seconds after the call. The webhook is a notification for your audit and alerting, not a channel for the decision — the decision is the synchronous response.
login_risk body{ "version": 2, "event": "login_risk", "eventId": "login-risk:4b1f9e2c-6d3a-4f80-9b7e-2a5c8d1e0f37", "timestamp": "2026-10-17T10:00:00Z", "login_risk": { "assessment_id": "snap-1", "user_id": "alice", "request_id": "0b6f5c1e-8a1d-4c3e-9f00-1234567890ab", "login_status": "success", "state": "assessed", "recommendation": "review", "reasons": ["new_device", "impossible_travel"], "device": { "device_ref": "7978151c54e94838", "visitor_id": "v1", "new_device": true, "trusted": false, "first_seen_at": null, "last_seen_at": null, "unique_devices_1d": 1, "unique_devices_7d": 2 }, "ato": { "type": "behavior-drift/2", "state": "not_assessed", "reason": "no_window" }, "location": { "country": "US", "city": "New York" }, "travel": { "from": { "country": "RU", "city": "Moscow" }, "to": { "country": "US", "city": "New York" }, "distance_km": 7510, "speed_kmh": 7510, "interval_s": 3600, "confidence": "high", "suppressed_by": null }, "assessed_at": "2026-10-17T10:00:00Z" }}The full file is examples/webhook-login-risk.json.
The envelope (version, event, eventId, timestamp) is camelCase like
every schema 2 event; the login_risk block is the stored assessment, in
snake_case, exactly as the synchronous response gave it, without
calculation_time and idempotent_replay. There are no visit blocks
(browser, geo, bot, ...).
trusted_device_enrolled and trusted_device_revoked body{ "version": 2, "event": "trusted_device_revoked", "eventId": "trusted-device:7", "timestamp": "2026-10-17T11:00:00Z", "trusted_device": { "user_id": "alice", "device_ref": "7978151c54e94838", "enrolled_at": "2026-10-17T10:00:00Z", "expires_at": "2026-11-16T10:00:00Z", "revoked_at": "2026-10-17T11:00:00Z", "revoked_by": "dashboard" }}The block is a snapshot at the moment of the event: a renewal or a revoke
that happens before the relay sends it does not change the body. Every renewal
is its own trusted_device_enrolled event with its own expires_at. With
trusted_device_enrolled, revoked_at and revoked_by are null. A revoke
from the dashboard that hits the device in two environments sends two
trusted_device_revoked events —
both to a webhook not bound to an environment,
one to a webhook bound to either of them.
Version 3 carries the same events with the envelope keys in snake_case
(event_id instead of eventId); the login_risk and trusted_device blocks
are already snake_case and come unchanged.
eventId equals the X-Tracio-Event-Id header and is stable across retries:
login-risk:<id> for an assessment, where <id> is our id of the stored record
(not your assessment_id — take that one from the body), and
trusted-device:<n> for a trust change. Delivery is at least once: store the
ids you have processed and skip a repeat. The events come with the headers of
schema 2 and X-Tracio-Event-Type set to the event type; there is no
X-Tracio-Request-Id — use login_risk.request_id to find the visit.
The account card has a Devices & logins tab: the trusted devices of the account (enrollment time, expiry, who revoked and when, the Revoke button), the latest login assessments (time, recommendation, reasons, device, city), the device counters for 24 hours and 7 days, and the workspace TTL setting.
Every assessment is stored — that is what idempotency and the dashboard feed
read. An assessment is kept for 90 days, the same depth as the account
history, and then deleted by a background cleanup that runs every hour. An
assessment whose login_risk webhook event has not been sent yet is kept until
it is sent. Until the cleanup removes an assessment, a repeat of its
assessment_id returns the stored result; after that, the repeat is evaluated
as a new call.
Errors of these endpoints use the v2 envelope:
{ "error": { "code": "invalid_request", "message": "request validation failed", "request_id": "01J8F3...", "errors": [ { "field": "request_id", "message": "must be a UUID" } ] } }errors is present only on validation failures and lists every invalid field
at once. request_id here is the id of this API call (also sent as the
X-Request-Id header) — quote it to support; it is not the visit's
request_id.
| HTTP | code | Reason |
|---|---|---|
| 400 | invalid_request | the body is not valid JSON, has an unknown key, is over 64 KiB, or fails validation (see errors): user_id missing, too long or unprintable, login_status not success/failed, assessment_id over 255 bytes, neither request_id nor session, request_id not a UUID, ttl_days outside 1–365 |
| 400 | session_invalid | session failed the signature or expiry check, or belongs to a different request_id |
| 404 | visit_not_found | enroll only: no visit with this request_id |
| 404 | not_found | trusted devices: the user_id in the path is unusable, or revoke found no live record |
| 409 | assessment_conflict | assessment_id was already used with a different request (see Idempotency) |
| 422 | bot_visit | enroll only: the visit is a bot |
| 503 | unavailable | storage is temporarily unavailable — retry; for assess, with the same assessment_id |
Assess answers 503 rather than a guess when the account history cannot be
read: an assessment without history would wrongly report first_device.
Responses of the shared authorization layer are still in the v1 envelope:
{"error":{"code","message","requestId"}}, with camelCase requestId. These are
401 unauthorized, 402 upgrade_required for a key without Data API access
(below Pro), 429 rate_limited with Retry-After, and 503 unavailable when
the key store is unreachable — see data-api.md, section
"Errors". Read the code from error.code: it has the same name in both
envelopes.
We have not measured the end-to-end latency of an assess call, and we publish no figure for it.
What was measured, on production on 17 October 2026, is one step of assess: the
read of the account history (the visits of this user_id over 90 days),
repeated for 100 accounts with at least 10 visits of history — p50 216 ms,
p95 237 ms, maximum 265 ms. The time was taken on the measuring host through a
tunnel with about 176 ms of round trip, and that round trip was not subtracted.
The lookup of the visit, the account-takeover verdict and the storage of the
assessment and of trusted devices were not part of that measurement.
Set your timeout from the latency budget of your own login, not from the figures above, and decide in advance what your login does when the call times out.
allow on a known
device lowers the friction; it does not certify who is signing in.unique_devices_1d / unique_devices_7d count device ids and can overstate
the number of physical devices.location is the country and city of the
visit's IP address, nothing more, and the city is often unknown. A roaming
phone whose traffic exits in its home country can therefore look like an
impossible-travel hop (travel is built from the same IP locations).allow does not mean the user
is who they claim to be, and challenge does not mean they are not. The
second factor is the proof; we only tell you when to ask for it.deny comes only from your own self-exclusion mark. We return deny
only when the account has a current self-exclusion you set (self_excluded,
see self-exclusion.md); every other signal tops out at
review. Refusing a login is still your decision, made on your data.no_window, see
above).