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).
- Endpoints
- balance · debit · credit · rollback
- Signature
- HMAC-SHA256, hex
- Clock window
- ±300 s
- Timeout
- 5 s per attempt
Requests
Section titled “Requests”Every call is a POST with a JSON body and three headers:
POST /sportsbook/debit HTTP/1.1Content-Type: application/jsonX-Timestamp: 1760000000X-Signature: 8bc4fc9b84ee8f997c3f3f51c9e3dfc7e848ddff0ad05908327fca9b4ec70a0e
{"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 optional, but if you send it, it must be the request’s. Refuse
with a 4xx status and an error body:
{ "error": { "code": "insufficient_funds", "message": "insufficient funds" } }How we read your answer:
| Your answer | Means | What we do |
|---|---|---|
2xx, valid JSON, no error |
Applied. | Done. |
4xx except 408 and 429 |
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, a 2xx we cannot parse |
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 even if the balance goes negative; 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"}Every settled bet gets exactly one payout: credit, also when it lost. 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 = 8bc4fc9b84ee8f997c3f3f51c9e3dfc7e848ddff0ad05908327fca9b4ec70a0eCheck 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 or amount, 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 | Kept pending and sent again, same txId and body, by a reconciliation job every minute, until you answer 2xx or 4xx |
| 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 can make a balance negative
Section titled “Corrections can make a balance negative”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. Apply the correction debit
anyway and let the balance go negative; recover it under your terms. If you
refuse it with a 4xx, automatic processing stops and the movement goes to
manual review on both sides.
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 onepayout:/cashout:credit (possibly 0), followed by anyresettle:movements. A bet with only its debit is open. - 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>).