Integrate

Integration guide

Updated 13 October 2026

Version 2.1 · October 2026 (first published September 2026) · for operators and aggregators.

This guide describes the integration surface as the server implements it: every field, code and limit below is enforced in code. The machine-readable companion is openapi.b2b.yaml.

New in 2.1. The signature of your responses also covers the HTTP status you answer with and a hash of the request body you are answering (§6.1): our connector decides on the status, so the status is signed. Test vectors updated. And the last Italian keys of the read surface are gone: /b2b/report answers with window, rows, subtotals, truncated and rows keyed currency/day (§7b), /b2b/round/:id with outcome, totals and fairness.publicPath, and /fair with persistent, outcome, positions, switches, exit, midRound and verificationPage (§9). Nothing else changed.

New in 2.0. One signature rule for every call, in both directions: method, path, timestamp and body are signed, and a response signs the timestamp of the request it answers (§6.1). The launch timestamp moved from the body (ts) to the x-timestamp header, and version 1.0 signatures are refused. Also new: close code 4409 when the same player opens the game somewhere else (Appendix A), and a new launch starts a new responsible-gaming session (§10).

0. At a glance

Four multiplayer crash games — God Candle, Big Fish, Diamond Hands, Hypnofrog — on one certifiable math engine and one integration: a signed launch call, an iframe, and four signed wallet callbacks to your platform. Adding the next title costs you a room id.

Model Remote Game Server (RGS): the game runs on our servers, your lobby embeds it in an iframe
Money Seamless wallet: the balance stays on your side; we call your /bet, /win, /rollback, /balance
Auth HMAC-SHA256 with a shared secret, in both directions, over method, path, timestamp and body; your responses also sign the status and the request they answer: we verify them (§6.1)
Session One-hour game token in the URL fragment, renewed on every connection and while the player stays connected
Currency One per operator, configured in our registry. Amounts are JSON numbers in major units (12.5 is €12.50), with the currency's decimals: two for most, none for JPY or KRW
Catalog GET /games — RTP, max multiplier, generator version, limits, icons, built from the running code
Fairness Provably fair: a SHA-256 hash chain committed per table, published by GET /fair
Environments Staging and production, fully separate (secrets, room ids, databases)

Quick start

  1. We give you, per environment: a base URL, an operator id, a shared secret, and one room id per game and per mode (real money, and optionally a free demo).
  2. You give us: your walletBaseUrl (https), your currency, and the origins of the lobby pages that will embed the game (allowedOrigins).
  3. Implement the four wallet callbacks (§6) under walletBaseUrl.
  4. On "play": call POST /b2b/launch (§4) from your backend and put the returned gameUrl in an iframe (§5).
  5. Run the certification checklist (§11) on staging, then go live (§12).

1. The model

The game never leaves our servers. Your lobby opens it in an iframe; the player's balance stays on your wallet, and we call your API for every stake and every payout. For you the integration is an iframe URL, a shared secret and four endpoints; for us, onboarding a new operator is configuration, not game code.

Consequences worth knowing up front:

  • The player is yours. From the launch we receive a pseudonymous player id and nothing else — no name, no e-mail, no channel to that person. We never contact your players; we could not if we wanted to. CRM, promotions and responsible-gaming controls stay on your side.
  • One room = one operator = one wallet. A room is shared by its players, on one table or more (§1b); every player in it belongs to the same operator and the same currency. Cross-operator liquidity is not offered.
  • The game name stays visible ("God Candle × YourBrand"), as with any branded title. Full white-label is not part of the standard offer.

1b. Rooms and tables

A room — the id you launch — can be served by more than one table. A table is one running game: its own game loop, its own hash chain, its own rounds. In a room with tables we seat each player at one of them and keep every table between a comfortable size and a hard cap (50 and 60 players by default). When a table gets crowded the game offers its players a move to a quieter table; at the cap the move happens anyway, for as many as it takes to bring the table back under the comfortable size. When a table empties out, the game offers its players a move to a busier one. Moves happen between two rounds, when nobody has an open position, and the client changes table on its own in a fraction of a second. A player who moves keeps their balance, their responsible-gaming session and limits, and their place on the leaderboard: those belong to the room, not to the table.

Switched on per room, with your written agreement. We enable tables room by room, and only after you confirm in writing the two points below that change your side: the ids and the exposure. Until then a room is a single table and nothing in this section applies to it.

What changes for you in a room with tables:

  1. The launch does not change: same room field, same token, same gameUrl. One new refusal, 503 ROOM_FULL with Retry-After: 20, when every open table is at its cap and no new one can open yet (§4). New tables open at most one a minute per room, so a crowd arriving in one wave is seated in steps — with the default sizes, 180 seats at once and then 60 a minute.
  2. Ids carry the table. Table 1 keeps the room id; from table 2 the id is <room>--t<n>. --t is reserved: no room id contains it. A roundId is <table>:<nonce>, a betId starts with the table id, and roomId in the rows of /b2b/bet and /b2b/settlements is the table. Remove --t<n> to get the room. /b2b/round/:id answers for every table of your rooms. Webhook events (§8c) carry the room you launched, never a table.
  3. The exposure cap is per table. maxExposure (§8) caps each table, so the worst case of a room is N × maxExposure, with N at most 20 tables with players. You confirm that figure in writing before your first room with tables.
  4. Status and fairness list the tables. GET /b2b/room/:id/status returns the room's tables (§7b), and GET /fair?room= lists every table with its published chain end (§9).

2. Environments

Staging and production are two independent installations, and the server enforces it:

staging production
Base URL given to you for the integration given to you for go-live
secret yours, distinct yours, distinct
room ids yours, distinct yours, distinct
Database and hash chains separate separate
Your walletBaseUrl your sandbox your production wallet
  • A staging game token is rejected in production and vice versa: the token names its environment, and the WebSocket closes with 4401.
  • A staging room id does not exist in production: the launch answers 403 ROOM_NOT_OWNED.
  • Nothing is shared, not even round history: the provably-fair chain of a room lives in one database.

To check which environment answers:

curl -s https://<base-url>/health
# {"ok":true,"world":"casino","env":"staging","activeRooms":4,"db":"ready","dbLastOkSec":4,"migrations":"ok","impronta":"ok","dbMs":null,"rev":"<git sha>"}

env is staging or prod. rev alone is not enough: both environments may run the same commit. /health is liveness: it answers 200 while the process is alive and reads no database unless you add ?db=1 (then dbMs is the round trip). GET /ready answers 200 when the service can take traffic now and 503 otherwise. Its realMoney: { ready, issues } says whether the service can take real money — database reachable, schema migrated, generator fingerprint verified, not in maintenance, not draining — and, if not, why. Before sending real-money traffic, check realMoney.ready, not only the status code.

3. Catalog — GET /games

Machine-readable and built from the engine constants, so it cannot drift from the running code.

curl -s https://<base-url>/games
{
  "provider": "Nerve Studios",
  "env": "staging",
  "rev": "<git sha>",
  "integration": { "launch": "POST https://<base-url>/b2b/launch", "wallet": "seamless: signed REST callbacks …", "health": "https://<base-url>/health" },
  "games": [
    {
      "id": "god-candle",            // also: "diamond-hands", "big-fish", "hypnofrog"
      "gameType": "GOD_CANDLE",      // the room's game; the launch picks the client from it
      "name": "God Candle",
      "tagline": "…",
      "category": "crash",
      "status": "live",              // "preview" for a title still in client polish
      "path": "/gc/",                // the launch's gameUrl already carries it
      "demoRoom": "demo",
      "demoUrl": "https://<base-url>/gc/?room=demo",   // points room, embeddable by anyone, no launch
      "fairUrl": "https://<base-url>/fair?room=demo",
      "math": {
        "rtp": 0.97, "houseEdge": 0.03, "rtpConfigurable": true,
        "maxMultiplier": 1000, "capMultiplier": 1000, "generatorVersion": "0.2.0", "volatility": "high",
        "medianRoundSec": 15, "maxRoundSec": 120,
        "p50MaxMultiplier": 1.8, "p90MaxMultiplier": 9.1, "reach2x": 0.45, "reach10x": 0.091,
        "simulatedRounds": 60000
      },
      "limits": { "minBet": 0.5, "maxBet": 500, "maxWin": null, "positionsPerRound": 3, "configurable": "per-room" },
      "currency": "operator",
      "languages": ["en", "es"],
      "platforms": ["desktop", "mobile", "webview"],
      "features": ["multiplayer", "provably-fair", "take-profit", "stop-loss", "multi-position", "mid-round-entry", "live-feed", "chat", "auto-bet-flag", "multiplier-in-settlement", "re-entry", "price-lock-epochs"],
      "fairness": { "model": "sha256-hash-chain", "verify": "GET /fair?room=<room>" },
      "icons": {
        "svg":    "https://<base-url>/games/god-candle/icon.svg",
        "png512": "https://<base-url>/games/god-candle/icon-512.png",
        "png256": "https://<base-url>/games/god-candle/icon-256.png",
        "png128": "https://<base-url>/games/god-candle/icon-128.png"
      }
    }
  ]
}

GET /games/<id> returns one entry. rtp, minBet, maxBet are the defaults: each room can carry its own values (§8), and the client reads the room's actual values at every connection. positionsPerRound is how many positions a player can hold open at the same time; in every game more can be opened during the round as earlier ones close (§7).

Volatility figures are measured over 60,000 simulated rounds of the production generator at the default RTP (Hypnofrog's median round duration over 300,000, because it sits on the boundary between two beats); generatorVersion says which generator they describe. maxMultiplier is the highest multiplier a round can actually reach and capMultiplier the configured cap: they coincide only in God Candle (exact barrier). Diamond Hands, Big Fish and Hypnofrog stop before the cap (pre-cap rule), so with the default configuration their maxMultiplier is 735.481, 999.0919 and 927.711 on a cap of 1000.

Only God Candle has the stop-loss feature: in the other three games the multiplier only climbs, so there is nothing for a stop order to catch. Hypnofrog's signature feature is fly-switch (a position can move to another live fly up to three times, free of charge, in front of the whole room).

4. Game launch — POST /b2b/launch

Called by your backend (never from the browser: it carries your secret) when a player opens the game.

Request

POST /b2b/launch
content-type: application/json
x-timestamp: 1788690000000
x-signature: <signature of this exact body, §6.1>

{"operator":"acme","player":"u-48213","room":"acme-gc-eur","displayName":"Bob","lang":"es","lobbyUrl":"https://lobby.acme.com/games"}
Field
operator required your operator id
player required your pseudonymous player id: printable ASCII without spaces (0x21–0x7e), at most 128 characters
room required one of your room ids, [a-zA-Z0-9_-]{1,64}
displayName optional at most 64 characters: the name other players see
lang optional xx or xx-YY
lobbyUrl optional https (http only on loopback, for local tests), no credentials in the URL, at most 512 characters
sessionId optional your own session id for this player: printable ASCII, no spaces, at most 64 characters. A different value starts a new responsible-gaming session (§10); without it, a 30-minute absence does

Signature. The rule of §6.1, with direction request, method POST, path /b2b/launch and the body exactly as you send it, byte for byte: serialise once, sign that string, send that string. Every field is covered, and no field can be moved into another. x-timestamp is epoch milliseconds, digits only, within 60 seconds of our clock.

// Node.js — sign() is the helper of §6.1
const body = JSON.stringify({ operator: 'acme', player: 'u-48213', room: 'acme-gc-eur', lang: 'es' });
const ts = String(Date.now());
const res = await fetch(`https://${BASE_URL}/b2b/launch`, {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    'x-timestamp': ts,
    'x-signature': sign('request', 'POST', '/b2b/launch', ts, body),
  },
  body,
});

Response

{
  "token":     "<game token>",
  "gameUrl":   "https://<base-url>/gc/?room=acme-gc-eur&lang=es#t=<game token>",
  "ttlSec":    3600,
  "alias":     "Bob",
  "realMoney": true
}
  • Use gameUrl as is. The path (/gc/, /dh/, /bf/, /hf/) is chosen from the room's game; the token travels in the URL fragment, so it never reaches logs or Referer headers. The query carries only room and lang: your lobbyUrl travels inside the token.
  • The token is signed, not encrypted. Whoever holds the gameUrl can read what the token carries — your operator id and the player's id, the alias, the room, lobbyUrl, sessionId and the expiry — and cannot change any of it. Treat a gameUrl like a session cookie: it belongs in that player's browser only.
  • alias is the public name other players will see: displayName after cleaning (control characters removed, whitespace collapsed, 24 characters max), or a name we generate. Your player id is never shown to anyone.
  • realMoney is false when the room is your demo room (§4.3).

Errors

Status error Meaning
400 BAD_REQUEST (+ field) a field is missing or malformed: player, room, x-timestamp, displayName, lang, lobbyUrl, sessionId
401 STALE x-timestamp is more than 60 s away from our clock
401 REPLAY the same signature was already seen within the last two minutes, sent from a different IP address: a captured request sent again from elsewhere gets nothing (since 3 October 2026). The same signature from the same address is a retry, and gets the first answer — see below
401 BAD_SIGNATURE the signature does not match
403 OPERATOR_SUSPENDED your operator is suspended on our side (kill-switch, §8): no new launches, open positions stay cashable
403 IP_NOT_ALLOWED the calling IP is outside the allowlist you gave us
403 ROOM_NOT_OWNED the room does not exist, is not yours, or is neither a real-money nor a demo room
404 OPERATOR_UNKNOWN the operator id is missing, or not in our registry for this environment
409 REPLAY_IN_PROGRESS a retry of a launch that is still being processed, and did not finish within 15 seconds: ask again in a moment
429 RATE_LIMITED more than 30 failed launches per minute from one IP; successful launches are never counted
503 ROOM_INACTIVE the room is paused (kill-switch on our side, §8)
503 MAINTENANCE a demo room during a maintenance window: no new launches, players already inside finish their round. A real-money room answers RGS_NOT_READY with MAINTENANCE in issues
503 RGS_NOT_READY (+ issues) a real-money room while our readiness checks fail (see GET /ready), or any launch while our database is unreachable (DB_NOT_READY in issues); issues is a subset of MAINTENANCE, DRAINING, DB_NOT_READY, MIGRATIONS_NOT_VERIFIED, ENGINE_NOT_VERIFIED; retry later
503 ROOM_RECOVERY_REQUIRED the room is held by a previous process after a crash and needs manual recovery on our side. In a room with tables (§1b): no table can take a player, and at least one is held this way
503 ROOM_FULL (+ Retry-After: 20) a room with tables (§1b): every open table is at its cap and no new one can open yet. Retry after the seconds in Retry-After; a player who already has a seat in the room is never refused this way
503 TOKEN_DISABLED / DB_REQUIRED our side is misconfigured; retry later and tell us

The order of the checks matters to you in two places. Operator ids are not secret: an unknown id answers 404 OPERATOR_UNKNOWN before anything else is read. Suspension and the IP allowlist are checked after the signature, so an unauthenticated caller learns nothing about an operator's state or network. A non-numeric x-timestamp is a 400, never a launch without replay protection. Only your own refusals (unknown operator, fields, timestamp, signature, suspension, IP, room) count towards the 429 budget; the 503s never do, ROOM_FULL included: a full room is not a wrong attempt.

The replay window is kept in the memory of the game process, which runs as a single replica: a restart empties it, so a signature captured before a redeploy would be accepted again within its own two-minute timestamp window. That is the residual risk of the design, stated here on purpose; the timestamp check still bounds it to two minutes.

A retry gets the first answer (since 6 October 2026). The same signed request sent again from the same IP address within two minutes — a proxy that retries the same bytes, a client that timed out while we were answering — is not launched twice: it gets the response we gave the first time, same status, same body and the same token, with the header idempotent-replayed: true. If the first one is still being processed, the retry waits for it, up to 15 seconds (409 REPLAY_IN_PROGRESS beyond). This holds for whatever we answered, a 503 included, as with idempotency keys elsewhere: the first answer is the answer to that request. To really try again after a refusal, sign a new request with a fresh x-timestamp — a new signature is a new request. Checks that come before the signature is verified (fields, timestamp, signature) leave nothing behind. Timestamps are milliseconds, so two byte-identical launches sent in the same millisecond share a signature: the second one is a retry of the first.

4.1 Session and token lifetime

The token is valid for ttlSec (3600 s) and is reusable within that window: the client keeps it in sessionStorage, so a page refresh inside the iframe does not need a new launch. Every successful connection returns a renewed token to the client, and while the player stays connected the server renews it again at about half its lifetime, so an active player's session rolls forward: a player who has been connected for longer than the TTL and then drops (a mobile network, a backgrounded tab) reconnects without a new launch. The token is not renewed while your operator is suspended (§8): then the client keeps the token it has and the session ends at its original expiry. When it finally expires the WebSocket closes with 4401, the client shows a "session expired" screen with a link back to lobbyUrl, and emits sessionExpired on the bridge (§5.3): relaunch from your lobby.

4.2 Player identity

Internally the player is user:<operator>:<player> — namespaced per operator, so two operators can never collide on the same id. Your wallet always receives your raw player id as playerId. The player row on our side is created at launch time (name, last seen); it is the only per-player data we hold.

4.3 Demo rooms

A room in demo mode, owned by you, accepts the same signed launch: same client, same math, same identity, but the balance is our points wallet, no callback ever reaches your wallet, and the client shows a "DEMO — points have no cash value" note. Use it for a "try for free" button; the launch answers realMoney: false.

5. Embedding the game

5.1 The iframe

<iframe src="<gameUrl>" allow="autoplay; fullscreen"
        style="width:100%;height:100%;border:0"></iframe>
  • The client is a self-contained mobile-first SPA. No third-party cookies; it works with our domain's storage blocked (Safari inside a third-party iframe): it only loses memory between visits.
  • Do not add a sandbox attribute without allow-same-origin and allow-scripts: the client needs its own storage and WebSocket.
  • Who may embed a real-money room is declared in our registry. The game page carries Content-Security-Policy: frame-ancestors 'self' <your allowedOrigins>; an origin you did not declare gets a blank iframe. Give us every lobby origin (https://host[:port], no path) for each environment. The browser checks every ancestor frame, not only the direct parent: an aggregator page that wraps your lobby, or wraps the game, needs its origin declared too. Demo rooms are embeddable from anywhere.
  • Origins are https only, tests included. A lobby served from http://localhost cannot embed a real-money room: test the embedding from an https origin you declared (a tunnel to your machine will do), or with your demo room. While no origin at all is declared, the game page also carries X-Frame-Options: SAMEORIGIN.
  • To relaunch, replace the iframe. A new gameUrl for the same room and language differs from the previous one only after the #, and a browser does not reload a page whose URL changed only there: setting src again leaves the game on its old token. Remove the iframe element and create a new one with the new gameUrl.
  • The WebSocket is opened by our page, never by yours: an upgrade whose Origin is not our host is closed with 4403. Per IP, 120 upgrades per minute are allowed (4429 above that) — a lobby with many players behind one NAT stays well inside.
  • All responses carry X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin and, over https, HSTS.

5.2 lang and lobbyUrl

  • lang opens the client in that language and saves it as the player's preference (the player can still change it from the menu). Supported: en, es; regional tags map to their base (es-MX → es); anything else falls back to the browser language.
  • lobbyUrl turns on "Back to the lobby" in the menu and on the session-expired screen, and "Deposit" in the insufficient-funds message. Its origin is also the only one that receives the bridge events (§5.3).
  • sessionId is your own session id for the player, if your lobby has one. It is not an identity (that stays operator:player, and your wallet never sees it): the RGS uses it for responsible gaming only — a new value starts a new session (time, reality check, session cap, net), see §7b. Without it, a 30-minute absence does.

5.3 The postMessage bridge (game → lobby)

The game posts { source: 'nerve', type, … } to its direct parent (window.parent). There are no commands in the other direction, by design.

type when data
gameReady first successful connection (once per page) —
balance whenever the balance shown to the player changes balance, currency
sessionExpired the server closed the session (token expired or invalid) —
exit the player pressed "Back to the lobby" —
openCashier the player, out of funds, pressed "Deposit" —
openResponsibleGaming the player asked for the responsible-gaming page from the game menu (§10) —
onboardingAtRisk a new player has closed N bets without ever cashing out (§8c); once per session —

Where the events go. With a lobbyUrl, every event is posted with its origin as targetOrigin: the browser delivers it only when the direct parent is on that origin, and drops it silently otherwise. Without a lobbyUrl, balance is never sent and the events without data go to any parent. Always check event.origin against our base URL before trusting a message.

window.addEventListener('message', (e) => {
  if (e.origin !== 'https://<base-url>' || e.data?.source !== 'nerve') return;
  if (e.data.type === 'openCashier') openDeposit();
  if (e.data.type === 'openResponsibleGaming') openResponsibleGamingPage();
  if (e.data.type === 'exit' || e.data.type === 'sessionExpired') closeGame();
});
  • If another page sits between your lobby and the game (an aggregator's wrapper), the direct parent is that page: give a lobbyUrl on its origin, or relay the events from it.
  • "Back to the lobby" posts exit and then navigates the iframe to lobbyUrl. Listen for exit and close the game from the parent, or your lobby opens inside the frame.

6. Wallet callbacks (implemented by you)

Four POST endpoints under your walletBaseUrl (https; plaintext http is accepted only on loopback, for local testing). JSON in, JSON out. We never follow redirects: serve them at that address.

6.1 Authentication, both ways

Every call between us is signed with the same rule, whichever side sends it: your launch and reads (§4, §7b), our wallet callbacks and events webhook (§6, §8c), and your responses to our callbacks.

x-timestamp: <epoch milliseconds>
x-signature: hex( HMAC-SHA256( secret, "NERVE-V2\nrequest\n<METHOD>\n<path>\n<timestamp>\n<body>" ) )

Your responses to our callbacks sign two more parts — the status you answer with, and a hash of the request body you are answering — between the timestamp and the body:

x-signature: hex( HMAC-SHA256( secret, "NERVE-V2\nresponse\n<METHOD>\n<path>\n<timestamp>\n<status>\n<sha256(request body)>\n<body>" ) )
Part Value
direction request on every request; response on your answers to our callbacks
METHOD POST or GET, upper case
path on calls to you: the logical path — /bet, /win, /rollback, /balance or /events — whatever prefix your walletBaseUrl or events URL carries. On calls to us: the request target exactly as you send it, query string included (/b2b/report?operator=acme&from=2026-09-01&to=2026-10-01)
timestamp the x-timestamp header of the request. A response signs the timestamp of the request it answers, and carries no x-timestamp of its own
status responses only: the HTTP status code you answer with, as decimal digits (200, 402…)
sha256(request body) responses only: the lowercase hex SHA-256 of the exact request body bytes you received (the hash of the empty string for a body-less request)
body the exact bytes sent; the empty string when there is no body

The parts — six for a request, eight for a response — are joined with a single line feed (\n, 0x0A), and nothing follows the body. The signature is lowercase hex; we accept either case from you.

What this buys: a signature is valid for one method, one path and one direction, so a /bet cannot be replayed as a /win, and a request of ours cannot come back as a response of yours; a response is bound to the request it answers — its timestamp and its body — so an old response cannot answer a new request, and two answers to two calls in the same millisecond cannot be swapped; and the status is signed, because the status is what we act on (6.4): a proxy that rewrote your signed 402 into a 200, body untouched, would otherwise turn a refusal into a live bet with nothing debited.

Verify every request we send, on the raw body bytes as received — never on a re-serialised object — with a constant-time comparison, and refuse a timestamp more than 60 seconds from your clock (keep your servers on NTP). We sign every attempt afresh: a retry carries a new timestamp and the same body. A request that fails verification moves no money: answer it with a signed 401 and a reason (BAD_SIGNATURE, say) — on /bet that is a definitive refusal (6.4), and a /win or /rollback that does not get its 200 is tried again later, signed afresh. Sign every response, refusals included: an unsigned or mis-signed response is treated as no response (6.4), so a forged reply can never create balance.

// Node.js, Express. The wallet routes need the raw body:
//   app.use('/rgs', express.raw({ type: 'application/json' }));
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';

export const signRequest = (method, path, timestamp, body) =>
  createHmac('sha256', SECRET)
    .update(['NERVE-V2', 'request', method, path, timestamp, body].join('\n'))
    .digest('hex');

// a response signs the status it carries and a hash of the request body it answers
export const signResponse = (method, path, timestamp, status, requestBody, body) =>
  createHmac('sha256', SECRET)
    .update(['NERVE-V2', 'response', method, path, timestamp, String(status),
      createHash('sha256').update(requestBody).digest('hex'), body].join('\n'))
    .digest('hex');

// path: '/bet', '/win', '/rollback' or '/balance' — never the full path under walletBaseUrl
export function verifyRequest(req, path) {
  const ts = req.get('x-timestamp') ?? '';
  if (!/^\d{1,16}$/.test(ts) || Math.abs(Date.now() - Number(ts)) > 60_000) return false;
  const expected = Buffer.from(signRequest('POST', path, ts, req.body.toString('utf8')));
  const received = Buffer.from(String(req.get('x-signature') ?? '').toLowerCase());
  // timingSafeEqual throws on buffers of different length: compare the lengths first
  return received.length === expected.length && timingSafeEqual(received, expected);
}

export function reply(req, res, path, status, payload) {
  const body = JSON.stringify(payload);
  res.status(status)
    .set('content-type', 'application/json')
    .set('x-signature', signResponse('POST', path, req.get('x-timestamp'), status, req.body, body))
    .send(body);
}

Test vectors. Secret test-secret-do-not-use, timestamp 1788690000000:

our request      POST /bet
body             {"playerId":"u-48213","betId":"acme-gc-eur.3f9a1c2e:1042:s0#0","amount":12.5,"currency":"EUR","roundId":"acme-gc-eur:1042"}
x-signature      ef9ebb50d1529230ba5a07e22f683e1eb291b66d93537f1978509c0e5e8323da

your response    200 to that /bet
body             {"balance":87.5}
x-signature      dffdb9c98ae04c6ac1a993fcc864c224c650882db450cf35a18177ee0ed7f256

your launch      POST /b2b/launch
body             {"operator":"acme","player":"u-48213","room":"acme-gc-eur","displayName":"Bob","lang":"es","lobbyUrl":"https://lobby.acme.com/games"}
x-signature      52af7af12f2bff83b56749889ed1bcff1e61d445542f320e27bed83209f020c1

your read        GET /b2b/report?operator=acme&from=2026-09-01&to=2026-10-01
body             (empty)
x-signature      ef9137cf26cc1d057d6faf2ac72b6adacf1c45d0280f7d0eaaebb06faf6fe7b4

The string signed for the first vector, with each \n shown as a line break:

NERVE-V2
request
POST
/bet
1788690000000
{"playerId":"u-48213","betId":"acme-gc-eur.3f9a1c2e:1042:s0#0","amount":12.5,"currency":"EUR","roundId":"acme-gc-eur:1042"}

And the string signed for the second — your 200 to that /bet: the status, then the SHA-256 of the request body above, then your body:

NERVE-V2
response
POST
/bet
1788690000000
200
d08c5ee8b0d5f96962744e302eb968ae2af6f25f4c0b78ac2ebe8c3c53899b48
{"balance":87.5}

6.2 Endpoints

Every call we send carries content-type: application/json, x-operator: <your operator id>, x-timestamp and x-signature.

Endpoint Request Success Failure
POST /bet { playerId, betId, amount, currency, roundId } 200 { balance } 4xx { reason, balance? } definitive · 409 { reason: "ROLLED_BACK" }
POST /win { playerId, betId, amount, currency, roundId, multiplier? } 200 { balance } anything else → we retry (6.5)
POST /rollback { betId } 200 { balance } 409 { reason: "ALREADY_SETTLED", balance }
POST /balance { playerId, currency } 200 { balance } anything else → the session is refused: the socket closes with 4004, the game says the table is unreachable and retries slowly (since 13 October 2026; before, the figure was shown as 0)
  • amount and balance are JSON numbers in major units of your currency (12.5 is €12.50), with at most the currency's decimals — two for most currencies, none for zero-decimal ones such as JPY. Stakes are rounded and payouts floored to that scale. currency is always the one configured for you.
  • playerId is your raw player id from the launch.
  • roundId is global: <roomId>:<nonce> (e.g. acme-gc-eur:1042), identical on every call of that round.
  • betId is globally unique and carries the room id first; treat it as an opaque string of up to 256 characters. Idempotency is on betId.
  • multiplier (/win only): the multiplier the position closed at, relative to entry, with four decimals and the same value on every retry (1.0 is break-even before the house edge; God Candle can close below 1.0), or 0 on a lost bet (amount: 0). Optional in the schema: ignore it if you do not need it. It is what a CRM or tournament tool needs to rank players by max multiplier instead of total staked; it is also in GET /b2b/settlements and GET /b2b/bet/:id. In Hypnofrog, for a position opened while the round was running, this value was the ladder's absolute multiplier before the release of 9 October 2026; it is now relative to entry, as in the other games (see the changelog).
  • amount on /win is not always stake × multiplier. It is stake × multiplier × f — with the multiplier at full precision, before the four-decimal rounding of the field — floored to the currency's decimals and capped at the room's maxWin, where f is 1 for a position opened in the betting window and the room's RTP (0.97 by default) for a position opened while a God Candle, Diamond Hands or Hypnofrog round is running. That factor is the house edge of a mid-round entry: an entry before the start pays its edge through the chance of losing at the start, which a mid-round entry has skipped. In Big Fish f is always 1, because every fish carries its own chance of an instant loss, whenever it is dropped. A €10 position opened mid-round at 2.00× and cashed out at 3.00× closes with multiplier: 1.5 and amount: 14.55 (10 × 1.5 × 0.97). Reconcile on amount; rank on multiplier.

6.3 What we require from your implementation

  1. Idempotency on betId for /bet, /win and /rollback: a repeated call returns the same result and moves no money twice.
  2. /win with amount: 0 closes a lost bet. Accept it like any win (idempotent). Every accepted /bet receives exactly one closing /win (won or lost) or one /rollback; your ledger never has an open bet at the end of a round.
  3. /rollback of a settled bet → 409 ALREADY_SETTLED. A bet already closed with /win (including amount 0) is never refunded.
  4. /rollback of a betId you have never seen → 200, and remember it. A /bet with that betId arriving later must be refused with 409 { reason: "ROLLED_BACK" } and never debited. Without this rule a rollback we issue during a timeout could be overtaken by the late debit, leaving the stake charged with nobody knowing. A /rollback carries no player, so there is no balance to report: answer 200 { "balance": 0 }. The balance of a /rollback answer is never used for accounting, and a 0 is never shown to the player.
  5. Answer within 4 seconds. We time out at 4 s, from the request to the last byte of your body, and retry once on network errors and timeouts — never on an HTTP response, whatever its status. Both are per-operator settings in our registry (walletTimeoutMs, a positive number of milliseconds; walletRetries, an integer 0–5; defaults 4000 / 1).
  6. Idempotency that holds under concurrency. The same betId can reach you twice almost at once: our retry after a timeout while your first answer is still on its way, or a /rollback racing a late /bet. Enforce it with a unique key or a lock on betId, not with a read followed by a write.
  7. No redirects. We do not follow 3xx: a redirect is an ambiguous answer (6.4), and on /bet it triggers a rollback.
  8. Expect bursts at the end of a round. Every open position is closed at once; our /win calls, amount: 0 included, go through a queue with at most 32 in flight per server. /bet, /rollback and /balance follow the players' actions.
  9. Never credit a /win for a betId you did not debit, or rolled back. By design we never send one: a refund claim excludes any later credit for the same betId (§7). If one reaches you, answer a signed 409 with a reason of your choice (UNKNOWN_BET, say) and alert your team. Like any /win without its 200, it is retried (6.5) and ends flagged for manual settlement, where both sides reconcile it with GET /b2b/bet/:id.

6.4 How we read a /bet response

Your answer Our reading What happens
200 signed, with a finite balance ≥ 0 debited the bet is live
4xx signed definitive refusal, nothing debited the player sees the reason (6.6); no rollback
5xx; a redirect or any other status below 400 that is not 200; a 200 without a finite balance ≥ 0; a timeout; no signature or a wrong one (a status that differs from the signed one is a wrong signature); non-JSON ambiguous — you may have debited we send /rollback immediately; if that fails too, the bet stays in our open-bet ledger and a sweeper retries the rollback — first 5 minutes after we requested the debit, then about every 5 minutes, up to 24 attempts — until you answer; a 409 ALREADY_SETTLED from you closes the entry as settled

6.5 Wins that fail

A /win that does not get a signed 200 is stored in a durable outbox and retried with backoff — 30 s, 1 min, 5 min, 15 min, then hourly — for about 26 hours. After that it is flagged for manual settlement and we contact you. Retries are idempotent on betId. The player never loses a due payout; the worst case is a delay.

The delays are minimums: the outbox is swept every 30 seconds, so an attempt can come up to 30 s after its slot. In GET /b2b/bet/:id, win.attempts counts the retries from the outbox — not the first call, and including the retry that succeeded — and win.lastError is what the last failed attempt got: ERROR for any answer other than a signed 200 (the reason in your body is not kept), CURRENCY_MISMATCH (below) or OPERATOR_NOT_REGISTERED (§7). It stays after the win is SETTLED, as the trace that the delivery needed retries.

One case is not retried: a queued /win whose currency no longer matches the currency configured for you (the registry changed while wins were pending) is flagged FAILED at once with CURRENCY_MISMATCH and settled manually — we never convert.

6.6 Refusal reasons on /bet

Return one of these in reason with a signed 4xx — we suggest 402 for funds, 403 for blocks, limits and maintenance, 401 for sessions. Never a 5xx: it reads as ambiguous (6.4), we roll the stake back, and the player sees a generic error instead of your reason. The player sees the reason translated:

reason Meaning
INSUFFICIENT_FUNDS not enough balance — the client offers "Deposit" (openCashier, §5.3)
PLAYER_UNKNOWN playerId does not exist
PLAYER_BLOCKED blocked or self-excluded player
LIMIT_EXCEEDED a stake, loss, or session limit of the player is exceeded
SESSION_EXPIRED the player's session on your side is no longer valid (relaunch needed)
MAINTENANCE wallet in maintenance

Any other value (or a 4xx without reason) is shown as a generic error and kept verbatim in our log.

7. Round lifecycle and settlement

your /bet (200)  ──►  position ACTIVE  ──►  cash-out / take-profit / stop-loss   ──►  your /win (amount > 0)
                                       ──►  lost (rug, hit, eaten, stomped)      ──►  your /win (amount 0)
                                       ──►  cancelled in the betting window     ──►  your /rollback
                                       ──►  round interrupted by us             ──►  your /rollback
  • Game settlement is separate from money settlement. The multiplier is locked on our side (price-lock per epoch, server-side orders); wallet calls are asynchronous with retries. Latency between you and us never changes a player's expected value.

  • Durable outcomes. An accepted outcome — a cash-out or a loss — is written together with its /win obligation before the player sees the confirmation and before the round result is published. A crash after that write leaves the /win in our outbox, retried as in 6.5; a crash before it leaves the stake to the orphan recovery below, which refunds it. A refund claim excludes a later credit for the same betId. Your idempotency on betId stays mandatory, including when a payment succeeds and its response is lost.

  • Take profit / stop loss are server-side orders executed inside the game loop: they are not client automation. Take profit exists in every game, stop loss only in God Candle, the one game whose multiplier can fall.

  • Positions: up to 3 open at once per player. In every game a player can also enter while the round runs, at the live multiplier, with the same expected return (the RTP) as an entry before the start. God Candle lets a player re-enter after cashing out; Hypnofrog lets each position switch fly up to 3 times.

  • Interrupted game policy. A planned shutdown drains every room: open positions are refunded through /rollback before the process exits. We write the stake's durable record before calling your /bet; if that write fails, the bet is refused without a debit request. After a crash, the ledger sweeper rolls back unresolved stakes 5 minutes after the debit was requested. A room that was running when the process died stays unavailable (ROOM_RECOVERY_REQUIRED) until we confirm the old process is gone and recover the room by hand: there is no automatic failover. In a room with tables (§1b) only the tables that were running are held: the room keeps playing on the others meanwhile, and answers ROOM_RECOVERY_REQUIRED only when none of them can take a player. A database failure at the same moment as a crash is not fully covered, and those cases are reconciled by hand. If your operator entry is missing from our registry at that moment, retries stop: wins become FAILED and orphan stakes are marked exhausted, both with OPERATOR_NOT_REGISTERED, until we restore the entry and reopen them.

  • Reconciliation. Every debited betId is meant to end with a /win (any amount) or a /rollback, and retries reuse the same betId (there is no separate idempotency-key header). That is a guarantee on outcomes, not exactly-once HTTP delivery: entries whose retries are exhausted are settled by hand, and you can read our side at any time with the same secret (§7b).

  • Capacity (measured, September 2026). On a single instance of our staging deployment, 400 concurrent real-money players across 8 rooms, 1,622 bets per minute through signed sessions and the wallet contract, with the 250 ms game loop at 252 ms and nothing near a limit — the load generator ran out first, not the server; locally, one process held 4,000 players betting in one room. Those are measurements under stated conditions, not a ceiling, and they predate the durable settlement of 12 September (a cash-out acknowledgement now waits for a database commit): we re-measure with you against your expected peak. Rooms are independent, so scaling is room sharding, not a redesign — and a room with tables (§1b) shards itself: in a local measurement of October 2026, 200 players on four tables used a quarter of the bandwidth of the same 200 in one table, with the money reconciled to the cent table by table. Full conditions on request.

  • ⚠️ Your wallet latency is the binding constraint, not ours — please read this one. POST /bet is called synchronously on the player's path, inside a 6-second betting window (the default; configurable per room between 3 and 20 seconds). So your latency does not slow us down — it shortens the window your players can bet in:

    A bet placed when less than L milliseconds remain in the window is confirmed by your wallet after the round has started. In God Candle, Diamond Hands and Hypnofrog we then refund the stake with /rollback and the player sees BETTING_CLOSED (they can still enter mid-round, at the live multiplier); in Big Fish the fish enters the ocean at the current beat, as a mid-round drop. With a 6 s window and a 1.5 s wallet, the usable window becomes ~4.5 s.

    Measured on 2026-09-08 against our staging deployment: 100 concurrent players in a real-money room, wallet latency injected, identical load on all four points (188/188/190/187 bets per round):

    your /bet latency bet → accepted p50/p95 cash-out → ack p50/p95 balance updated bets lost
    0 ms 50 / 67 ms 48 / 58 ms 4 ms —
    150 ms 241 / 377 ms 49 / 128 ms 735 ms —
    500 ms 673 / 1,094 ms 49 / 93 ms 2.4 s 3 of 190
    1,500 ms 1,894 / 3,152 ms 47 / 73 ms 8.3 s 5 of 187

    (~50 ms is present throughout: it is the network round trip between the measuring machine and our Frankfurt region, not our processing.)

    Historical benchmark. It was measured before the durable settlement of 12 September, when cash-outs settled in memory; the latency above is not a measurement of the current implementation, so we re-measure before agreeing an SLA. Wallet delivery remains asynchronous. Three observations from that version:

    1. Cash-out acknowledgement never moved — 58 → 128 → 93 → 73 ms across the whole sweep, and our game tick stayed at 251 ms on a 250 ms target. If a player reports the game feels slow, this row tells you where to look.
    2. Your wallet's latency hits three separate paths. /bet is synchronous and shortens the window; /win is deferred, so the acknowledgement follows our settlement but the balance figure lags; /rollback is synchronous, so cancellations take longer to confirm. Today the game adds a payout to the balance it shows as soon as the position closes, and replaces that figure with your balance when your /win answers: while a /win is being retried (6.5) the game is ahead of your wallet, and a reload, which reads /balance, shows your figure until the retry lands.
    3. Degradation is gradual, not a cliff. At 500 ms roughly 1.5% of bets are lost — the ones sent in the final moment of the window. That is survivable; it is not invisible.

    What we need from you before we agree volumes: the p95 of your /bet under your own peak load. Under 150 ms you lose nothing. Around 500 ms you lose ~1.5% of bets and should either accept that or ask us to lengthen the betting window for your rooms. Above one second, the integration still works but the experience belongs to someone else.

7b. Reading your own data

Five read-only endpoints, signed with the same shared secret you already use for the wallet callbacks. No new credential, no dashboard to log into.

Authentication. Add ?operator=<your id> to the query and sign the call with the rule of §6.1: direction request, method GET (POST for the opt-out of §8b), the path with its query string exactly as you send it, your x-timestamp, and an empty body. Build the path once and use the same string for the request and for the signature: every parameter is signed, in the order you wrote it.

A request is valid for 60 seconds around our clock, and a signature names one request: we remember it as soon as it verifies, for two minutes. The same signature again from the same IP address is a retry: a read is simply answered again, and the opt-out of §8b, the one call here that changes something, gets the response we gave the first time with idempotent-replayed: true, and changes nothing more (§4, «A retry gets the first answer»). From a different IP it answers 401 REPLAY. A new attempt — after a 503, say — is signed again with a fresh x-timestamp. Keep the secret out of logs and stay on TLS. Failed attempts are rate-limited per IP; successful reads are not.

Errors, common to the five: 404 OPERATOR_UNKNOWN, 400 BAD_REQUEST (missing or non-numeric x-timestamp), 401 STALE, 401 REPLAY (the same signature from another IP), 401 BAD_SIGNATURE, 403 OPERATOR_SUSPENDED, 403 IP_NOT_ALLOWED, 429 RATE_LIMITED (30 failed attempts per minute per IP, a budget separate from the launch's), 503 DB_REQUIRED (no database on this service) / 503 DB_UNAVAILABLE (the database is unreachable right now: retry). Per endpoint: 400 BAD_ROUND_ID (/b2b/round), 400 BAD_BET_ID (/b2b/bet, ids over 256 characters), 400 BAD_RANGE / RANGE_TOO_WIDE (/b2b/report, /b2b/settlements).

⚠️ "Not yours" and "does not exist" return the same 404 NOT_FOUND. With a valid secret, a different answer would let you probe another operator's rounds. It is deliberate, and it means a 404 on a round you believe is yours is worth a message to us.

GET /b2b/round/:id — the id is the roundId we send you on every /bet and /win, i.e. <table>:<nonce>, where the table is the room id itself unless the room has tables (§1b). Returns the outcome, the generator inputs (so you can point the player at the public verification, §9), the round's total staked and paid out, and your bets on it: the positions that closed in the round, won or lost. A stake refunded with /rollback — cancelled in the betting window, or refused after your 200 because the round had moved on — is not among them: find it on /b2b/bet/:id or /b2b/settlements. A round appears here once it has settled: while it is still running the answer is 404.

GET /b2b/bet/:id — the id is the betId you saw on /bet. Returns the stake's state towards your wallet, the state of its win obligation, and the timeline of what the server received for that bet, in arrival order. playerId is your own player id — the value you sent on /b2b/launch and see on /bet and /win. This is the endpoint for "the player says they won and nothing arrived".

status Meaning
OPEN we requested the debit and the position is not closed yet — a /bet whose answer was ambiguous included
REJECTED you refused the debit with a signed 4xx; nothing was debited
SETTLED the outcome is final on our side, won or lost; whether the credit reached you is win.status
ROLLBACK_PENDING our sweeper has claimed a refund and is retrying /rollback
ROLLED_BACK refunded: cancelled, interrupted, or recovered after a crash

win.status is PENDING (still retrying), SETTLED (your /win answered 200) or FAILED (no more automatic retries; settled by hand); win.attempts and win.lastError are in 6.5. SETTLED on the stake alone does not prove the credit was delivered. Escalate a missing credit with its betId and roundId; never create a replacement credit id.

Each timeline entry has a kind — PLACE_BET, CASHOUT, CANCEL_BET, SET_ORDERS (take profit or stop loss set), SWITCH_BET (Hypnofrog's fly switch), AUTO_CASHOUT (an exit the server executed: take profit, stop loss, or a forced exit at the cap or the time limit) or RUG_SETTLE (the position was still open at the loss) — an outcome, ACCEPTED or REJECTED, and a reason: why a command was refused, or why the server closed the position (TAKE_PROFIT, STOP_LOSS, RUG…). epoch and price are the step and the price a command was priced at. A timeline entry's multiplier is the one the ledger recorded: in Hypnofrog it is the ladder's absolute value, the number the player saw, while the /win call carries the multiplier relative to entry.

⚠️ betIds contain # (<table>.<instance>:<nonce>:<slot>#<n>): percent-encode the id in the path (%23), or your HTTP client cuts the URL at the # — the ?operator= query with it — and the answer is 404 OPERATOR_UNKNOWN. Sign the encoded path, exactly as sent. The same applies to :player in the opt-out call.

⚠️ On a demo room /b2b/round lists no bets of yours, and /b2b/report and /b2b/settlements have no rows for it: demo bets are points and never enter the operator ledger. That is by design, not a fault.

GET /b2b/settlements?from=2026-09-01&to=2026-10-01 — the durable wallet ledger, one row per stake, for batch reconciliation (JSON, or &format=csv). The window selects stakes by the moment we requested the debit, from included and to excluded, at most 90 days. Rows come from the ledger and the win outbox, so they include rounds that were interrupted and never reached /b2b/report, and stakes still open or waiting for a refund. Amounts are exact decimal strings; payout: null means no win obligation was recorded, while "0.00" is a recorded loss. playerId is your own id. At most 20,000 rows: when truncated: true (also the header x-report-truncated: true), split the window before reconciling — missing rows in a truncated export are not missing transactions. The CSV has UTC timestamps and prefixes formula-like values with an apostrophe for spreadsheet safety; the JSON keeps identifiers exact. Query the same window again later to see deliveries that settled since.

GET /b2b/report?from=2026-09-01&to=2026-10-01 — settlement per currency × day of settlement (UTC): bets, distinct players, wagered, payout, GGR, plus per-currency subtotals. &format=csv for a file. from and to are both required, ISO dates or date-times, from included and to excluded — the example is the whole of September. Otherwise 400 BAD_RANGE; there are no preset windows. At most 90 days (RANGE_TOO_WIDE) and 20,000 rows, with truncated: true when cut — narrow the window. The response is { operatorId, window: { from, to, label }, rows, subtotals, truncated }; each row is { currency, day, bet, player, wagered, payout, ggr } and each subtotal the same without day and player; the CSV has the same seven columns — see ReportResponse in the OpenAPI. The report counts completed rounds only, so it can lag, or miss a round interrupted by a crash: reconcile money on /b2b/settlements and /b2b/bet/:id.

⚠️ There is never a grand total across currencies. Adding euros and dollars produces a number that exists nowhere, and it is worse than no number because it looks like an answer.

GET /b2b/room/:id/status — whether one of your rooms can take real money now, without waking it: status, active, recoveryRequired, quarantineUntil, and realMoney: { ready, issues }, where issues holds the readiness checks of /ready plus ROOM_RECOVERY_REQUIRED, ROOM_QUARANTINED and ROOM_NOT_ACTIVE. status is the room's switch: ACTIVE, PAUSED by the kill-switch of §8, or ARCHIVED once retired. active says whether the room is running in memory right now: a room sleeps when nobody plays and wakes on the next launch or connection, so status: "ACTIVE" with active: false is normal, and realMoney.ready can be true with it. It reports operational eligibility, not regulatory approval.

tables lists the room's tables in number order; a room without tables (§1b) has one, itself. Each entry is { id, number, active, usable, closed, retiring, recoveryRequired, quarantineUntil }: active as above; usable = it would take a player now, by the same rule as the launch; closed = taken out of the seating by us, it empties and goes to sleep; retiring = in the last rounds of its hash chain, it takes no new players and then retires; recoveryRequired and quarantineUntil as for the room. The room's own recoveryRequired and quarantineUntil sum up its tables: recovery when no table is usable and one is held, quarantine when every usable table is in quarantine. Tables that finished their chain are not listed: retiredTables counts them, and GET /fair?room= keeps their history (§9).

8. Per-operator configuration

Configured by us, per room, at onboarding and on request:

Setting Where Notes
id registry your operator id: letters, digits, - and _, at most 64 characters
secret registry the shared HMAC secret (§6.1), at least 16 characters; we generate 64 hex characters and hand it over out of band
currency registry (one per operator) a currency code we know; EUR if omitted. Codes with more than two decimals (BTC, ETH, KWD…) or unknown codes are refused; zero-decimal currencies (JPY, KRW) are accepted, and the room limits are then set in that currency
allowedOrigins registry the lobby origins allowed to embed real-money rooms (§5.1); each entry exactly https://host[:port], no path; invalid entries are dropped one by one
enabled registry true by default; false = operator suspension (kill-switch below). A value that is not a boolean — "false" in quotes — also suspends
ipAllowlist registry exact IPs or IPv4 CIDR blocks allowed to call /b2b/*; IPv6 by exact match only; empty = no filter
walletTimeoutMs, walletRetries registry 4000 ms / 1 by default; a positive number / an integer 0–5 (§6.3)
limits.maxBet, limits.maxWin, limits.maxExposure registry positive numbers; the lower of yours and the room's applies (the room's default when it sets none), so a registry value can only tighten
eventsWebhookUrl registry https (http only on loopback); where the onboarding_at_risk event goes (§8c)
minBet, maxBet room the client reads them at every connection
RTP room default 97 %, configurable; one parameter, no re-tuning of the games
maxWin room hard cap on the payout of one position, in your currency. Applied as an implicit take-profit (the position exits at the first tick that would reach the cap) plus a hard clamp; the effect on expected value is at most one tick, only on positions that hit the cap
maxExposure room per table in a room with tables (§1b), so the room's worst case is N × maxExposure. Cap on the worst-case liability per round (sum over active positions of min(stake × capMultiplier, maxWin), with capMultiplier the configured cap from the catalog); a stake that would exceed it is refused with EXPOSURE_CAP before your wallet is called. It always covers at least one maximum stake, min(maxBet × capMultiplier, maxWin) with the effective limits: below that the room would refuse its own maximum stake at an empty table, so we refuse such a configuration when the room is created or changed — also when the binding cap is your registry limits.maxExposure
feature flags room auto bet (off by default in operator builds); the first-visit guided tour and its hints (on by default; when off, the in-game ? help stays). The client reads them at every connection

Kill-switch (implemented, operated by us from the console). Two levels:

  • Room pause — the room is set to PAUSED; launches answer 503 ROOM_INACTIVE, open positions are refunded immediately through your /rollback, and connected players are disconnected with WebSocket code 4503 (Appendix A). Setting it back to ACTIVE needs no further action.
  • Operator suspension (enabled: false in our registry) — every room of yours in one gesture: launches and signed reads answer 403 OPERATOR_SUSPENDED, new stakes are refused with MAINTENANCE, open positions can still be cashed out, so nobody's money is left halfway, and the game token is not renewed, on reconnection or during the session: the session ends at its original expiry.

Settlement export (implemented) — GET /b2b/settlements, the durable ledger view for batch reconciliation (JSON or CSV): see §7b.

On the roadmap: per-operator theming.

8b. Promotions — what exists today, and the player exclusion that comes first

Free bets exist today in demo rooms only. A welcome free bet is offered on the player's first connection to a room, the game shows a «Free bet» chip next to the stake chips, and the bet goes through the normal bet/win path with the stake credited by us — so cash-out, refunds and the round ledger are exactly the same as for a paid bet. The operator-facing API (campaigns and rewards over the signed /b2b/promo/* surface, with isFree marks on /bet and /win) is the next cycle, and will be shaped on your spec. Until then a real-money room never offers a free bet, and a stake that claims one there is refused with PROMO_INVALID before your wallet is called. What ships first, on purpose, is the switch that every promotion must obey:

Endpoint Effect
POST /b2b/promo/players/:player/opt-out?operator=<id>&optOut=0|1 sets the player's promo opt-out flag; the row is created if the player never launched
  • Signed like the reads (§7b), with method POST and no body — and no content-type header: a content-type: application/json on an empty body is a 400 from our HTTP layer before the signature is even read. optOut is part of the signed query, so a signature for optOut=1 is not valid for optOut=0.
  • player is printable ASCII without spaces, at most 128 characters (as in the launch). The response is { operatorId, player, promoOptOut }. Each call writes an entry in our audit log (promo.opt_out, before/after, attributed to your operator), exactly like the same action taken from our console on your written request. A retry of the same signed call (same IP, within two minutes) gets the first response and writes nothing (§7b); if the first one is still running past 15 seconds it answers 409 REPLAY_IN_PROGRESS.
  • Use it to mirror your own harm indicators (UKGC SR Code 3.4.3, requirement 10). It already applies to the welcome free bets of your demo rooms — an excluded player is offered none and cannot claim any — and every promotion that reaches real money will obey it from day one.
  • Bonus balances, wagering requirements and withdrawal rules stay on your side: when free bets reach your wallet they will be marked on /bet and /win (a promo reference and an isFree flag), never accounted by us.
  • On the wire, in the rooms where the layer is active: HELLO_OK.promo.rewards[] (rewardId, campaignId, name, betValue, maxWin, quantityLeft, endsAt; the field is absent where there is no promo layer), PLACE_BET.promo { rewardId } with amount === betValue, PROMO_UPDATE { rewards } after a free bet is accepted, and BET_REJECTED with PROMO_INVALID when the reward is missing, spent, expired, opted-out or not valid in that room. A free bet's payout is capped by min(room.maxWin, reward.maxWin).

8c. The early-churn signal — onboarding_at_risk

Players who never cash out in their first bets leave and do not come back. The RGS does not fix that with a bonus of its own on your players (your money, your licence, and in the UK an incentive tied to harm indicators is forbidden: SR Code 3.4.3, requirement 10). It raises a signal, and you decide:

  • Trigger: a new player (no bet on record) has closed N bets without a single cashout. N defaults to 5. The trigger reads only bet outcomes (cashed out or not), never amounts, losses or balance. Once per session.
  • In the game: a non-blocking tip («cash out early, around 1.2×»; 1.5× in Hypnofrog), and the onboardingAtRisk post to your lobby (no data).
  • To your backend (optional): declare eventsWebhookUrl (https) in your operator config and the RGS POSTs, once, with no retry — only for sessions opened through /b2b/launch on a room bound to your operator, with player = your id from the launch:
{ "event": "onboarding_at_risk", "roomId": "<room>", "player": "<your player id>", "bets": 5, "at": "2026-09-13T20:00:00.000Z" }

roomId is the room you launched, also in a room with tables (§1b): never a table id.

Headers: content-type: application/json, x-nerve-event: onboarding_at_risk, x-operator, and the signature of §6.1 (x-timestamp, x-signature) with direction request, method POST and the logical path /events, whatever your webhook URL is — the code that verifies /bet verifies this. Same timeout as the wallet calls. A 2xx is enough, and the response need not be signed; anything else is logged on our side and not resent: it is a CRM signal, not an obligation.

  • Excluded players (§8b opt-out) still receive the in-game tip: it is not a promotion.

8d. Ending a player's sessions — POST /b2b/players/:player/sessions/revoke

Since 13 October 2026. Ends every game session of the player at once: the player's open sockets close with 4401 (the game shows that the session has ended and offers the way back to your lobby), and every game token issued to the player up to that instant — the launch token and its renewals alike, which carry the first token's iat — is refused from then on, at the handshake and on /me/*. A launch made after the call issues a fresh token that works.

Endpoint Effect
POST /b2b/players/:player/sessions/revoke?operator=<id> ends the player's sessions; answers { operatorId, player, revokedAt, closed }
  • Signed like the reads (§7b) and the opt-out (§8b): method POST, the path with its query, no body and no content-type header. player is printable ASCII without spaces, at most 128 characters, exactly as in the launch. revokedAt is in epoch seconds: a token issued at or before that second is refused; closed is how many open sockets were closed.
  • Use it when you close or block the account, when a self-exclusion lands, or when a gameUrl has been shared where it should not have been: until this endpoint existed a leaked link lived until its hour ran out.
  • The revocation lives in the memory of the game process (one replica per service, like the anti-replay window of §4): a restart forgets it, and a token issued before the restart would be accepted again within its own one-hour lifetime. That is the residual risk, stated on purpose.
  • Each call writes an entry in our audit log (session.revoke, attributed to your operator, with the player and the number of sockets closed). A retry of the same signed call (same IP, within two minutes) gets the first response and writes nothing.

9. Fairness and audit

  • Provably fair. Each table commits to its own SHA-256 hash chain before its first round; a room without tables (§1b) is one table. GET /fair?room=<room> returns { chainTerminus, clientSeed, generatorVersion, nonce, persistent } for the room's first table: the published end of the chain, the room's client seed (a fixed text, or a drand round: drand:quicknet:<round>:<signature>, see Provably fair), the generator version, the current round number, and persistent: true when the room's rounds are persisted, so GET /fair/<room>/<nonce> returns every input of any past round: the seed and its commitment, the configuration and client seed of that round, its outcome, its positions (by betId — the one your wallet saw on /bet, which starts with the table id — with no player identity) and verificationPage, the path of the HTML page. Each round's server seed is committed as a hash before betting opens and revealed when the round ends. The same calls take a table id: GET /fair?room=<room>--t2, GET /fair/<room>--t2/<nonce>.
  • Every table, listed. GET /fair?room=<room> also returns tables, one entry per table of the room in number order, { id, number, chainTerminus, clientSeed, createdAt, retiredAt }, and tablesNext. A room's tables are created before they are needed, with their chain end already published, and used in the order they were created: createdAt lets anyone check that a table existed, with its chain end, before the rounds it played — we cannot pick a favourable chain for the players who move. The list grows as tables finish their chains and is never pruned, so it comes in pages of 50: tablesNext is the number to pass as &tablesAfter= for the next page, null on the last. A room without tables lists one, itself.
  • What anyone can check, and what needs the generator. With nothing but SHA-256, anyone can check that a revealed seed matches the hash committed before betting (check 1) and that it belongs to the chain published before the first round (check 2); the client shows both after every round. A seed is 64 lowercase hex characters, and SHA-256 is applied to that text, not to the 32 bytes it spells: printf %s "$seed" | sha256sum prints the round's commitment. Re-deriving the outcome from the seed (check 3) needs our generator: the page GET /fair/<room>/<nonce>/verifica runs all three for any persisted round, and the engine source is available to testing laboratories and partners on request.
  • Client seed. Each table has one client seed, fixed when the table is created and published with its chain. It is not chosen per round or by players: every round's outcome is fixed by the server seed, committed before any bet.
  • Same math in demo and real money: same generator, same version, by specification.
  • Command ledger. Every player command of a round — accepted and refused, with receive timestamp and the epoch used for pricing — is stored append-only, and is verifiable against the revealed seed (the server cannot price a cash-out at an epoch later than the click).
  • Generator versions are in the catalog and in /fair; a change of generator is a new version, never a silent edit.

10. Compliance notes

  • Responsible gaming — your platform is the primary source. Deposit limits, self-exclusion and your own session/loss limits are enforced by you: refuse the /bet with PLAYER_BLOCKED or LIMIT_EXCEEDED and the player sees why. We hold no player contact data and run no player communication.

  • Responsible gaming — what the RGS enforces on top, per room and configured by us on request (rgRealityCheckMin, rgSessionMaxMin, rgLossLimit; 0 = deliberately off):

    default in operator builds
    reality check every 60 min of active play, on — in operator builds a dialog (REALITY_CHECK { sessionMin, last24hMin, net, ack: true }) shown at the end of the round if a position is open; until the client answers RG_ACK, new stakes are refused with REALITY_CHECK. Open positions can always be cashed out
    session cap 240 min of active play, on — beyond it new stakes are refused with LIMIT_EXCEEDED; the connection stays up and open positions can always be cashed out
    loss cap configurable, not set by default (it is an amount, and an amount has no sensible default without knowing the currency)

    Time is active play, counted by the server: minutes with the socket attached and the page visible (the client reports PRESENCE { visible } on every visibility change), credited from the server heartbeat, per player and across rooms. A session is continuous play: a 30-minute absence ends it and the next presence starts a new one — the reality check, the session cap and the net start again. If your launch carries a sessionId, a different value also starts a new session; a reconnection within the same one continues it. The last 24 hours of active play are kept per player (PlayTime, hourly buckets, written every five minutes and on the last disconnect, kept 30 days), so they survive reconnections and redeploys. The session P&L lives in process memory and restarts at zero after a redeploy — stated here rather than hidden. The client receives the resolved values on every connection (HELLO_OK.responsible: sessionMin, last24hMin, sessionNet, caps), gets RG_INFO when they change outside a HELLO, shows session time, last 24 hours, net and minutes left in the settings sheet, and links the responsible-gaming page you give us (configured once per installation, and reachable from the lobby through openResponsibleGaming on the bridge, §5.3).

  • Data we hold per player: your pseudonymous id, the public alias, first-bet and last-seen times, the promo opt-out flag you set (§8b), and the game history needed for fairness and dispute handling.

  • No simulated activity in real-money rooms: demo rooms carry scripted [BOT] chatter and [BOT] demo participants, real-money rooms carry neither (server-side, not a setting you have to remember); other players' stakes are visible in the shared feed (this can be turned off per room on request).

  • Room chat: the server masks profanity, insults and slurs (English, Spanish, Italian) before any other player reads them. A word filter, not moderation.

11. Certification checklist (run on staging before go-live)

Expected results are exact; "signed" means x-timestamp and x-signature present and valid.

# Test Expected
1 Launch with a valid signature and all fields 200, alias = your displayName, gameUrl opens the right game in lang
2 Launch with a wrong signature · stale x-timestamp · x-timestamp: abc · another operator's room · unknown operator · paused room 401 BAD_SIGNATURE · 401 STALE · 400 field x-timestamp · 403 · 404 · 503 ROOM_INACTIVE
3 Open gameUrl in your lobby iframe game loads, gameReady received; /balance called once, signed; balance shown equals yours
4 Place a stake /bet received, signed, roundId = <room>:<nonce> (<table>:<nonce> in a room with tables, §1b); balance shown decreases
5 Cash out /win with amount > 0; balance shown increases
6 Let a position lose /win with amount 0
7 Cancel a stake during the betting window /rollback; stake refunded
8 Refuse /bet with each reason of 6.6, as a signed 4xx player sees the translated message; no debit; no rollback
9 Make /bet hang (> 8 s) we retry once at 4 s, and /rollback for that betId arrives when the retry times out too: about 8 s after the stake with the default settings (6.3). Your late /bet must answer 409 ROLLED_BACK
10 Answer /bet with 500 · an unsigned 200 · a 200 signed with the timestamp of an earlier request /rollback arrives immediately in all three cases
11 Answer /win with 500 for a minute, then 200 retried with backoff, credited once (idempotent)
12 Send /rollback for a settled betId yourself your side answers 409 ALREADY_SETTLED
13 Refresh the iframe within the hour same session, open positions intact, no new launch
14 Wait past the token TTL without reconnecting, then reconnect 4401, "session expired" screen, sessionExpired on the bridge
15 Embed from an origin not in allowedOrigins blank iframe (CSP)
16 Stake below minBet / above maxBet / that would exceed maxWin or maxExposure the game's stake controls stay within the room's limits, and what gets past them is refused by the server before your wallet (INVALID_AMOUNT, EXPOSURE_CAP): wallet never called
17 Launch the demo room realMoney: false, "DEMO" note visible, no wallet callback
18 Open the same gameUrl in a second tab the first tab closes with 4409 and offers "Play here"; no reconnection loop, no burst of /balance
19 Launch the same player again an hour later a new responsible-gaming session: the clock in the settings sheet starts from zero
20 Play 50 rounds and reconcile with /b2b/settlements every /bet has exactly one /win or /rollback; balances match
21 Only for a room with tables (§1b): play until stakes land on table 2 their roundId and betId start with <room>--t2; /b2b/round/<room>--t2:<nonce> answers with your bets; /b2b/room/<room>/status lists both tables; reconciliation as in 20, table ids included

12. Go-live checklist

  • Production secret, room ids and walletBaseUrl (https) exchanged and configured.
  • For a room with tables: your written confirmation of §1b — the table ids and the worst case of N × maxExposure.
  • Production lobby origins in allowedOrigins.
  • GET /health on the production base URL answers env: "prod".
  • The staging checklist (§11) passed in full, with the log kept.
  • An escalation contact on both sides for settlement disputes and outages.
  • Your monitoring alerts on callback failures (5xx, signature mismatches) on your side.

Appendix A — codes

Launch: see §4. WebSocket close codes (RFC 6455 application range; what the client does next in brackets):

code meaning client
4401 invalid or expired token on a room that requires one, or a token from the other environment shows "session expired", emits sessionExpired, does not retry
4403 foreign Origin — the page is not served from our host (integration error) does not retry
4409 the same player opened the game in another tab or device, and this one lost its seat shows "open in another window" with a "Play here" button that reloads, does not retry
4429 too many upgrades from one IP (120/min), or a socket that keeps sending far past its message budget (600/min) retries slowly (20 s, growing)
4004 room not found — also a failed wake, e.g. a database blip retries slowly
4503 room paused: kill-switch (§8) or quarantine after a fault on our side; open positions are refunded before the close. In a room with tables (§1b), also every table full (after a TABLES_FULL message): the client shows the same screen retries slowly
4302 the player was moved to another table of the same room (§1b) reconnects at once, shows nothing

Refusals the player may see, besides your own reasons (§6.6): INVALID_AMOUNT, MAX_BETS, NOT_LIVE, NOT_READY, EXPOSURE_CAP, INVALID_TARGET (Hypnofrog: the chosen fly is not in the round, or no longer alive), PROMO_INVALID (§8b), MAINTENANCE (maintenance window, operator suspended, or our readiness checks failing: new stakes only, cash-outs stay free), LIMIT_EXCEEDED (our session or loss cap, §10), WALLET_DISABLED (the room's operator is missing from our registry — configuration error on our side), and BETTING_CLOSED / ROUND_CLOSED. The last two can also arrive after your /bet answered 200: the round moved on while we waited for your wallet, and we refund the stake with /rollback (§7).

Appendix B — glossary

Term Meaning
Room what you launch: one game, one operator, one currency, played on one table or more (§1b)
Table one running game of a room, with its own hash chain and rounds; table 1 has the room id, the others <room>--t<n>
Nonce the round counter of a table; roundId = <table>:<nonce>
Epoch God Candle's 250 ms price step; cash-outs are priced at the epoch of the click
Take profit / stop loss server-side exit orders on a position (stop loss in God Candle only)
Rug / hit / eaten / stomped the losing outcome in God Candle / Diamond Hands / Big Fish / Hypnofrog (a tongue takes the fly, or the frog's frenzy ends the round; STOMPED keeps the event's original name)
Fly switch Hypnofrog: moving a live position to another live fly, free, up to 3 times per position, one expiring every 8 beats; recorded in the command ledger
Game token the one-hour signed session credential in the gameUrl fragment
Signature v2 x-timestamp + x-signature, the HMAC-SHA256 of direction, method, path, timestamp and body; a response also signs its status and the hash of the request body (§6.1)
Logical path the path signed on calls to you: /bet, /win, /rollback, /balance, /events, independent of your URL prefix