Wallet integration
Your wallet.
Our games.
We never hold a player balance. Every bet and win is a request from us to your existing wallet API, so deposits, withdrawals and the player relationship stay entirely with you. Four operations, one contract, and a reference wallet to test against before you write a line of your own.
Five operations. That is the whole surface.
You configure one base URL and we call five paths beneath it. Paths, field names, auth and error codes are configurable per operator, so a wallet that already speaks its own dialect usually needs no changes at all.
balancea read | The player’s current balance. Safe to retry; changes nothing. |
debita bet | Money leaves the player. Must fail if the balance is insufficient. |
credita win | Money returns to the player. |
refunda round that could not finish | Returns a specific earlier debit, named by referenceTransactionId. |
rollbacka correction | Voids an earlier movement in full, when a round is annulled. |
What a call looks like
A debit, and the only response we need back.
POST {base_url}/wallet/debit
{
"transactionId": "bet_9f21c4",
"playerId": "your_player_42",
"amount": "10.00",
"currency": "GHS",
"roundId": "rnd_7c31ea",
"sessionId": "sess_kgv5ws4d",
"gameId": "0f7c2a1e-5b3d-4c8a-9e11-6d2f8b40c7aa",
"gameName": "Mines"
}200 OK — that is all
{
"balance": "489.50",
"currency": "GHS"
}or, to refuse it
{
"error": { "code": "INSUFFICIENT_FUNDS" }
}The three rules your wallet must honour
Everything else is negotiable. These are not.
Be idempotent on transactionId
If you have seen the id, return the same balance you returned the first time and do not move money again. We retry, and we retry with the same id on purpose.
Be atomic
Apply the movement and record the id in one database transaction. Doing them separately opens a window where a crash loses one or the other.
Answer quickly
We wait a bounded time. Beyond it we treat the call as indeterminate, leave the transaction pending, and retry with the same id.
!The dangerous case is a timeout, not a rejection
A clean rejection is easy — nothing moved. A timeout is ambiguous: your wallet may have applied the debit and failed to answer. That is exactly why we retry with the same id, and exactly why your wallet must recognise it.
A correct handler
Roughly twenty lines. The unique index is what makes it safe under concurrency.
app.post("/wallet/debit", async (req, res) => {
const { transactionId, playerId, amount, currency } = req.body;
const result = await db.transaction(async (tx) => {
// Let the unique index decide. Checking first and inserting later
// races under concurrency, which is the whole problem here.
const existing = await tx.movements.findByTransactionId(transactionId);
if (existing) return { balance: existing.balance_after };
const player = await tx.players.lockById(playerId);
if (lessThan(player.balance, amount)) {
throw new WalletError("INSUFFICIENT_FUNDS", 402);
}
const balance = subtract(player.balance, amount);
await tx.players.setBalance(playerId, balance);
await tx.movements.insert({ transactionId, playerId, amount, balance_after: balance });
return { balance };
});
res.json({ balance: result.balance, currency });
});Error codes we understand
Case, hyphens and spaces are ignored when matching. Anything outside this list is a terminal rejection — the movement fails and we do not retry it.
insufficient_funds→ 402 | The bet is refused and the round never opens. The player sees a balance message. |
player_blocked→ 403 | Self-exclusion, a hold, or any other reason you will not accept play. |
player_not_found→ 403 | Unknown player id. |
currency_mismatch→ 422 | The player does not hold the session currency. |
transaction_not_found→ 404 | On a reversal: the transaction it names means nothing to you. |
already_reversed→ 409 | You have already refunded or rolled it back. Usually it is us retrying. |
invalid_request→ 502 | We sent something you cannot accept, or could not authenticate. |
Test it before you build it
The sandbox ships a reference wallet with play money. It enforces balances and idempotency exactly as production does, and it can be told to time out or fail — so you can prove your retry path works before it matters.
Securing the callback, the signature scheme and the full field reference are in the wallet documentation.