# Wallet integration > The five calls we make to your wallet, every field on the wire, and the contract it must honour. Source: https://casinoapipro.com/docs/wallet We never hold a player balance. Every movement is a request from us to your wallet API, and your wallet is the authority on whether it happened. Deposits, withdrawals, KYC and the player relationship stay entirely with you. ## Which way the calls go ```http Player → Your platform accounts · KYC · wallet ↕ wallet API (we call you) Casino Api Pro sessions · ledger · webhooks ↕ outcome Game engine provably fair results ``` You configure one base URL and we call five paths beneath it. Paths, field names, auth and error codes are all configurable per operator, so a wallet that already speaks its own dialect usually needs no changes at all. Everything on this page is the default — what we send if you configure nothing but the base URL. > **Casing differs by direction, and it is deliberate** > > Requests _you_ send _us_ are snake_case (`reference_transaction_id`). Requests _we_ send _your wallet_ are camelCase (`referenceTransactionId`) by default, because that is what most wallets we integrate already use. If you would rather receive snake_case, set a field map — see [renaming fields](/docs/wallet#fieldmap). Nothing about this is guessable, so it is spelled out below field by field. ## Configuring it Everything on this page is set in your dashboard under Developer → Wallet: the base URL, the five paths, the authentication method and its credential, the field names, timeouts and retries. Sandbox and live are separate records — configuring one does not configure the other, and neither falls back to the other. The page shows the five URLs your settings actually resolve to, and a Test connection button that performs a real balance read against your wallet and reports what came back. A balance read moves nothing, so it is safe against a live wallet, and it exercises the whole chain — URL resolution, transport, authentication, signature and response shape. Use it before your first bet rather than after. ## The five operations | Name | Type | Meaning | | --- | --- | --- | | `balance` | a read | Current balance for a player. Carries only `playerId` and `currency`. Safe to retry. | | `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, named by `referenceTransactionId`. Always the full value — there is no partial rollback. | ## The default paths ```http POST {base_url}/wallet/balance POST {base_url}/wallet/debit POST {base_url}/wallet/credit POST {base_url}/wallet/refund POST {base_url}/wallet/rollback ``` All five are POST, including `balance`. Override any of them individually in your wallet integration settings; the ones you do not override keep the defaults above. > **A path prefix on your base URL is dropped unless you ask for it** > > Paths are resolved against your base URL as URLs, not concatenated as strings. A base URL of `https://wallet.example.com/api/v2` with the default absolute paths resolves to `https://wallet.example.com/wallet/debit` — the `/api/v2` is gone. To keep a prefix, end the base URL with a slash (`https://wallet.example.com/api/v2/`) and configure _relative_ paths (`debit`, not `/debit`). That resolves to `https://wallet.example.com/api/v2/debit`. ## What we send A bet. Only the four required fields appear here because this movement has nothing else to carry: _POST {base_url}/wallet/debit_ ```json { "transactionId": "bet_9f21c4", "playerId": "your_player_42", "amount": "10.00", "currency": "GHS" } ``` A refund, with everything populated. `referenceTransactionId` is the id of the original debit; `transactionId` is the id of this reversal: _POST {base_url}/wallet/refund_ ```json { "transactionId": "rfd_4a90b1", "playerId": "your_player_42", "amount": "10.00", "currency": "GHS", "roundId": "rnd_7c31ea", "gameId": "0f7c2a1e-5b3d-4c8a-9e11-6d2f8b40c7aa", "sessionId": "sess_kgv5ws4d", "referenceTransactionId": "bet_9f21c4", "gameName": "Mines", "reason": "round could not be settled", "metadata": { "platform_transaction": "0b41…" } } ``` **Every field we can send** | Name | Type | Required | Description | | --- | --- | --- | --- | | `transactionId` | string | yes | The id of _this_ movement, and the key your idempotency must be built on. It is the `transaction_id` you gave us when you called our API. | | `playerId` | string | yes | Your own player identifier, exactly as you sent it when the session was created. | | `amount` | decimal string | yes | Always a string, always positive, always in minor-unit-free decimal form — `"10.00"`, never `10` or `1000`. | | `currency` | string | yes | ISO 4217 code of the session. | | `roundId` | string | no | Present on every movement belonging to a round. Absent on balance reads. | | `gameId` | uuid | no | Our uuid for the game — not the slug. The slug is in `gameName`’s sibling catalogue; if you key on the slug, map it once at integration time. | | `sessionId` | string | no | The launch session the movement belongs to. | | `referenceTransactionId` | string | no | The original transaction on refund and rollback. Present only on those two operations, and only when the reversal names a target — which, through our API, it always does. | | `gameName` | string | no | Human-readable name, for your statements and support tooling. | | `reason` | string | no | Free text you supplied on the reversal, passed through unchanged. | | `metadata` | object | no | Whatever you attached, plus `platform_transaction` — our internal id for the movement, which is what our support will ask you for. | > **Two things that are not in the body** > > There is no `timestamp` field and no nested `game` object. The time of the request is a header, and only when HMAC signing is on — see [securing the callback](/docs/wallet#security). Game identity is the two flat fields `gameId` and `gameName`. ### Absent, not null Optional fields we do not have are omitted from the JSON entirely — we never send `null` for them. A schema that requires `roundId` to be present will reject our balance reads. ### Renaming fields Seven keys can be renamed: `transactionId`, `playerId`, `amount`, `currency`, `roundId`, `gameId`, `sessionId` and `referenceTransactionId`. Set a field map on your wallet integration and we send your names instead: _wallet integration → field map_ ```json { "transactionId": "transaction_id", "playerId": "player_id", "roundId": "round_id", "gameId": "game_id", "sessionId": "session_id", "referenceTransactionId": "reference_transaction_id" } ``` `gameName`, `reason` and `metadata` are not remappable and always arrive under those exact names. ## What we expect back A `200` with the resulting balance. That is the whole success contract. ```json { "balance": "489.50", "currency": "GHS" } ``` **Fields we read from your response** | Name | Type | Required | Description | | --- | --- | --- | --- | | `balance` | decimal string | no | The balance after the movement. A number is accepted, but a string avoids float artefacts entirely. | | `reference` | string | no | Your own id for the movement. Stored and shown in our transaction records, which makes reconciliation with your ledger a lookup rather than an investigation. | | `duplicate` | boolean | no | Set it `true` when you recognised the `transactionId` and did not move money again. Optional, and useful: it lets us distinguish a replay from a fresh movement in reporting. | All three are remappable through `responseMap` if your wallet nests them elsewhere — dotted paths like `data.balance` work. ## Refusing a movement Answer with a non-2xx and a code we can map. Any of these shapes is read correctly: ```json { "error_code": "INSUFFICIENT_FUNDS" } { "code": "INSUFFICIENT_FUNDS" } { "error": "INSUFFICIENT_FUNDS" } { "error": { "code": "INSUFFICIENT_FUNDS", "message": "balance 4.20" } } ``` Matching ignores case, hyphens and spaces, so `insufficient-funds` and `INSUFFICIENT_FUNDS` are the same string to us. These are the codes we recognise without being told, and what your player and your API call see as a result: | Name | Type | Meaning | | --- | --- | --- | | `insufficient_funds` | → 402 | Aliases: `insufficient_balance`, `not_enough_funds`, `low_balance`. The bet is refused and the round never opens. | | `player_blocked` | → 403 | Alias: `player_disabled`. Self-exclusion, KYC hold, or any other reason you will not accept play. | | `player_not_found` | → 403 | Alias: `unknown_player`. Unknown player id — usually a session opened for someone since deleted. | | `currency_mismatch` | → 422 | Alias: `invalid_currency`. The player does not hold the session currency. | | `transaction_not_found` | → 404 | Alias: `reference_not_found`. On a refund or rollback: the `referenceTransactionId` means nothing to you. | | `already_reversed` | → 409 | Aliases: `already_refunded`, `already_rolled_back`. You have already reversed it. Usually it is us retrying, and this is the right answer. | | `invalid_request` | → 502 | Alias: `unauthorized`. We sent something you cannot accept, or could not authenticate. This is a bug on one side and is escalated. | Anything else is a terminal rejection — the movement fails, your API call gets a `502` with `WALLET_REJECTED`, and we do not retry. Your string is preserved verbatim in the transaction record either way, so an unmapped code is diagnosable rather than lost. If you have codes of your own, map them to ours in your wallet integration instead of leaving them to fall through. ## The three rules your wallet must honour - 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 transaction. Doing them separately opens a window where a crash loses one or the other. - Answer quickly. The default timeout is six seconds, with two retries and exponential backoff. Beyond it we treat the call as indeterminate, leave the transaction pending and retry. > **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 ```js app.post("/wallet/debit", async (req, res) => { const { transactionId, playerId, amount, currency } = req.body; const result = await db.transaction(async (tx) => { // The unique index on transaction_id is what makes this safe. Checking // first and inserting later races under concurrency; let the DB decide. const existing = await tx.movements.findByTransactionId(transactionId); if (existing) return { balance: existing.balance_after, replayed: true }; 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, duplicate: result.replayed === true }); }); ``` Refund and rollback differ only in what they look up first: ```js app.post("/wallet/refund", async (req, res) => { const { transactionId, referenceTransactionId, playerId, amount, currency } = req.body; const result = await db.transaction(async (tx) => { // Idempotency is still on OUR id for this reversal… const existing = await tx.movements.findByTransactionId(transactionId); if (existing) return { balance: existing.balance_after, replayed: true }; // …and referenceTransactionId is what it reverses. Unknown means say so: // answering 200 here credits a player against a bet that never took money. const original = await tx.movements.findByTransactionId(referenceTransactionId); if (!original) throw new WalletError("transaction_not_found", 404); if (original.reversed_at) throw new WalletError("already_reversed", 409); const balance = add((await tx.players.lockById(playerId)).balance, amount); await tx.players.setBalance(playerId, balance); await tx.movements.markReversed(original.transactionId); await tx.movements.insert({ transactionId, playerId, amount, balance_after: balance }); return { balance }; }); res.json({ balance: result.balance, currency, duplicate: result.replayed === true }); }); ``` ## Securing the callback Five authentication modes are available on a wallet integration: `NONE`, `BEARER`, `BASIC`, `API_KEY_HEADER` and `HMAC_SIGNATURE`. Signing is not on unless you turn it on, and for a live wallet you should. Under `HMAC_SIGNATURE` we add two headers. The timestamp is Unix seconds — not milliseconds, not ISO 8601 — and it is signed alongside the body so a captured request cannot be replayed under a fresh one: ```http x-timestamp: 1788321861 x-signature: 4d1b8f0a5c2e9a7b3f6d8c1e0a4b7d2f9c3e6a1b8d5f0c7e2a9b4d6f1c8e3a0b signature = hex( HMAC_SHA256( secret, "{x-timestamp}" + "." + "{raw request body}" ) ) ``` | Name | Type | Meaning | | --- | --- | --- | | `Encoding` | lowercase hex | Not base64, and no scheme prefix — the header value is the 64 hex characters and nothing else. | | `Signed string` | {timestamp}.{body} | A literal dot between them. The body is the exact bytes we sent — verify before parsing, because re-serialising changes them. | | `Header names` | x-signature / x-timestamp | Both configurable per integration if your wallet expects different ones. | | `Freshness` | your call | We do not require a window. Rejecting timestamps older than five minutes is the usual choice, and is yours to make. | _verifying, in Node_ ```js import { createHmac, timingSafeEqual } from "node:crypto"; // rawBody must be the unparsed bytes. express.json() discards them unless you // ask: express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }) function verify(secret, req) { const ts = req.get("x-timestamp"); const sig = req.get("x-signature"); if (!ts || !sig) return false; if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; const expected = createHmac("sha256", secret) .update(\ ``` > **Wallet signatures and webhook signatures are not interchangeable** > > The algorithm and the signed string are identical, but the headers are not. Webhooks arrive as `x-capro-signature: v1=` with `x-capro-timestamp` — note the `v1=` prefix, which wallet signatures do not have. Strip it before comparing. See [webhooks](/docs/webhooks). ## Headers we always send ```http content-type: application/json accept: application/json user-agent: CasinoApiPro/1.0 (+https://casinoapipro.com) x-request-id: 01J8Z4Q7F2K9M3N5P7R9T1V3W5 ``` `x-request-id` is our id for the request that caused the movement. When you raised it yourself through `/v1/transactions/*` it is the same id we returned to you. When it came from a player acting inside the launcher — which is most rounds — it is our id for that play, which you never saw: you did not make a call. > **It was missing on launcher rounds until 13 September 2026** > > The header was sent on movements an operator raised directly and not on movements the launcher raised, which is almost all of them — so an operator who followed this page and stored the value ended up with a column that was always null. One of them told us. It is sent on every call now. Log it. It is the one value that appears on both sides of the conversation: `GET /v1/rounds/{roundId}/wallet-calls` returns it as `request_id` against each movement, so a value in your log can be quoted straight back at ours. Add static headers of your own on the integration if your gateway needs them. ## Testing without writing one There is no separate sandbox hostname. Every environment is `https://api.casinoapipro.com`, and your API key decides which one you are in — `ck_test_` keys are sandbox, `ck_live_` keys are live. See [test and live](/docs/test-and-live). In sandbox you can use the built-in reference wallet with play money instead of building anything. It enforces balances and idempotency exactly as production does, and it can be told to add latency or fail a proportion of calls, so you can prove your retry path works before you build the real thing. > **Sandbox and live wallets are configured separately** > > A wallet integration belongs to one environment. Configuring the sandbox does not configure live — until a live integration exists, live money calls fail with `WALLET_NOT_CONFIGURED` rather than falling back to anything. That is deliberate: a sandbox wallet settling real bets is the worst failure this platform could have, and both the code and a database constraint refuse it.