Wallet hooks

The four money commands and the balance read you implement, and what your status code means to us.

Self-hosted integrators expose three endpoints under a base URL registered in the operator console. The operator calls them to move money — a stake is a debit, any return (void, cash-out, settlement payout) is a credit. There is no freeze primitive. Paths below are relative to your wallet_base_url.

Two things you must get right

  • Idempotency. Every call carries an idem_key (and an Idempotency-Key header). Applying the same key twice must not move money twice — return the original result. The same value also arrives as an Idempotency-Key header, so you may dedupe on the header instead of parsing the body if that suits your stack; the two always carry the same value.
  • Signature. Verify the signature header against the hook canonical below before acting, and reject a stale X-Wallet-Ts. The signature header name is part of your hook configuration rather than a fixed constant — X-Wallet-Token is the usual choice and is used throughout this page, but read the name you configured rather than hard-coding ours.

Your base URL must be publicly reachable

Before any money command is sent, the operator resolves your wallet_base_url and refuses to open the connection if the host resolves to a loopback, private, link-local or unspecified address, or to an IPv6 unique-local one in fc00::/7. Cloud metadata endpoints such as 169.254.169.254 fall under link-local and are refused with everything else. Only the address that passed the check is dialled, so a name that answers publicly once and privately the next time does not get through either. Redirects are never followed — the URL you register is the URL that is called.

It is worth knowing exactly where that refusal lands, because the two sides of it look nothing alike.

  1. Operator prepares the money command
  2. Resolves wallet_base_url
  3. Checks every resolved address
  4. Opens the connection never happens
  5. Your server receives the request never happens
  6. Your server verifies the signature never happens
On your side

Nothing. No request, no signature error, no 4xx — the hook reads as though it was simply never called, which is easy to file as somebody else's problem.

On the player's side

Every bet, payout and cash-out fails with upstream_error (502), once the retry sequence has run.

A hook pointed at a VPC-internal name, a private address, or a host reachable from your network but not from the internet produces exactly this, and it looks nothing like a configuration mistake until you know to check.

Co-located deployments where the operator and the wallet genuinely share a private network are handled by an operator-side allowlist, not by anything you can set. If that is your topology, ask the operator to allow your host rather than trying to make the URL public.

The four money hooks

They share one request shape, which makes them look interchangeable. One takes money and three give it back, and which one gives it back is the only record of why — that is what your reconciliation reads later. Implementing all four as a single balance adjustment loses the reason and cannot be recovered afterwards.

  • /wallet/bet↓ debit

    A market order fills, or a limit BUY is placed.

    The only hook that takes money. A limit buy debits at placement, not at fill — resting an order costs the stake up front.

  • /wallet/payout↑ credit

    A market settles in the player's favour, or a limit SELL fills.

    The winning outcome. Not the same as getting a stake back — this is proceeds.

  • /wallet/cashout↑ credit

    The player closes a position early, in full or in part.

    A voluntary exit at the current price. Distinct from a payout because the market has not resolved.

  • /wallet/refund↑ credit

    An order is voided, or a resting limit order is cancelled or expires.

    Money returned because the bet never stood. Route it here even if your ledger entry is identical to a payout — the distinction is what tells you later whether a player won or simply got their stake back.

Verifying the signature

The hook canonical hashes the body — this differs from the B2B canonical, which signs the raw body. Do not share a verifier between them.

Five fields, not four. Write commands carry no query string, but the empty RAWQUERY line is still there — drop it and every signature fails, writes included. It exists because GET /wallet/balance passes player_id in the query: signing it is what stops a tampered request from reading another player's balance.

Hook canonical + verify — Node.jsjavascript
import crypto from "node:crypto";

// canonical = ts + "\n" + METHOD + "\n" + path + "\n" + RAWQUERY + "\n" + sha256hex(body)
function verifyHook(req, rawBody, secret) {
  const ts = req.header("X-Wallet-Ts");
  const got = req.header("X-Wallet-Token");
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // stale
  // GET /wallet/balance has no body: hash the empty string, not undefined.
  const bodyHash = crypto.createHash("sha256").update(rawBody ?? "").digest("hex");
  // RAWQUERY is the raw query string with no leading "?" — "" on writes, which still
  // contributes an empty line. Keys arrive sorted (url.Values.Encode), so compare bytes as received.
  const rawQuery = req.url.includes("?") ? req.url.slice(req.url.indexOf("?") + 1) : "";
  const canonical = [ts, req.method.toUpperCase(), req.path, rawQuery, bodyHash].join("\n");
  const want = crypto.createHmac("sha256", secret).update(canonical).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want));
}

Common request headers

FieldTypeDescription
X-Wallet-TokenstringrequiredHex HMAC-SHA256 over the hook canonical.
X-Wallet-TsstringrequiredUnix seconds; reject if stale (±300s).
Idempotency-KeystringrequiredMirrors body idem_key; dedupe on it.

How long you have to answer

Each hook call carries a request timeout configured per merchant, defaulting to 3 seconds. Exceed it and the operator treats the call as a network failure and retries it — so a slow hook does not fail, it duplicates. That is survivable only because you dedupe on idem_key; if you do not, a hook that occasionally takes four seconds will double-charge players.

Retries apply to network errors and 5xx only, with exponential backoff, up to the retry count on your hook configuration — 3 by default. Backoff starts at 100 ms and doubles (100, 200, 400 ms…). Total attempts per command are hard-capped at 8 regardless of how the hook is configured, so one money command reaches you at most eight times within the synchronous window.

Do your slow work — ledger writes, risk checks, third-party calls — before you answer, not after, and answer the same way every time for a given idem_key.

“Not retried” and “finished” are three different questions

A status code is read at three levels, and they do not agree with each other. Reading one rule where there are three is what produces the two classic failures: treating a bounded synchronous window as the lifetime of the command, and expecting a credit to give up the way a debit does.

1 · The synchronous attempt — what happens to this call

Network errors and 5xx are retried inside the window described above. A 402, a 404, 401/403 and other 4xx are not — repeating an identical request immediately cannot change any of those answers. That is the whole meaning of “not retried” in the table below: it bounds this attempt, and says nothing about whether the money command is over.

2 · The durable record — whether the command is over

For a debit, exactly two answers end it: a clean 402 — the first genuinely delivered request of the round answering “insufficient funds”, with no earlier timeout in that round that might already have moved money — and a 400 carrying one of the request-shape codes. Both mean provably nothing moved, so the order is voided and the intent is closed.

Everything else leaves the command undecided, and 404 is the one that surprises people: it is not the clean “no such player, give up” it looks like, because it cannot prove the remote wallet never moved. An undecided debit parks the order at order_status 2 and a reconciliation worker re-delivers the same idem_key on a backoff schedule; a stuck record has been observed re-delivering for hours in production. So never dedupe on an attempt count or a time window: dedupe on idem_key for the life of the record, and keep those keys indefinitely rather than expiring them on an assumed delivery budget.

3 · The direction — credits never give up at all

payout, cashout and refund move money towards the player, which means the operator owes it. Those commands have no failing terminal state: any non-2xx, including 402 and 404, is retried with capped backoff indefinitely and is never dead-lettered automatically. Past a retry threshold we raise a critical alert for a human and keep retrying — the only exits are a 2xx or a person. Dropping the row instead would leave a player quietly unpaid, which is the one outcome the system refuses to produce.

So a 402 on a credit is meaningless — you are receiving money, not paying it — and buys nothing but another delivery. On these three paths, answer 2xx or 5xx and nothing else.

There is a 20-attempt ceiling in the system, and this is the part worth getting right: it applies to the other direction — recovering a debit, and rows whose direction cannot be classified. Those exit the retry queue to a human with a critical alert rather than being silently dropped. Settlement commands are excluded as well, because dead-lettering one would orphan the order and freeze payouts for the whole market. Nothing in the credit direction is ever dead-lettered on a count.

What your status code means to us

The operator branches on your HTTP status before it looks at your body, and the branch decides whether a money command is retried, failed cleanly, or surfaced as an internal error. Returning a plausible-but-wrong status is the difference between “player has no funds” and “something broke.”

FieldTypeDescription
2xxsuccessrequiredApplied. Response body is parsed for balance_after. The only answer that ends a credit command.
402 · on /wallet/betends the debitrequiredInsufficient balance, and the one answer that proves no money moved — so we void the order and close the intent, permanently. Say it only when it is true: a 402 after you have already taken the money is the single silent-loss shape in this system, because nobody comes back to check. It is trusted only on the first genuinely delivered request of a round; if an earlier attempt timed out, we treat the 402 as undecided instead.
402 · on a creditmeaninglessoptionalOn payout, cashout and refund you are receiving money, so insufficient balance cannot apply. It is not terminal here — it just costs another delivery, forever. Return 2xx or 5xx on these three paths.
404 · on /wallet/betundecidedoptionalNot retried inside the synchronous window, but NOT the end of the command: it cannot prove your wallet never moved, so the order parks at order_status 2 and a worker keeps re-delivering the same idem_key until the outcome is decided. If you mean 'this player does not exist and never will', that is still not a terminal answer here — fix the mapping rather than expecting us to give up.
404 · on a creditretried foreveroptionalSame as any other non-2xx in the credit direction: retried with capped backoff indefinitely, never auto dead-lettered, with a critical alert raised for a human past a threshold.
401 / 403terminaloptionalYour signature check failed. Not retried — resending the same signature cannot help. Surfaces as upstream_error (502) and is logged loudly, because it almost always means the signing secret or the canonical drifted between deployments.
409 + code=idem_conflictterminaloptionalReserved for one narrow case: this idem_key already moved money on your side, but you cannot prove that movement belongs to this player and this order. Say so only when that is literally true. The operator will not retry and will not refund automatically — refunding on an unprovable key is how free money is minted — so the command is marked dead and raises a critical alert for a human. Returning it for anything else parks a real order in manual handling.
5xxretriedoptionalTreated as a transient upstream fault and retried with exponential backoff, up to the retry count configured on your hook.
other 4xxterminaloptionalRejected without retry and without a specific meaning. This is where a 409 for insufficient funds ends up — do not use it.

Bet

Money commands are addressed by business meaning, not by direction: the operator calls /wallet/bet, /wallet/payout, /wallet/cashout and /wallet/refund. All four take the same body and the same response — bet deducts, the other three add. Expose all four; a path you do not serve is a 404 on a real money command.

POST/wallet/betX-Wallet-Token

Deduct a stake from the player's balance when they place a bet. Must fail with 402 (insufficient_balance) rather than going negative — 402 is the only status the operator reads as insufficient funds, and 409 is already taken by idem_conflict. Idempotent on idem_key.

Body (operator → you)

merchant_idstringrequiredYour merchant identifier.
player_idstringrequiredThe player's external_uid.
currencystringrequiredISO-4217 currency of the amount.
amountstringrequiredPositive decimal, ≤2 dp, to deduct.
idem_keystringrequiredDedupe key; reapply → same result.
market_idstringoptionalOriginating market (reference).
order_idstringoptionalOriginating order (reference).
bizstringrequiredEchoes the path segment: bet | payout | cashout | refund.
Request bodyjson
{
  "merchant_id": "mch_9f3c",
  "player_id": "PLAYER-123",
  "currency": "EUR",
  "amount": "10.00",
  "idem_key": "ord-2026-07-22-abc123",
  "market_id": "mkt_1",
  "order_id": "ord_77f0",
  "biz": "bet"
}

Response

FieldTypeDescription
balance_afterstringrequiredPlayer balance after the debit.
idempotentbooloptionaltrue if this was a replay of a prior idem_key.
Response · 200json
{ "balance_after": "90.00", "idempotent": false }

Errors

insufficient_balance402Balance cannot cover the amount — do not go negative. Must be 402: any other 4xx is read as an opaque rejection.
unauthorized401Signature/token check failed.

Payout · Cashout · Refund

Three separate paths sharing one contract. /wallet/payout settles a winning position, /wallet/cashout returns the proceeds of closing early, and /wallet/refund gives a stake back when an order is voided. Route them separately even if the ledger entry is the same — the distinction is what your reconciliation reads.

POST/wallet/payoutX-Wallet-Token

Add funds to the player's balance. Same body and response for /wallet/cashout and /wallet/refund. Idempotent on idem_key.

Body (operator → you)

merchant_idstringrequiredYour merchant identifier.
player_idstringrequiredThe player's external_uid.
currencystringrequiredISO-4217 currency of the amount.
amountstringrequiredPositive decimal, ≤2 dp, to add.
idem_keystringrequiredDedupe key; reapply → same result.
market_idstringoptionalOriginating market (reference).
order_idstringoptionalOriginating order (reference).
bizstringrequiredEchoes the path segment: payout | cashout | refund.
Request bodyjson
{
  "merchant_id": "mch_9f3c",
  "player_id": "PLAYER-123",
  "currency": "EUR",
  "amount": "18.88",
  "idem_key": "settle-mkt_1-PLAYER-123",
  "market_id": "mkt_1",
  "order_id": "ord_77f0",
  "biz": "payout"
}

Response

FieldTypeDescription
balance_afterstringrequiredPlayer balance after the credit.
idempotentbooloptionaltrue if this was a replay of a prior idem_key.
Response · 200json
{ "balance_after": "108.88", "idempotent": false }

Errors

unauthorized401Signature/token check failed.

Balance

GET/wallet/balanceX-Wallet-Token

Report the player's current balance. Called by the operator to reconcile and to show available funds in the game-client. Read-only — no idempotency needed. This is the one hook that carries a query string, and those parameters are covered by the signature.

Query (operator → you)

merchant_idstringrequiredYour merchant identifier.
player_idstringrequiredThe player's external_uid.
currencystringrequiredCurrency to report.
Requesthttp
GET /wallet/balance?currency=EUR&merchant_id=mch_9f3c&player_id=PLAYER-123

# Keys arrive alphabetically sorted. Verify the signature against the query string
# exactly as received — re-encoding it before hashing is the usual way this breaks.

Response

FieldTypeDescription
availablestringrequiredSpendable balance.
frozenstringoptionalReserved amount, if you track one (0 in seamless model).
Response · 200json
{ "available": "90.00", "frozen": "0.00" }

Errors

not_found404Answer this only when you genuinely cannot report a balance for that player. Unlike the money commands, this one is a pure read with no idempotency and no worker behind it: your 404 is relayed straight through to whoever called GET /b2b/v1/wallet/balance, as not_found with the same status. Two states worth separating in your own code before you return it — the player is unknown to you, and the player is known but has no wallet in this currency. The second is the one that catches integrations: a wallet row created only at signup does not come back if it is later removed while the account survives, so the player exists, every other read answers 200, and only the balance 404s, permanently, until the row is restored.
unauthorized401Signature/token check failed.