Casino Api Pro

Integrate

Wallet integration

The five calls we make to your wallet, every field on the wire, and the contract it must honour.

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.

iCasing 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. 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

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
{
  "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
{
  "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

transactionId
stringrequired
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
stringrequired
Your own player identifier, exactly as you sent it when the session was created.
amount
decimal stringrequired
Always a string, always positive, always in minor-unit-free decimal form — "10.00", never 10 or 1000.
currency
stringrequired
ISO 4217 code of the session.
roundId
stringoptional
Present on every movement belonging to a round. Absent on balance reads.
gameId
uuidoptional
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
stringoptional
The launch session the movement belongs to.
referenceTransactionId
stringoptional
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
stringoptional
Human-readable name, for your statements and support tooling.
reason
stringoptional
Free text you supplied on the reversal, passed through unchanged.
metadata
objectoptional
Whatever you attached, plus platform_transaction — our internal id for the movement, which is what our support will ask you for.

iTwo 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. 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
{
  "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

balance
decimal stringoptional
The balance after the movement. A number is accepted, but a string avoids float artefacts entirely.
reference
stringoptional
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
booleanoptional
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:

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}" ) )
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
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(`${ts}.${req.rawBody}`)
    .digest("hex");

  // Length-check first: timingSafeEqual throws on a mismatch rather than
  // returning false.
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(sig, "utf8");
  return a.length === b.length && timingSafeEqual(a, b);
}

!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=<hex> with x-capro-timestamp — note the v1= prefix, which wallet signatures do not have. Strip it before comparing. See 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.

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.