A self-exclusion is a mark you put on an account of your workspace: this player
asked not to be let in until a given date. From then on, POST /v2/logins/assess
(login-risk.md) answers deny for a login to that account and
review for a login that looks like the same person coming back under another
account.
We recommend; you act. Tracio does not block a login and does not keep sessions:
your backend asks us after it has checked the password, gets the recommendation,
and decides itself. deny and review are recommendations; enforcing them is up
to you.
The mark is your record inside your workspace. It is not a regulatory register and it is not a confirmation of identity. Tracio does not learn who the player is, does not compare documents and does not ask any national register.
Self-exclusion registers exist per country, and a licensed operator is required to check them by the player's documents, with the operator's own licence key:
The registry is checked by you with your licence key; Tracio does not query registries — it looks for the same person under a new identity in your traffic. So the mark works in two ways: you put it on an account (from your own records or from a register answer), and we help you notice the player who comes back with a new e-mail, a new phone or a new account.
The mark lives only in your workspace. Another workspace never sees it. It does not change the verdict of a visit and does not enter the reputation of a person.
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. The endpoints and the deny / review outcomes are available on every
plan with Data API access; no plan gates them.
The routes live under /v2: every key in the request, the response and the error
body is snake_case. The mark belongs to the whole workspace; the environment
of your key is stored only as the origin of the mark and does not narrow it.
| Method | Path | What it does |
|---|---|---|
| POST | /v2/self-exclusions | set a self-exclusion on an account, or extend the current one |
| GET | /v2/self-exclusions | list the workspace's self-exclusions, with their history |
| DELETE | /v2/self-exclusions/{user_id} | lift a self-exclusion early, with a reason |
Two errors are common to all three routes. A request that the storage rejects as
invalid data is 400 invalid_request with the message self-exclusion rejected by storage: … — the mark is not changed, and retrying the same request would not
help. When the storage is unavailable, the answer is 503 unavailable: nothing
was changed, retry.
curl -X POST https://api.tracio.ai/v2/self-exclusions \ -H "Authorization: Bearer $TRACIO_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "user_id": "alice", "until": "2027-04-19T00:00:00Z", "reason": "register hit, 6 months" }'| Field | Type | Required | Meaning |
|---|---|---|---|
user_id | string, ≤ 256 bytes | yes | your account id — the same value you pass to the SDK as linkedId and to assess as user_id (same normalization, see login-risk.md) |
until | string, RFC 3339 | yes | when the exclusion ends; strictly later than now and not later than 100 years from now. For an open-ended exclusion, send a far date |
reason | string, ≤ 500 bytes | no | free text for your records |
{ "user_id": "alice", "until": "2027-04-19T00:00:00Z", "reason": "register hit, 6 months", "status": "active", "created_at": "2026-10-19T10:00:00Z", "created_by": "api", "revoked_at": null, "revoked_by": null, "revoke_reason": null}| Status | Meaning |
|---|---|
201 | a new self-exclusion was created (also over an expired one) |
200 | the account already had a current self-exclusion, and its term was extended |
400 | the body failed validation; the errors array names the fields |
409 self_exclusion_shortening | until is earlier than the current term. The mark is not changed |
A second POST for an account with a current self-exclusion can only extend it:
the same until or a later one. A shorter term is an early lifting, and it goes
only through DELETE with a reason — otherwise until set to "one second from
now" would lift the mark without a reason and without a trace.
{ "error": { "code": "self_exclusion_shortening", "message": "until is earlier than the current term; use DELETE with a reason to lift the exclusion early", "request_id": "0b6f5c1e-8a1d-4c3e-9f00-1234567890ab" }}There is no check that the account exists: you may exclude a player before his or her first sign-in.
| Field | Meaning |
|---|---|
user_id | the account, after normalization |
until | the end of the term, RFC 3339 UTC |
reason | the reason you gave when setting it; empty when none |
status | active, expired or revoked — computed on read, see below |
created_at, created_by | when the mark was set and by whom (api for this API) |
revoked_at, revoked_by, revoke_reason | null while the mark stands; with revoked — when, by whom (api or the id of a dashboard user) and why |
The mark acts while until is in the future. There is no background cleanup: at
the moment until passes, the mark stops acting, and it is listed as expired.
DELETE on an expired mark answers 404 — there is nothing to lift.
Every episode of self-exclusion is a separate record. A new POST over an expired
mark closes the old record (it stays expired) and creates a new one, so the list
keeps the whole history of the account: when each episode started, how long it
lasted, and who lifted it and why. Setting, extending and lifting a mark are also
written to the audit trail of the workspace.
curl "https://api.tracio.ai/v2/self-exclusions?status=all&limit=50" \ -H "Authorization: Bearer $TRACIO_SECRET_KEY"| Parameter | Meaning |
|---|---|
status | active (default), expired, revoked or all |
user_id | an exact match, after normalization |
limit | 1–100, default 50 |
cursor | the next_cursor of the previous page |
{ "data": [ { "user_id": "alice", "until": "2027-04-19T00:00:00Z", "status": "active", "...": "..." } ], "has_more": false, "next_cursor": null}The newest records come first.
has_more is true whenever the page is full (exactly limit records). So when
the last records fit the page exactly, you get has_more: true and a
next_cursor, and the next page comes back empty with has_more: false. Stop on
an empty page, not only on has_more: false.
curl -X DELETE https://api.tracio.ai/v2/self-exclusions/alice \ -H "Authorization: Bearer $TRACIO_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "reason": "player request confirmed by the register" }'{user_id} is URL-encoded (user%2F42 for user/42). A {user_id} longer than
256 bytes, or empty after normalization, is 404 not_found with the message
account not found; it is checked before the body. The body is required, and
so is reason in it (1–500 bytes). Without it — 400, and the mark is not
changed. There is no current mark for the account (none at all, already lifted,
or expired) — 404 not_found. On success — 200 with the object, status: "revoked".
DELETE carries a JSON body. The reason is free text and does not belong to
the URL, where it would end up in access logs. Some HTTP clients and proxies drop
the body of a DELETE; check that yours sends it, or you get 400.
Details of the response are in login-risk.md; in short:
| Situation | recommendation | reasons |
|---|---|---|
| the login is for an account with a current self-exclusion | deny | self_excluded |
| the login looks like the same person as a self-excluded account (a link, see below) | not weaker than review | self_excluded_link |
This is the only place where assess returns deny. It applies even when the
visit could not be assessed (state: not_assessed): the response keeps its
state and reason but carries recommendation: "deny", because an operator
must not let the player in whether or not the fingerprint arrived. A trusted
device lifts new_device only; it never lifts self_excluded or
self_excluded_link.
A link needs a visit, and it has two bases. The block self_exclusion of the
answer names the one that worked, in link_type:
link_type | Basis | confidence |
|---|---|---|
user | the same user_id as the mark (not a link, a direct hit) | high |
person | the visit belongs to the same person as the marked account in the person graph | medium |
device | the device id of the visit has been seen on the marked account in its non-bot visits over the last 90 days | medium |
person_device | both bases hold | high |
none | no link; the block only explains why (see person_excluded, link_unavailable) | null |
If several marked accounts are linked, the strongest link is returned (higher
confidence, then the later until).
person_uid) is an identifier of the Tracio graph, which is built across
customers; but the link is applied strictly within your workspace: only your
marks and your user_id values are read, and nothing from another workspace is
ever returned.Sometimes the person of the visit matches the marked account's, but the link by
person is not made. The answer says why in self_exclusion.person_excluded:
person_excluded | Why there is no link by person |
|---|---|
poisoned | the person is flagged as poisoned |
visitor_quarantine | one of the browsers of the person is in quarantine. The price: one browser in quarantine switches off the link by person for the whole person — a miss, which is acceptable for a review |
magnet | the person is a magnet: it reached the cap of linked identifiers or browsers (at least 10 identifiers or 64 browsers) and could attract anyone |
device_bridge | the person was glued through a device bridge, and the device of that merge carried more than 4 account ids (user_id). A merge through a device with 4 or fewer does not cut the link off. Recognized only while the device-bridge lever is not off |
person_status_unknown | the mark is not classified yet, or the device-bridge lever is off (device_bridge_mode = off). With the lever off, the status of the person cannot be told, so every mark gets person_status_unknown and no link by person is made at all; the reasons above are not checked then |
When only a mark whose person is excluded matched, and there is no link by device,
the block comes with link_type: "none", confidence: null, excluded_user_id,
until and person_excluded; the recommendation and the reasons do not change.
"self_exclusion": { "link_type": "none", "excluded_user_id": "alice", "until": "2027-04-19T00:00:00Z", "confidence": null, "person_excluded": "magnet"}The link by device looks at the devices of at most 1,000 current marks of the
workspace, the newest first. Beyond that, the older marks are still checked for a
direct hit (deny) and by person, but not by device.
State them to your compliance team as they are:
shadow mode in production. A review by
link_type=person is issued all the same.deny for the same user_id
and a link by device work at once). A merge of two persons becomes visible after
the next pass as well. If we never saw the account's linkedId in your traffic,
its person is not found at all, and there is nothing to link by.persons.poisoned) is not set in production today,
so poisoned does not occur yet. While the device-bridge lever is off, there is
no link by person for any mark (person_status_unknown, see above).self_exclusion.link_unavailable: true
(with link_type: "none" when nothing else matched). The deny for the same
user_id and the link by person stay.deny is not sent as a webhook. A review by a link is sent with the
existing login_risk event, which carries self_excluded_link and the
self_exclusion block.