Wallet API reference
Player money stays in your wallet. The platform calls your server for every
movement: four POST endpoints under the walletUrl you give us (for example
https://wallet.your-casino.example/sportsbook).
These endpoints handle sportsbook balance queries and bet-related movements.
They do not implement the player’s deposits, withdrawals, PIX, cards or other
payment methods. Those stay in your platform’s cashier and payment providers.
In particular, a settlement credit updates the casino wallet; it is not a
bank payout.
- Endpoints
- balance · debit · credit · rollback
- Signature
- HMAC-SHA256, hex
- Clock window
- ±300 s
- Timeout
- 5 s per attempt
Requests
Section titled “Requests”This fixed example uses the public test secret documentation-only-secret. Its old timestamp is for offline verification only; generate a fresh timestamp for live calls. Download the test vector.
Every call is a POST with a JSON body and three headers:
POST /sportsbook/debit HTTP/1.1Content-Type: application/jsonX-Timestamp: 1760000000X-Signature: 5a409c37866145f0505662fc18747d92fb61c08c9bff78c1f404ad8779b9428d
{"txId":"bet:8c1d3f2a-5b7e-4c69-a0d4-2e9f6b1c7a53","playerId":"player-42","amount":500,"currency":"USD","kind":"bet","betId":"8c1d3f2a-5b7e-4c69-a0d4-2e9f6b1c7a53"}| Field | Type | |
|---|---|---|
txId |
string | Idempotency key, at most 200 characters. See transaction ids. |
playerId |
string | Your player id: the externalPlayerId you sent when creating the session. |
amount |
integer | Minor units (cents). Always zero or positive; the endpoint gives the direction. |
currency |
string | The tenant currency, e.g. USD, BRL. |
kind |
string | What the movement is, see the table below. |
betId |
string | The bet, when the movement belongs to one. |
ref |
string | Context: the debit being rolled back, or the result that caused a payout, e.g. result:ev_8f2c/1x2@1. |
kind |
Endpoint | |
|---|---|---|
balance |
/balance |
Balance query (amount is 0). |
bet |
/debit |
A stake. |
payout |
/credit |
The first settlement of a bet: the winnings, or 0 for a lost bet. |
refund |
/credit |
A void bet: the stake comes back. |
cashout |
/credit |
The player cashed out. |
resettle |
/credit or /debit |
A correction after a market was resettled. |
rollback |
/rollback |
A stake debit that did not become a bet. |
adjustment |
/credit or /debit |
A manual correction by the platform’s staff, audited. |
Transaction ids
Section titled “Transaction ids”txId |
Call |
|---|---|
balance:<uuid> |
/balance (fresh every time) |
bet:<betId> |
/debit, the stake |
rollback:bet:<betId> |
/rollback of that stake (ref = bet:<betId>) |
payout:<betId>:<version> |
/credit, first settlement (kind payout or refund) |
resettle:<betId>:<version>:<n> |
/credit or /debit, the n-th settlement of the bet |
cashout:<betId> |
/credit, cash-out |
Treat the formats as informative: rely on txId being unique per movement,
not on its shape.
Responses
Section titled “Responses”Answer 2xx with the balance after the movement:
{ "txId": "bet:8c1d3f2a-5b7e-4c69-a0d4-2e9f6b1c7a53", "balance": 49500, "currency": "USD" }txId is required for debit, credit and rollback and must exactly match the request.
balance must be an explicit integer (zero is valid; missing or null is not),
and currency must match the request. Balance queries do not need to echo txId.
Refuse with a 4xx status and a JSON error body with a nonempty error.code:
{ "error": { "code": "insufficient_funds", "message": "insufficient funds" } }How we read your answer:
| Your answer | Means | What we do |
|---|---|---|
2xx, matching txId and currency, explicit integer balance, no error |
Applied. | Done. |
4xx except 408 and 429, valid error envelope |
Refused, nothing moved. | A stake: the bet is refused (insufficient_funds tells the player why). Anything else: not retried, flagged for manual review. |
408, 429, 5xx, timeout, connection error, malformed or incomplete success/error body, mismatching transaction/currency |
Unknown: you may or may not have applied it. | Retried or rolled back, see Retries. |
Endpoints
Section titled “Endpoints”POST /balance
Section titled “POST /balance”{"txId":"balance:5f0c7a52-3f6e-4f6e-9a54-0b8f8f6e2a11","playerId":"player-42","amount":0,"currency":"USD","kind":"balance"}Answer {"balance": 50000, "currency": "USD"}. Called when the sportsbook
shows the balance; no money moves. If it fails, the sportsbook shows no
balance until the next successful call.
POST /debit
Section titled “POST /debit”{"txId":"bet:8c1d3f2a-5b7e-4c69-a0d4-2e9f6b1c7a53","playerId":"player-42","amount":500,"currency":"USD","kind":"bet","betId":"8c1d3f2a-5b7e-4c69-a0d4-2e9f6b1c7a53"}kind: "bet"is a stake. If the balance is short, refuse with codeinsufficient_funds(402is conventional; any 4xx except 408/429 works).- A stake debit is sent once and never retried. If its outcome is unknown, we roll it back and refuse the bet, so a bet is never accepted on money you did not confirm.
kind: "resettle"or"adjustment"is a correction the player owes. Apply it only when the full amount is available; otherwise refuse withinsufficient_fundsfor operator review. See corrections.
POST /credit
Section titled “POST /credit”{"txId":"payout:8c1d3f2a-5b7e-4c69-a0d4-2e9f6b1c7a53:1","playerId":"player-42","amount":1050,"currency":"USD","kind":"payout","betId":"8c1d3f2a-5b7e-4c69-a0d4-2e9f6b1c7a53","ref":"result:ev_8f2c/1x2@1"}Credits are owed: we retry them until you confirm (see below). Accept
amount: 0.
Zero-amount credits close lost bets
Section titled “Zero-amount credits close lost bets”{"txId":"payout:d4e8a6b2-1c3f-4e5a-9b7d-6f0a2c4e8b19:1","playerId":"player-42","amount":0,"currency":"USD","kind":"payout","betId":"d4e8a6b2-1c3f-4e5a-9b7d-6f0a2c4e8b19","ref":"result:ev_8f2c/1x2@1"}A bet settled normally receives an initial payout: credit, also when it lost. Full cash-out is closed by its cash-out credit instead. Use it
to close the bet in your books (many regulators want an explicit result per
wager). Answer 2xx with the unchanged balance.
POST /rollback
Section titled “POST /rollback”{"txId":"rollback:bet:f27b9c1e-6d4a-4b8f-83e2-5a1c9d7e0b46","playerId":"player-42","amount":200,"currency":"USD","kind":"rollback","betId":"f27b9c1e-6d4a-4b8f-83e2-5a1c9d7e0b46","ref":"bet:f27b9c1e-6d4a-4b8f-83e2-5a1c9d7e0b46"}Reverses the stake debit whose txId is ref, because the bet did not go
through (your answer to the debit was lost, or the bet failed after it).
- If you applied that debit and have not reversed it: give the money back and mark the debit reversed.
- If you already reversed it: do nothing, answer
2xx. - If you never saw that debit (it timed out before reaching you, or is
still on its way): answer
2xxwith the current balance, and remember theref. If the debit arrives later, refuse it with a4xx(rolled_back). Otherwise the player would pay for a bet that does not exist.
amount is the original stake; refund what you actually debited.
Signature
Section titled “Signature”X-Signature = hex( HMAC-SHA256( walletSecret, X-Timestamp + "." + rawBody ) )rawBodyis the request body exactly as received, bytes for bytes. Verify before parsing; never re-serialise the JSON (key order and spacing would change).X-Timestampis the Unix time in seconds when the attempt was sent. Refuse requests more than 300 seconds away from your clock (replay protection), and keep your servers on NTP.- Compare in constant time. The hex is lowercase; accept either case.
- A retry carries a fresh timestamp and signature over the same body.
Worked example
Section titled “Worked example”walletSecret = dev-wallet-secretX-Timestamp = 1760000000rawBody = {"txId":"bet:8c1d3f2a-5b7e-4c69-a0d4-2e9f6b1c7a53","playerId":"player-42","amount":500,"currency":"USD","kind":"bet","betId":"8c1d3f2a-5b7e-4c69-a0d4-2e9f6b1c7a53"}signed text = 1760000000.{"txId":"bet:8c1d3f2a-5b7e-4c69-a0d4-2e9f6b1c7a53","playerId":"player-42","amount":500,"currency":"USD","kind":"bet","betId":"8c1d3f2a-5b7e-4c69-a0d4-2e9f6b1c7a53"}X-Signature = 5a409c37866145f0505662fc18747d92fb61c08c9bff78c1f404ad8779b9428dCheck it from a shell:
printf '%s' '1760000000.{"txId":"bet:8c1d3f2a-5b7e-4c69-a0d4-2e9f6b1c7a53","playerId":"player-42","amount":500,"currency":"USD","kind":"bet","betId":"8c1d3f2a-5b7e-4c69-a0d4-2e9f6b1c7a53"}' \ | openssl dgst -sha256 -hmac dev-wallet-secretVerification code
Section titled “Verification code”Each sample is tested against requests signed by the platform’s own wallet client code: valid requests pass; a changed body and a 301-second-old timestamp fail.
import { createHmac, timingSafeEqual } from 'node:crypto';
const MAX_SKEW_SECONDS = 300;
/** * @param {string} secret your wallet secret * @param {string} timestamp the X-Timestamp header (unix seconds) * @param {string} signature the X-Signature header (hex) * @param {Buffer|string} rawBody the request body exactly as received * @returns {null | 'bad_timestamp' | 'bad_signature'} */export function verifySignature(secret, timestamp, signature, rawBody, nowSeconds = Math.floor(Date.now() / 1000)) { if (!/^\d+$/.test(timestamp ?? '') || Math.abs(nowSeconds - Number(timestamp)) > MAX_SKEW_SECONDS) return 'bad_timestamp'; const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest(); const given = Buffer.from(signature ?? '', 'hex'); return given.length === expected.length && timingSafeEqual(given, expected) ? null : 'bad_signature';}const MAX_SKEW_SECONDS = 300;
/** * Returns null when valid, else 'bad_timestamp' or 'bad_signature'. * $rawBody is file_get_contents('php://input'), not re-encoded JSON. */function verify_signature(string $secret, string $timestamp, string $signature, string $rawBody, ?int $now = null): ?string{ $now = $now ?? time(); if (!ctype_digit($timestamp) || abs($now - (int) $timestamp) > MAX_SKEW_SECONDS) { return 'bad_timestamp'; } $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); return hash_equals($expected, strtolower($signature)) ? null : 'bad_signature';}
// In a handler:// $error = verify_signature($secret, $_SERVER['HTTP_X_TIMESTAMP'] ?? '', $_SERVER['HTTP_X_SIGNATURE'] ?? '', file_get_contents('php://input'));import hashlibimport hmacimport time
MAX_SKEW_SECONDS = 300
def verify_signature(secret: str, timestamp: str, signature: str, raw_body: bytes, now: int | None = None) -> str | None: """Returns None when valid, else "bad_timestamp" or "bad_signature".
raw_body is the request body exactly as received (bytes), not re-serialised JSON. """ now = int(time.time()) if now is None else now if not timestamp.isdigit() or abs(now - int(timestamp)) > MAX_SKEW_SECONDS: return "bad_timestamp" expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest() return None if hmac.compare_digest(expected.encode(), signature.lower().encode()) else "bad_signature"static final long MAX_SKEW_SECONDS = 300;
/** Returns null when valid, else "bad_timestamp" or "bad_signature". rawBody: the body bytes as received. */static String verifySignature(String secret, String timestamp, String signature, byte[] rawBody, long nowSeconds) throws Exception { if (timestamp == null || !timestamp.matches("\\d{1,18}") || Math.abs(nowSeconds - Long.parseLong(timestamp)) > MAX_SKEW_SECONDS) { return "bad_timestamp"; } Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8)); byte[] expected = mac.doFinal(rawBody); byte[] given; try { given = java.util.HexFormat.of().parseHex(signature == null ? "" : signature); } catch (IllegalArgumentException e) { return "bad_signature"; } return MessageDigest.isEqual(expected, given) ? null : "bad_signature";}const maxSkew = 300 * time.Second
// VerifySignature checks X-Timestamp and X-Signature against the raw body.// It returns "" when valid, else "bad_timestamp" or "bad_signature".func VerifySignature(secret, timestamp, signature string, rawBody []byte, now time.Time) string { ts, err := strconv.ParseInt(timestamp, 10, 64) if err != nil { return "bad_timestamp" } if d := now.Sub(time.Unix(ts, 0)); d > maxSkew || d < -maxSkew { return "bad_timestamp" } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(timestamp + ".")) mac.Write(rawBody) given, err := hex.DecodeString(signature) if err != nil || !hmac.Equal(given, mac.Sum(nil)) { return "bad_signature" } return ""}On failure answer 401 with {"error": {"code": "invalid_signature"}}.
Idempotency
Section titled “Idempotency”txId is the idempotency key of every money call.
- Store each
txIdyou process with its outcome (status and body). - When a
txIdarrives again, answer the stored outcome and move no money. This is how retries become safe. - If a known
txIdarrives with a different player, endpoint, amount, currency, kind, bet or reference, refuse it (409,duplicate_tx). - Apply the movement and store the
txIdin one database transaction.
Retries and timeouts
Section titled “Retries and timeouts”| Timeout per attempt | 5 seconds |
Stake debits (kind: bet) |
One attempt. Unknown outcome: rolled back, bet refused. |
| Credits, rollbacks, correction debits | Up to 5 attempts, waiting 200 ms, 400 ms, 800 ms and 1.6 s in between (doubling, at most 2 s) |
| Still unknown after that | Eligible calls remain pending and retry with the same txId and body. Refusals, corrections and predecessor obligations can stop retries; see operating limits below. |
| Settlements | Also queued: a settlement that could not be paid is retried every 30 seconds |
So a credit can reach you minutes or hours after the bet was settled (after an outage on your side), and more than once. Idempotency makes that harmless.
Corrections with insufficient funds
Section titled “Corrections with insufficient funds”A market can be resettled (a result corrected after the first
settlement). The platform then computes the difference for each affected bet
and sends a resettle movement with txId resettle:<betId>:<version>:<n>:
a credit if the player is owed more, a debit if they were overpaid.
The player may already have spent the winnings. Check the balance and apply the
correction atomically. If the full amount is not available, return 402 with
error.code: "insufficient_funds" and move no money. Do not collect a partial
amount, create a negative balance, or capture future deposits. Persist the refusal
under the original txId; duplicates return that same refusal even after a deposit.
OrbiSportbook retains the obligation as failed, stops automatic delivery for that
bet and shows it in Operator workspace → Integration → Financial delivery and
exceptions. The operator investigates with the original transaction and bet IDs.
Do not invent a replacement transaction ID or mark the obligation paid without
evidence from both ledgers.
Error codes
Section titled “Error codes”| Situation | Status | error.code |
Player sees |
|---|---|---|---|
| Stake larger than the balance | 402 |
insufficient_funds |
“Insufficient funds” |
| Unknown player | 404 |
player_not_found |
The bet is refused (payment_rejected) |
| Player blocked on your side | 403 |
player_blocked |
The bet is refused (payment_rejected) |
| Bad signature or stale timestamp | 401 |
invalid_signature |
The bet is refused |
txId reused with other parameters |
409 |
duplicate_tx |
The bet is refused |
| Debit already rolled back | 409 |
rolled_back |
The bet is refused |
| Temporary failure | 5xx or timeout |
any | A stake: refused as temporarily unavailable. Others: retried |
Only insufficient_funds has a special meaning. Any other code is recorded in
the platform’s wallet call log and returned in the error details as
operatorCode.
Reconciliation
Section titled “Reconciliation”- Keep a ledger row per
txId: endpoint, kind, amount, bet, status, time. - For every bet you will see one
bet:debit and then either one rollback (the bet was refused) or an initialpayout:/fullcashout:credit (possibly 0), followed by anyresettle:movements. Partial cash-outs may produce severalcashout:<betId>:p<n>credits before the remaining stake settles. A debit alone does not prove an open bet: acceptance may have failed with a rollback still pending. - The platform logs every money call it makes to you (status, attempts, your answer) and keeps unresolved ones pending until they are resolved.
- Daily, compare your totals per UTC day with the back office Reports
(
GET /api/v1/admin/reports/ggr?from=YYYY-MM-DD&to=YYYY-MM-DD: stakes, payouts, refunds, cash-outs and GGR), and look up single bets under Bets (GET /api/v1/admin/bets?player=<player id>).
Delivery order and operating limits
Section titled “Delivery order and operating limits”Postings for the same bet are delivered in durable creation order. An unknown or refused predecessor blocks later postings for that bet; unrelated bets continue. Each retry preserves the original financial identity. A crashed worker may send the same transaction again after its lease expires: your wallet must still enforce atomic idempotency. There is no exactly-once network delivery guarantee.
The bet result may be visible before wallet delivery finishes. Never infer that a
player was paid from won, cashed_out or an HTTP 200 on a bet operation alone.
Use the financial delivery queue and your transaction ledger.
The current wallet transport has four endpoints. A transaction-status lookup is not implemented in this revision. Unknown outcomes are resolved through idempotent replay or stake rollback; keep the transaction records needed for manual investigation. Do not discard idempotency keys after a short retry window.
Exception: a corrected, unconfirmed credit is held for review. If a later
resettlement supersedes a positive credit whose delivery is still unresolved,
OrbiSportbook stops automatic sends and records obsolete_credit_requires_review.
Neither the original credit nor the following correction may be assumed applied.
Compare both ledgers using the original IDs before resolving the incident.
Already confirmed cash-outs remain guaranteed. The wallet transport’s general
reconciliation worker cannot bypass the settlement queue, its ordering or holds.
Download the OpenAPI contract and follow the certification scenarios.
Changing the wallet destination
Section titled “Changing the wallet destination”The wallet URL, wallet mode and currency become fixed after the first financial use. Changing them requires a controlled migration with the integration team, covering balances, idempotency history, open bets and later corrections; ordinary settings return 409 with reason wallet_identity_locked. Coordinated signing-secret rotation remains available.
An unresolved transaction without proof of its original destination is held for operator review. Never send it to a replacement URL merely because that URL is now configured.