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:

json
{
  "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" }
  ]
}
FieldTypeRequiredDescription
request_idstring✓Id of this request — echo it back in the response to correlate
player_usernamestring✓The player's username on your system
currencystring✓Currency, e.g. THB
product_codestring–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
timestampnumber✓Send time in unix milliseconds
txnstxn[]✓*The whole transaction batch — *every endpoint except /api/balance, which has no txns

Fields inside txns[]

FieldTypeRequiredDescription
txn_idstring✓★ 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_idstring✓The game round this txn belongs to
amountnumber✓Amount, always positive — direction comes only from the endpoint type or direction
game_codestring–Game code
is_featureboolean–Free/feature spin (default false)
ref_txn_idstring–Reversals/adjustments (/rollback, /void, /cancel-tip, /refund, /adjust): the txn_id of the original transaction (nullable)
directionstring–Net ops (/void, /adjust, single-state /settle): "DEBIT" or "CREDIT"
statusstring–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_typestring–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_updateboolean–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_buyboolean–/bet, /settle: a bought-feature bet (paired with is_feature)
play_infostring–The bet selection (e.g. banker/player) or the game name — on every bet-related callback
turn_overnumber–/settle: the bet's valid stake (turnover) — for your turnover/commission reporting
is_end_roundboolean–/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:

json
{ "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 → apply amount (the net = payout − bet, always positive) in the given direction, and always check balance >= bet_amount first (as if the player committed the stake before receiving the payout) — insufficient balance replies 10002
  • single_state: false (a normal settle, a /bet came earlier) → credit amount (= payout) directly — no bet_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:

json
{
  "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" }
  ]
}
json
{
  "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):

json
{
  "code": 0,
  "request_id": "r1",
  "username": "foobar",
  "currency": "THB",
  "balance_before": 1000,
  "balance_after": 800,
  "timestamp": 1712760660100
}
FieldTypeDescription
codenumber0 = success · other values in the table below
request_idstringEchoed from the request (correlation)
usernamestringEcho of player_username (defensive)
currencystringEchoed from the request
balance_beforenumberBalance before processing the whole txns[] batch
balance_afternumberBalance after processing the whole batch (not per txn)
timestampnumberWhen 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.

codeMeaningHTTP
0Success200
10001Player not found on your system200
10002Insufficient balance for the debit200
20001Referenced transaction not found (ref_txn_id unknown)200
20002Duplicate transaction (txn_id already processed)200
30002Invalid signature200 or 401
40003Forbidden (api-key / IP)200
50001Internal error on your system200

Full details: Seamless codes

Hard rules

Atomic batch

  • Process the whole txns[] batch in one DB transaction — if any txn fails (insufficient balance, unknown ref_txn_id) → roll back the whole batch; a half-applied batch must never exist
  • Reply the error with the failing txn's code and balance_before == balance_after
  • balance_before/balance_after are 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 (so txn_id dedupe 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

  1. Keep a rollback counter per bet, starting at 0.
  2. On /rollback, increment it. If transaction_type is BY_ROUND, increment it for every bet in that round_id.
  3. Use (endpoint, txn_id, current counter) as your idempotency key.
  4. 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-Signature from 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_id before writing money, always
  • amount is always positive — the direction comes from the endpoint type or direction
  • Single-state settle: check balance >= bet_amount before applying the net amount
  • balance_before/balance_after are the whole batch's balances
  • Reply within 10 seconds — defer heavy work until after the commit