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.
balance
a read
The player’s current balance. Safe to retry; changes nothing.
debit
a bet
Money leaves the player. Must fail if the balance is insufficient.
credit
a win
Money returns to the player.
refund
a round that could not finish
Returns a specific earlier debit, named by referenceTransactionId.
rollback
a 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.