Password recovery is a second way into an account, and a weaker one than the
sign-in form: whoever controls the mailbox or the SIM gets the account without the
password and without a step-up login. POST /v2/recoveries/assess answers one
question at each step of a recovery: is this recovery risky enough to ask for
more proof, to delay, or to review by hand? You get a recommendation — allow,
challenge, hold, review or deny — with the reasons behind it.
We recommend; you act. Tracio does not block a recovery, does not send emails or SMS, does not issue reset tokens and does not touch the password: your backend asks us, gets the recommendation, and decides itself. The one thing Tracio does by itself is revoking the account's trusted devices after you report that the recovery was executed (see Outcome).
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. Authorization is the same secret key as for the rest of the
Data API (see data-api.md).
| Method | Path | What it does |
|---|---|---|
| POST | /v2/recoveries/assess | assess one stage of a recovery |
| POST | /v2/recoveries/{recovery_id}/outcome | record how the recovery ended; executed revokes the account's trusted devices |
| GET | /v2/recoveries/{recovery_id} | read the stages, the hold and the outcome of a recovery |
Tracio gives a recommendation and a record. Everything below stays with your application or your identity provider; none of it is done for you:
assess says, and do not wait for assess in the
request that renders it (see Limits). The form must never tell a
visitor that an account exists.deny the message is simply not sent, and
the form still answers as it does for everybody else.challenge — a second factor, or any proof you trust more
than the mailbox alone.hold and notify the old contacts. Tracio returns the
time until which to wait (hold.until, 24 hours after the first hold) and
blocks nothing: the delay is yours to enforce.review — your own operator looks at the case before the
password is written.POST /v2/recoveries/{recovery_id}/outcome — it closes
the loop and, for executed, triggers the revoke of the trusted devices.A recovery has two stages, and you assess each of them with the same
recovery_id:
requested — the user asked for a reset; call it before the email or the SMS is
sent.completed — the token is accepted and the new password is not written yet.curl -X POST https://api.tracio.ai/v2/recoveries/assess \ -H "Authorization: Bearer $TRACIO_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "recovery_id": "rec-8c1f", "stage": "requested", "reason": "forgot", "channel": "email", "request_id": "0f6c2d3a-91b4-4c1e-8a57-3b9d2e1f4a60", "identifier": "Alice@example.com", "contact_changed_at": "2026-10-17T09:30:00Z" }'Request body. An unknown key answers 400 invalid_request.
| Field | Type | Required | Meaning |
|---|---|---|---|
recovery_id | string | yes | 1–255 bytes. The idempotency key and the link between the stages of one recovery |
stage | string | yes | requested or completed |
reason | string | yes | forgot, forced or support |
channel | string | yes | email, sms, authenticator or support |
request_id or session | string | one of them | the visit of the reset form: the requestId of the visit from the SDK (a UUID), or a session token — see Binding the login to a visit |
user_id | string | on completed | your account id. On requested send it only if your app found the account |
identifier | string | on requested | the login the user typed. It is never stored — see Privacy |
contact_changed_at | string | no | RFC3339: when your app last changed the email or the phone of the account; not later than now plus 5 minutes |
There is no field for a token, a password or a one-time code, and you must not put one into any field.
{ "recovery_id": "rec-8c1f", "stage": "requested", "reason": "forgot", "channel": "email", "state": "assessed", "not_assessed_reason": null, "recommendation": "hold", "reasons": ["recent_contact_change"], "hold": { "until": "2026-10-18T12:00:00Z", "released_at": null, "released_by": null }, "device": { "device_ref": "7978151c54e94838", "new_device": false, "trusted": false, "first_seen_at": "2026-10-15T12:00:00Z", "last_seen_at": "2026-10-17T10:00:00Z" }, "travel": null, "ato": null, "self_exclusion": null, "location": {"city": "Berlin", "country": "DE"}, "velocity": {"account_1h": 1, "device_1h": null, "network_1h": null}, "assessed_at": "2026-10-17T12:00:00Z", "calculation_time": 41, "idempotent_replay": false}| Field | Meaning |
|---|---|
state | assessed, or not_assessed when the visit was not found (see below) |
not_assessed_reason | visit_not_found with not_assessed, otherwise null |
recommendation | allow, challenge, hold, review, deny, or null — nothing to recommend |
reasons | reason codes, in the fixed order of the table below |
hold | {until, released_at, released_by} with hold, otherwise null. until is 24 hours after the first hold of this recovery_id; released_by is dashboard once an operator lifted it |
device | the device of the visit: device_ref is a 16-hex reference, stable within your workspace; the raw device id is never returned. new_device, trusted, first_seen_at and last_seen_at are null without an account |
travel, ato, self_exclusion | the same blocks as in login-risk.md; null without an account. ato is Business+ |
location | the city and the country of the visit, or null |
velocity | counts of recovery requests over the last hour (see Velocity) |
idempotent_replay | true when this is a repeat of an identical call |
The response has no user_id, no identifier and no "account found" flag: the
set of keys is the same with and without user_id (see
Same response with and without an account).
A visit that is not found (request_id unknown, for example a script posting to
your form without the SDK) is not an error: the answer is state: "not_assessed"
with not_assessed_reason: "visit_not_found". The recommendation is then built
from the facts that do not need a visit — recovery_velocity_account and
recent_contact_change — and is null when neither fired. A script that
brute-forces the form without the SDK is therefore still caught by login.
The strongest recommendation wins: deny > review > hold > challenge > allow.
Without a user_id there are no account facts (history, trust, travel, ATO,
self-exclusion): the bot check, the velocity counters and recent_contact_change
decide.
| Condition | Recommendation | Code |
|---|---|---|
a bot, stage requested | deny | bot_traffic |
a bot, stage completed | challenge | bot_traffic |
| a trusted device, no other reasons | allow | trusted_device |
| the first device of the account | allow | first_device |
| a known device | allow | none |
| a new, untrusted device | challenge | new_device |
| an ATO alert on the visit (Business+) | challenge | ato_alert |
| an impossible-travel hop, medium or high, without an anonymizing arrival | challenge | impossible_travel |
| a high travel hop and a new, untrusted device | hold | impossible_travel, new_device |
| a travel hop with an anonymized arrival | allow | impossible_travel_anonymized |
3 or more requested in an hour for the same login (account) | challenge | recovery_velocity_account |
| 3 or more different logins in an hour from the same device (Business+) | challenge | recovery_velocity_device |
| 5 or more different logins in an hour from the same /24 (IPv6: /48) network (Business+) | challenge | recovery_velocity_network |
contact_changed_at within the last 72 hours | hold | recent_contact_change |
completed on an untrusted device other than the one that sent requested | hold | completion_device_mismatch |
| the account carries a self-exclusion mark | allow, never raised | self_excluded |
| a link to a self-excluded account | review | self_excluded_link |
deny is only for a bot at stage requested. A self-excluded account does not
raise the recommendation: it still cannot sign in, because
/v2/logins/assess answers deny for it. review is only for
a link to a self-excluded account.
The order of reasons is fixed: trusted_device, first_device, new_device,
ato_alert, impossible_travel, impossible_travel_anonymized, self_excluded,
self_excluded_link, bot_traffic, recovery_velocity_account,
recovery_velocity_device, recovery_velocity_network, recent_contact_change,
completion_device_mismatch.
| Code | Meaning |
|---|---|
trusted_device, first_device, new_device | the device against the account's history; a trusted device removes only new_device — the same rules as in login-risk.md |
ato_alert, impossible_travel, impossible_travel_anonymized, self_excluded, self_excluded_link | the same codes and meaning as on a login |
bot_traffic | the visit is a bot: device_method is bot or the bot check says bot |
recovery_velocity_account | 3 or more requested in the last hour with the same login bucket; with the same user_id when no identifier was sent |
recovery_velocity_device | 3 or more different login buckets on requested in the last hour from the same device (Business+) |
recovery_velocity_network | 5 or more different login buckets on requested in the last hour from the same /24 (IPv6: /48) network (Business+) |
recent_contact_change | the contact of the account was changed within the last 72 hours before the call |
completion_device_mismatch | the recovery was requested from one device and completed from another, untrusted one |
The counters cover the last hour and include the current call. Each code that
fires gives challenge.
account_1h — requested stages with the same login bucket, or with the same
user_id when no identifier was sent.device_1h — different login buckets from the same device. Business+; null
on Pro.network_1h — different login buckets from the same /24 (IPv6: /48) network.
Business+; null on Pro.A repeat of the same login does not grow the device and network counts: they count
distinct buckets. Velocity is counted on requested only; on completed the block
has the same keys with null values. A visit without a device or without a network
key counts as zero for that counter, not as one shared key.
The key is (recovery_id, stage). A repeat with the same body answers with the
stored response and idempotent_replay: true, writes no second row and does not
grow the velocity counters. A repeat with a different body answers
409 assessment_conflict. The body is compared by its login bucket in place of
identifier, the other fields as they are.
The error body is the same envelope as everywhere in /v2.
| Status | Code | When |
|---|---|---|
| 400 | invalid_request | an unknown key, or a field outside its rules |
| 400 | session_invalid | the session token is unusable |
| 401, 402 | the key is wrong, or has no Data API access | |
| 409 | assessment_conflict | the same recovery_id and stage were assessed with a different body |
| 503 | unavailable | the answer could not be built; repeat the call |
holdhold means: let some time pass before the password is written, and tell the
owner. Tracio changes nothing on the account, in the sign-in or in the sessions;
the delay and the notification of the old contacts are done by your app, and the
time to wait comes from the response.
hold.until is 24 hours after the first hold of the recovery_id. A
second hold on the stage completed keeps the first until: repeating a stage
does not extend the delay.until sends no event: your app knows the time from the response.hold in the dashboard, on the
Devices & logins tab of the account card, in the Password recoveries
section. The Release hold button asks for a reason and needs the
workspace:general:write right. The release is written to the audit log and
sent as a recovery_hold_released webhook. A hold that is already released, has
no live row, is past its until or belongs to a recovery that already has an
outcome cannot be released.GET /v2/recoveries/{recovery_id} shows hold.released_at and
hold.released_by (dashboard).Report how the recovery ended. The first value wins.
curl -X POST https://api.tracio.ai/v2/recoveries/rec-8c1f/outcome \ -H "Authorization: Bearer $TRACIO_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"outcome": "executed"}'outcome | Meaning |
|---|---|
executed | the password was changed |
abandoned | the user did not finish the recovery |
confirmed_fraud | you confirmed that the recovery was an attack |
user_reported_unauthorized | the owner reported that they did not ask for it |
{ "recovery_id": "rec-8c1f", "outcome": "executed", "recorded_at": "2026-10-17T12:40:00Z", "trusted_devices_revoked": 2, "idempotent_replay": false}200 with the stored outcome and idempotent_replay: true, and changes
nothing.404 recovery_not_found — the workspace has no assessment with this
recovery_id. 400 invalid_request — an unknown key or an outcome outside the
list.executed revokes every live trusted device of the account in every
environment of the workspace, in the same step as the record, and sets
revoked_by: recovery. trusted_devices_revoked is the number of devices it
revoked. Each revoke sends a trusted_device_revoked webhook.recovery_id carried a user_id, there is no account
to revoke devices of: trusted_devices_revoked is 0. A repeated executed
does not revoke again. The other outcomes revoke nothing.You do not revoke the devices yourself after a recovery if you send executed —
see runbook-login-step-up.md.
GET /v2/recoveries/{recovery_id}:
{ "recovery_id": "rec-8c1f", "stages": [ { "stage": "requested", "recommendation": "hold", "reasons": ["recent_contact_change"], "assessed_at": "2026-10-17T12:00:00Z" } ], "hold": {"until": "2026-10-18T12:00:00Z", "released_at": null, "released_by": null}, "outcome": null}hold and outcome are null while there is none. A recovery_id of another
workspace answers 404 recovery_not_found.
Two event types, delivered by the same webhooks as every other event — see
webhooks.md for setup, signatures, retries and limits. Both are
sent only on an explicit subscription by name — * does not include them —
and only to webhooks on payload version 2 or 3.
| Event | Plan | When |
|---|---|---|
recovery_risk | all | an assessment ended with challenge, hold or review, unless every reason is recovery velocity (neutral reasons aside) |
recovery_hold_released | all | an operator released a hold in the dashboard |
allow, deny and null are not sent: deny is a bot, which is noise for an
integration. The plan does not throttle the stream: Pro receives it as well.
recovery_risk is not sent for an assessment whose reasons are all
recovery_velocity_account, recovery_velocity_device or
recovery_velocity_network (together with trusted_device, first_device and
impossible_travel_anonymized); bot_traffic and any other reason send it as
before. recovery_hold_released is always sent. A
webhook not bound to an environment gets the events of every environment; a bound
one gets its own.
The revoke of the trusted devices after executed sends trusted_device_revoked
with revoked_by: "recovery" — see login-risk.md.
The events are sent by a background relay, up to about 5 seconds after the call,
and carry no X-Tracio-Request-Id header. X-Tracio-Event-Id is stable:
recovery-risk:<id> and recovery-hold-released:<id>; deduplicate by it. Do not
wait for a webhook in the recovery flow: the decision is the synchronous answer.
recovery_risk bodyThe recovery_risk block is the stored assessment, in snake_case, exactly as the
synchronous response gave it, without calculation_time and idempotent_replay.
{ "version": 3, "event": "recovery_risk", "event_id": "recovery-risk:4b1f9e2c-6d3a-4f80-9b7e-2a5c8d1e0f37", "timestamp": "2026-10-17T12:00:00Z", "recovery_risk": { "recovery_id": "rec-8c1f", "stage": "requested", "reason": "forgot", "channel": "email", "state": "assessed", "not_assessed_reason": null, "recommendation": "hold", "reasons": ["recent_contact_change"], "hold": {"until": "2026-10-18T12:00:00Z", "released_at": null, "released_by": null}, "device": { "device_ref": "7978151c54e94838", "new_device": false, "trusted": false, "first_seen_at": "2026-10-15T12:00:00Z", "last_seen_at": "2026-10-17T10:00:00Z" }, "travel": null, "ato": null, "self_exclusion": null, "location": {"city": "Berlin", "country": "DE"}, "velocity": {"account_1h": 1, "device_1h": null, "network_1h": null}, "assessed_at": "2026-10-17T12:00:00Z" }}recovery_hold_released body{ "version": 3, "event": "recovery_hold_released", "event_id": "recovery-hold-released:4b1f9e2c-6d3a-4f80-9b7e-2a5c8d1e0f37", "timestamp": "2026-10-17T15:20:00Z", "recovery_hold_released": { "recovery_id": "rec-8c1f", "user_id": "alice", "hold_until": "2026-10-18T12:00:00Z", "released_at": "2026-10-17T15:20:00Z", "released_by": "dashboard" }}| What | Plan |
|---|---|
the three routes, recovery_risk and recovery_hold_released | Pro and up — the same access as /v2/logins/assess (the apiAccess entitlement); the method has no gate of its own |
the ato block and the code ato_alert | Business and up |
the code recovery_velocity_device and velocity.device_1h | Business and up |
the code recovery_velocity_network and velocity.network_1h | Business and up |
On Pro the Business+ codes never appear and never change the recommendation, but the journal keeps the device reference and the network key of every call: after an upgrade the counters see the earlier history. The webhook stream is not gated by the plan.
identifier is never stored and never logged. Tracio keeps only a login
bucket: 16 hex characters of an HMAC of the lower-cased login with a key of your
workspace. The bucket is for counting repeats, not an identity — User@x and
user@x land in the same one. The raw IP is not stored either; the journal keeps
a 16-hex key of the /24 (IPv6: /48) network. The journal is kept for 90 days.
The set of keys of the response is the same with and without user_id. The
fields that depend on the account — device.new_device, device.trusted,
device.first_seen_at, device.last_seen_at, travel, ato and
self_exclusion — are null without one. There is no "account found" field.
Your form must answer a visitor in the same way for a login that exists and one
that does not.
assess with an account and without one differs. Do not wait for
assess before you answer the reset form, or the response time will tell a
visitor whether the account exists. Call it after, or off the request path.hold blocks nothing. The 24-hour delay is yours to enforce; Tracio only
tells you the time and records the release.review only for a link to a self-excluded account (self_excluded_link).
No other reason ends with review.