Contract — Request · Response · Rules
This page is the contract for implementing the wallet API correctly: the request/response shapes and the hard rules (atomic · idempotency · retry) that keep the money right
Request envelope
Every endpoint shares the same envelope: player context at the top level + a batch of transactions in txns[] — endpoints differ only in small per-txn extras · example POST {base}/api/bet, a 200 bet:
{
"request_id": "r1",
"player_username": "foobar",
"currency": "THB",
"product_code": "PGSOFT",
"timestamp": 1712760660000,
"txns": [
{ "txn_id": "T-001", "round_id": "R-1", "amount": 200, "game_code": "10300" }
]
}| Field | Type | Required | Description |
|---|---|---|---|
request_id | string | ✓ | Id of this request — echo it back in the response to correlate |
player_username | string | ✓ | The player's username on your system |
currency | string | ✓ | Currency, e.g. THB |
product_code | string | – | Game-provider code as branded by the platform (display code, e.g. PGSOFT; may differ from the upstream vendor's own code) — useful for grouping your reports. Stable unless the operator rebrands the provider |
timestamp | number | ✓ | Send time in unix milliseconds |
txns | txn[] | ✓* | The whole transaction batch — *every endpoint except /api/balance, which has no txns |
Fields inside txns[]
| Field | Type | Required | Description |
|---|---|---|---|
txn_id | string | ✓ | ★ idempotency key — a re-send must never re-apply; a transaction after a rollback reuses the id but must be applied (re-settle after rollback) |
round_id | string | ✓ | The game round this txn belongs to |
amount | number | ✓ | Amount, always positive — direction comes only from the endpoint type or direction |
game_code | string | – | Game code |
is_feature | boolean | – | Free/feature spin (default false) |
ref_txn_id | string | – | Reversals/adjustments (/rollback, /void, /cancel-tip, /refund, /adjust): the txn_id of the original transaction (nullable) |
direction | string | – | Net ops (/void, /adjust, single-state /settle): "DEBIT" or "CREDIT" |
status | string | – | Vendor bet status (on every callback) — /bet "OPEN" normally, "WAITING" for a sport bet before the provider confirms (then re-sent "OPEN" + skip_balance_update) · /settle "SETTLED" · /refund "REFUND" (cancel) or "REJECT" (sport rejected) · /rollback "ROLLBACK" · /void "VOID" · /tip "TIPS" |
transaction_type | string | – | Scope (/settle, /refund, /rollback, /void) — "BY_TRANSACTION" = act on this txn_id · "BY_ROUND" = act on every bet in round_id at once (amount is the round's combined total) |
skip_balance_update | boolean | – | true = do not move the balance on this call (a sport OPEN confirm, or a record-only settle) — reply code:0 + current balance |
is_feature_buy | boolean | – | /bet, /settle: a bought-feature bet (paired with is_feature) |
play_info | string | – | The bet selection (e.g. banker/player) or the game name — on every bet-related callback |
turn_over | number | – | /settle: the bet's valid stake (turnover) — for your turnover/commission reporting |
is_end_round | boolean | – | /settle: true finalizes the round · false = a multi-settle part (more follow) · absent = one-shot settle |
/api/balance carries the top level only (read-only, no txns[])
Special /settle — single-state round
Some games (e.g. single-state slots) finish both the bet and the result in one call — no /bet comes first. The txn in /settle then carries extra fields:
{ "txn_id": "T-001", "round_id": "R-1", "single_state": true, "bet_amount": 200, "payout_amount": 500, "direction": "CREDIT", "amount": 300, "is_feature": false }single_state: true→ applyamount(the net = payout − bet, always positive) in the givendirection, and always checkbalance >= bet_amountfirst (as if the player committed the stake before receiving the payout) — insufficient balance replies10002single_state: false(a normal settle, a/betcame earlier) → creditamount(= payout) directly — nobet_amount/direction
Single-state settle still checks the stake
Even with direction: "CREDIT" (the net movement is a credit) you must still check balance >= bet_amount — the player must be able to afford the round's stake first
Sportsbook — the same bet arrives twice
A sport bet reaches /api/bet twice in the normal flow: once when the ticket is placed (best-system holds it as WAITING → the txn carries status: "WAITING"), and once more when the vendor confirms the ticket (OPEN) — best-system then relays another /api/bet with the same txn_id, carrying status: "OPEN" + skip_balance_update: true. Your txn_id dedupe must absorb the second call: reply code: 0 with the current balance and never debit twice (use status/skip_balance_update to tell them apart, or just dedupe on txn_id)
Two consecutive /api/bet payloads for the same ticket — new request_id/timestamp, same txn_id:
{
"request_id": "r10",
"player_username": "foobar",
"currency": "THB",
"product_code": "SBOBET",
"timestamp": 1712760660000,
"txns": [
{ "txn_id": "S-88001", "round_id": "SB-1", "amount": 100, "game_code": "SBO-FOOTBALL", "status": "WAITING" }
]
}{
"request_id": "r11",
"player_username": "foobar",
"currency": "THB",
"product_code": "SBOBET",
"timestamp": 1712760790000,
"txns": [
{ "txn_id": "S-88001", "round_id": "SB-1", "amount": 100, "game_code": "SBO-FOOTBALL", "status": "OPEN", "skip_balance_update": true }
]
}Debit 100 on the first call · on the second call reply code: 0 + the current balance with no wallet write — exactly the idempotency rule below, no sport-specific handling needed
Response
Every endpoint replies with the same flat JSON shape — no nested envelope · example reply for /api/bet, a 200 bet (previous balance 1000):
{
"code": 0,
"request_id": "r1",
"username": "foobar",
"currency": "THB",
"balance_before": 1000,
"balance_after": 800,
"timestamp": 1712760660100
}| Field | Type | Description |
|---|---|---|
code | number | 0 = success · other values in the table below |
request_id | string | Echoed from the request (correlation) |
username | string | Echo of player_username (defensive) |
currency | string | Echoed from the request |
balance_before | number | Balance before processing the whole txns[] batch |
balance_after | number | Balance after processing the whole batch (not per txn) |
timestamp | number | When you processed it (unix ms) |
/api/balance returns a single balance instead of the before/after pair
Always reply HTTP 200 — even for business errors
Business errors (insufficient balance, duplicate txn, player not found) reply HTTP 200 with the code in code and balance_before == balance_after (no money moved) · best-system retries only on network errors/timeouts — the moment any HTTP response arrives the call is final and is never re-sent; replying 5xx marks the transaction failed for good · the single exception: a failed signature check may reply 401
Error codes
These codes belong to best-system and are stable — they do not change per game or version.
code | Meaning | HTTP |
|---|---|---|
0 | Success | 200 |
10001 | Player not found on your system | 200 |
10002 | Insufficient balance for the debit | 200 |
20001 | Referenced transaction not found (ref_txn_id unknown) | 200 |
20002 | Duplicate transaction (txn_id already processed) | 200 |
30002 | Invalid signature | 200 or 401 |
40003 | Forbidden (api-key / IP) | 200 |
50001 | Internal error on your system | 200 |
Full details: Seamless codes
Hard rules
Atomic batch
- Process the whole
txns[]batch in one DB transaction — if any txn fails (insufficient balance, unknownref_txn_id) → roll back the whole batch; a half-applied batch must never exist - Reply the error with the failing txn's
codeandbalance_before == balance_after balance_before/balance_afterare the whole batch's balances, not per txn
Idempotency per txn_id
txn_id is the transaction's identity — on a txn_id you have already processed successfully, never re-apply, and reply either code: 0 + the current balance (recommended) or code: 20002
Dedupe on txn_id, not request_id — a re-send of the same transaction may arrive with a new request_id, but the txn_id always stays the same · in practice: a unique index on txn_id, with the conflict handled inside the same transaction as the wallet write
A slow reply gets re-sent by the vendor
If you processed successfully but replied after the 10-second timeout, best-system treats the call as failed (returns an error upstream) — the game vendor then retransmits with the same txn_id, which is why idempotency is mandatory
Retry / timeout on best-system's side
- Timeout per call: 10 seconds — a system-wide value (env
SEAMLESS_RELAY_TIMEOUT_MS, ops-tuned), not configurable per agent. The relay runs inside the vendor's synchronous callback window, so the budget is bounded by the vendor's SLA, not a per-agent knob. - Single synchronous attempt — no inline retry. On a network error / timeout best-system returns an error to the upstream vendor immediately; it never queues the transaction to re-send later (no outbox, no asynchronous re-delivery).
- Recovery relies solely on the upstream game vendor retransmitting the transaction — which arrives as a fresh call with the same
txn_id(sotxn_iddedupe is mandatory). - Any HTTP response — even a business error — is final (there is nothing to retry). Keep your endpoint highly available and fast.
Re-settle after rollback
A /rollback reopens the round as pending and the new outcome is sent again with the same txn_id — either a /settle with a corrected result or a /refund cancelling it instead. Never reply 20002 in this case, or the round's outcome is lost for good.
We send no field to help you tell them apart, because the game provider sends none to us. You count them yourself, which you can do because you receive every /rollback we receive.
How
- Keep a rollback counter per bet, starting at 0.
- On
/rollback, increment it. Iftransaction_typeisBY_ROUND, increment it for every bet in thatround_id. - Use
(endpoint, txn_id, current counter)as your idempotency key. - A transaction arriving after a rollback lands on a fresh key and is applied; a genuine retransmit still collides with the existing key and is skipped as before.
Do not use an "accept once more" rule. The same round can be rolled back more than once — real traffic contains bets rolled back twice and settled on the third attempt — and a single-shot allowance drops that last settle, which is the correct one.
Why endpoint belongs in the key. /bet and /settle for the same bet share a txn_id; without the endpoint the settle looks like a duplicate of the bet.
Checklist — common mistakes
- Verify
X-Signaturefrom the raw bytes — not a parsed object stringified back - Reply business errors as HTTP 200 +
code— a 5xx is read as an internal error and is never retried - Dedupe on
txn_idbefore writing money, always amountis always positive — the direction comes from the endpoint type ordirection- Single-state settle: check
balance >= bet_amountbefore applying the net amount balance_before/balance_afterare the whole batch's balances- Reply within 10 seconds — defer heavy work until after the commit