POST /v2/transactions gives you a synchronous decision on a business event in
your product — a registration, a login, a deposit, a withdrawal, a payment and,
above all, a bonus claim. The main scenario is bonus abuse: one player
claiming the same bonus campaign from several accounts.
We decide; you act. Tracio does not keep bonus balances, does not credit or
debit anything and does not track wagering: your backend asks us before it
grants the bonus, gets APPROVE, REVIEW or DECLINE, and enforces it itself.
The decision is enforced from the first call — there is no observe-only mode.
Base URL: https://api.tracio.ai. The route lives under /v2, the first route
of the snake_case version of the Data API: every key in the request, the
response and the error body is snake_case. The /v1 routes described in
data-api.md keep their camelCase shape.
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". A call counts against the usual Data API per-second limit and daily
quota of your plan; there is no separate meter.
The endpoint requires the Business plan or higher. A key on a lower plan
gets HTTP 402:
{ "error": { "code": "upgrade_required", "message": "Transactions require the Business plan or higher", "request_id": "01J8F3..." } }One exception: below Business the endpoint accepts an account_register or a
deposit that carries an affiliate_id — an affiliate conversion, see
affiliates.md. Such a call is not run
through the bonus rules.
A key without Data API access at all (below Pro) is turned away earlier, by the
shared authorization layer, with 402 upgrade_required in the v1 envelope — see
Errors.
curl -X POST https://api.tracio.ai/v2/transactions \ -H "Authorization: Bearer $TRACIO_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "action_type": "bonus", "transaction_id": "bns-81723", "user_id": "usr_8412", "request_id": "01J8F3ZK8Q0000000000000000", "bonus_campaign_id": "welcome-200", "transaction_amount": 50, "transaction_currency": "EUR", "custom_fields": { "affiliate": "aff-17" } }'| Field | Type | Required | Meaning |
|---|---|---|---|
action_type | string | yes | one of account_register, account_login, deposit, withdrawal, bonus, payment, account_update, other |
transaction_id | string, ≤ 255 bytes | yes | your id of the event; the idempotency key within your workspace |
user_id | string, ≤ 255 bytes | yes | your account id — the same value you pass to the SDK as linkedId (see below) |
request_id | string | no | the Tracio visit (requestId from the SDK) during which the event happened |
session | string | no | the inline verdict token tracio_vt1.… of that visit; checked if present |
bonus_campaign_id | string, ≤ 100 bytes | no | the bonus campaign being claimed |
transaction_amount | number, ≥ 0 | no | the amount |
transaction_currency | string, ≤ 4 bytes | when transaction_amount is set | the currency of the amount, e.g. EUR |
custom_fields | object of strings | no | up to 50 keys, key ≤ 40 bytes, value ≤ 500 bytes; stored with the transaction, not used by the rules |
affiliate_id | string, ≤ 100 bytes | no | the affiliate who brought the player; only on account_register and deposit — see affiliates.md |
sub_id | string, ≤ 100 bytes | no | the affiliate's sub-id; only on account_register and deposit |
click_id | string, ≤ 200 bytes | no | the click id of the tracking link; only on account_register and deposit |
affiliate_user_id | string, ≤ 128 bytes | no | the affiliate's own account at your casino, in the form of its linkedId; only on account_register and deposit |
The body is at most 64 KiB. Unknown keys are rejected with 400 invalid_request — a typo in a field name fails loudly instead of being
ignored. Every custom_fields value must be a string.
request_id is the main link to the visit: from it we take the device
(visitor_id), the person behind the account and the visit's guidance (see
data-api.md, section "Guidance"). The visit is looked up within
the last 30 days, in the environment of your key.
session is an optional authenticity check. Pass the inline_token your
frontend received (see inline-verdict.md), and we verify
its signature and expiry. If request_id is absent, the token's requestId is
used; if both are present, they must match. The token lives 2 minutes from
the visit, so it fits an event that follows the page load immediately (a
registration, a login); a deposit or a bonus claimed later in the session will
fail the check — send only request_id for those.
Both fields are optional. Without a visit the decision is still made on what is
available and is marked partial (see Response).
user_id must match linkedIdThe rules find a player's other accounts through the identity graph, which is
built from the linkedId your site passes to the SDK. user_id is matched
against it byte for byte: case and spaces are significant (USR_8412,
usr_8412 and usr_8412 are three different accounts). Send the same id in
the same form in both places. 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.
200 OK:
{ "id": "6f1c2d7e-3b4a-4c5d-9e8f-0a1b2c3d4e5f", "transaction_id": "bns-81723", "state": "REVIEW", "fraud_score": 60, "applied_rules": [ { "id": "B01", "name": "same_campaign_same_person", "operation": "+", "score": 60 } ], "partial": true, "partial_sources": ["visit"], "recommendations": ["hold_bonus_until_review"], "evidence": [ { "rule_id": "B01", "code": "same_campaign_same_person", "score": 60, "message": "Another user ID of the same person already took this campaign." } ], "calculation_time": 37, "idempotent_replay": false}| Key | Meaning |
|---|---|
id | our id of the stored transaction (UUID) |
transaction_id | your id, echoed back |
state | APPROVE, REVIEW or DECLINE — the decision to enforce |
fraud_score | sum of the scores of the rules that fired, capped at 100 |
applied_rules | only the rules that fired: id, name, operation (always +), score; an empty array when none fired |
partial | present, with the value true, only when some data source was unavailable; an ordinary response has no partial key |
partial_sources | present together with partial: which sources were missing, sorted — visit (no request_id, no visit for it, the visit store was unavailable, or the visit's guidance was computed on incomplete data — the same case where guidance.partial is true in GET /v2/sessions/{request_id}), person (the account has no person yet — for example, it never went through the SDK — so B01 was skipped), history (your workspace's rule settings could not be read; the defaults below were used) |
recommendations | what to do with the bonus, from the effective state of the transaction (see below); always an array |
evidence | up to 5 reasons behind the decision of the rules (see below); an empty array when no rule fired |
calculation_time | how long this call took, milliseconds |
idempotent_replay | true when this is a stored decision returned for a repeated transaction_id |
affiliate_conversion | the reasons on an affiliate conversion — affiliate_id, sub_id, click_id, conversion, advice, score, recommendation, hold_until, reasons, pending_async (see affiliates.md); null when the transaction is not attributed to an affiliate. It never changes state, fraud_score, applied_rules or recommendations |
recommendationsThe action for your backend, derived from the effective state: the state an
analyst or a label set on the transaction, if any,
otherwise state. On a first call nobody has changed the transaction yet, so
the effective state is state.
| Effective state | recommendations |
|---|---|
DECLINE | ["withhold_bonus"] |
REVIEW | ["hold_bonus_until_review"] |
APPROVE | [] |
evidenceHuman-readable reasons, built from applied_rules: at most 5 elements, sorted
by score from high to low (equal scores — by rule_id). Each element:
| Key | Meaning |
|---|---|
rule_id | the id of the rule in applied_rules |
code | a stable machine code of the reason |
score | the rule's contribution to fraud_score |
message | a short English explanation |
rule_id | code | message |
|---|---|---|
| B01 | same_campaign_same_person | Another user ID of the same person already took this campaign. |
| B02 | same_campaign_same_ring | Another account of the same fraud ring already took this campaign. |
| B03 | campaign_device_velocity | Several distinct user IDs took this campaign on the same device. |
| B04 | bonus_to_deposit_ratio | Total bonus used divided by deposits exceeds the ratio (same currency). |
| B05 | visit_guidance | The visit verdict is deny, review or challenge. |
For a rule not in this table, code is its rule_id in lower case and
message is its name. A transaction outside the bonus flow has no applied
rules, so its evidence is [].
Bonus flow — a transaction with a non-empty bonus_campaign_id, or with
action_type: "bonus". It goes through the bonus rules B01–B05 below; the
scores of the rules that fired are summed into fraud_score, and the state is
DECLINE at fraud_score ≥ 80, REVIEW at ≥ 50, otherwise APPROVE
(default thresholds).
Everything else — the state comes from the guidance of the visit, for the scenario that matches the action:
action_type | guidance scenario |
|---|---|
account_login | login |
account_register | registration |
deposit, withdrawal, payment | payment |
account_update, other | overall |
deny → DECLINE, review or challenge → REVIEW, allow → APPROVE.
No visit → APPROVE with partial: true and partial_sources: ["visit"]. These
transactions have fraud_score: 0 and an empty applied_rules: the bonus rules
are not run for them.
Every transaction is stored, partial and non-bonus ones included: stored deposits feed rule B04, and stored bonus claims are what the next call is counted against.
| ID | name | Fires when | Default score |
|---|---|---|---|
| B01 | same_campaign_same_person | another user_id of the same person claimed this campaign within the last 90 days | +60 |
| B02 | same_campaign_same_ring | another account of the same fraud ring claimed this campaign | +50 |
| B03 | campaign_device_velocity | on the same device (visitor_id of the visit), at least 2 different user_id — the current one included — claimed this campaign within 24 hours | +40 |
| B04 | bonus_to_deposit_ratio | over the last 30 days, the account's total bonus used divided by its total deposits is above 1.0 | +20 |
| B05 | visit_guidance | the visit's overall guidance is deny | +50 |
the visit's overall guidance is review or challenge | +20 |
Default thresholds: REVIEW at fraud_score ≥ 50, DECLINE at ≥ 80.
Details:
transaction_amount over the
account's transactions with a non-empty bonus_campaign_id; "deposits" is the
sum over its transactions with action_type: "deposit". Both include the
current transaction and count only amounts in the currency of the current
transaction. No deposits — the rule does not fire. A transaction without a
currency does not evaluate B04 at all. For B04 to work, report deposits to
this endpoint too.user_id was the linkedId. No person — B01 is
skipped and person appears in partial_sources.request_id).Properties the defaults rely on: no single rule reaches DECLINE on its own
(the maximum is +60), so a second signal is needed; an honest family on one
device (two different persons, one bonus each) gets only B03 = 40 → APPROVE.
The defaults are expert estimates, not a calibration on your traffic. For the
first weeks, check the applied_rules of REVIEW and DECLINE decisions in
the dashboard and tune the settings.
Dashboard → Transactions → Rule settings. Per workspace you can switch each
rule on or off and change its score, its window (B01 and B04 in days, B03 in
hours), the minimum number of accounts for B03, the ratio for B04, both B05
scores, and the REVIEW and DECLINE thresholds. The next call uses the new
values. A workspace that never saved settings uses the defaults above.
transaction_id is unique within your workspace. Repeating a call with the same
transaction_id:
idempotent_replay: true — the rules are
not 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 transaction_conflict if the repeat differs from the stored
transaction in action_type, user_id, bonus_campaign_id,
transaction_amount, transaction_currency or the affiliate fields
(affiliate_id as you sent it; sub_id, click_id and affiliate_user_id
of an attributed conversion). request_id, session and custom_fields are
not compared.calculation_time of a replay is the time of the replay itself.
recommendations of a replay follow the current effective state: if an
analyst or a label declined a transaction the rules approved, the replay keeps
state: "APPROVE" and returns recommendations: ["withhold_bonus"].
If a call fails with 503, nothing was stored: retry with the same
transaction_id.
A replay always returns the original decision in state. To read the state an
analyst or a label set, use
GET /v2/transactions/{transaction_id};
to be told when it changes, subscribe to the
transaction_state_changed webhook.
GET /v2/transactions/{transaction_id}Re-reads a stored decision together with its label and effective state. The
same key, the same plan (Business+) and the same per-second limit and daily
quota as POST /v2/transactions; a lower plan gets the same 402 upgrade_required, except for a transaction attributed to an affiliate, which is
returned on every plan (see affiliates.md).
curl -H "Authorization: Bearer $TRACIO_SECRET_KEY" \ https://api.tracio.ai/v2/transactions/bns-81723transaction_id is your id, the one you sent to POST. Percent-encode it in
the path if it contains /, %, a space or other reserved characters
(bns%2F81723 for bns/81723); the lookup uses the decoded value.
200 OK:
{ "id": "6f1c2d7e-3b4a-4c5d-9e8f-0a1b2c3d4e5f", "transaction_id": "bns-81723", "state": "REVIEW", "fraud_score": 60, "applied_rules": [ { "id": "B01", "name": "same_campaign_same_person", "operation": "+", "score": 60 } ], "partial": true, "partial_sources": ["visit"], "effective_state": "DECLINE", "recommendations": ["withhold_bonus"], "evidence": [ { "rule_id": "B01", "code": "same_campaign_same_person", "score": 60, "message": "Another user ID of the same person already took this campaign." } ], "label": "fraud_detection_bonus_abuse", "label_source": "dashboard", "label_at": "2026-10-15T10:04:00Z", "calculation_time": 2, "idempotent_replay": false}The keys of the POST response, with the same meaning, plus:
| Key | Meaning |
|---|---|
effective_state | the outcome to act on: the state an analyst or a label set, if any, otherwise state; recommendations follow it |
label | the current label of the transaction, or null |
label_source | where the label was set: api or dashboard; null without a label |
label_at | when the label was set (UTC, RFC 3339); null without a label |
state stays the decision of the rules. idempotent_replay is always false:
a GET stores nothing. Who set the label is not returned.
A transaction_id that does not exist in the workspace of your key gets 404
in the v2 envelope:
{ "error": { "code": "not_found", "message": "transaction not found", "request_id": "01J8F3..." } }A transaction of another workspace looks exactly like a missing one.
To be told about a change instead of polling, use the
transaction_state_changed webhook: its
transaction block is this object without calculation_time and
idempotent_replay.
Errors of this endpoint use the v2 envelope:
{ "error": { "code": "invalid_request", "message": "request validation failed", "request_id": "01J8F3...", "errors": [ { "field": "action_type", "message": "must be one of account_register, account_login, deposit, withdrawal, bonus, payment, account_update, other" }, { "field": "transaction_currency", "message": "is required when transaction_amount is set" } ] } }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); also a value the storage rejects (for example an amount out of range) |
| 400 | affiliate_fields_not_allowed | an affiliate field on an action_type other than account_register and deposit; checked before the plan |
| 400 | invalid_affiliate_field | an affiliate field over its byte limit or with a control character |
| 400 | session_invalid | session failed the signature or expiry check, or belongs to a different request_id |
| 402 | upgrade_required | plan below Business, except an affiliate conversion; for GET below Business — a transaction without an affiliate |
| 404 | not_found | GET only: no transaction with this transaction_id in your workspace |
| 409 | transaction_conflict | transaction_id was already used with a different request (see Idempotency) |
| 503 | unavailable | storage is temporarily unavailable — retry with the same transaction_id |
Responses of the shared authorization layer are still in the v1 envelope
until the whole API moves to v2: {"error":{"code","message","requestId"}},
with camelCase requestId and without errors. These are 401 unauthorized
(missing, revoked or expired key), 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. Their meaning is the one in
data-api.md, section "Errors". Read the code from
error.code: it has the same name in both envelopes.
Retry 429 and 503 with a growing pause; retrying the other 4xx is
pointless — they mean the request itself needs fixing.
The request and response follow SEON's Fraud API, so an existing SEON integration maps field for field.
Request:
| SEON | Tracio | Note |
|---|---|---|
action_type | action_type | same values |
transaction_id | transaction_id | |
user_id | user_id | must equal the SDK linkedId, in the same form |
session | session | in Tracio this is the inline verdict token tracio_vt1, valid for 2 minutes |
bonus_campaign_id | bonus_campaign_id | |
transaction_amount | transaction_amount | |
transaction_currency | transaction_currency | |
custom_fields | custom_fields | values are strings |
affiliate_id | affiliate_id | only on account_register and deposit |
| — | request_id | Tracio only: the visit id from the SDK; SEON has no equivalent |
Response:
| SEON | Tracio | Note |
|---|---|---|
state | state | APPROVE / REVIEW / DECLINE |
fraud_score | fraud_score | 0..100 |
applied_rules | applied_rules | id, name, operation, score |
calculation_time | calculation_time | milliseconds |
id | id | our own id of the stored transaction |
success / error / data wrapper | — | no wrapper: a 200 body is the result itself; an error is {"error":{…}} with a non-2xx status |
| — | partial, partial_sources, recommendations, evidence, idempotent_replay, affiliate_conversion | Tracio only |
GET or receive it with the
transaction_state_changed webhook.Report the actual outcome of a transaction with transaction labels.