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/reportanswers withwindow,rows,subtotals,truncatedand rows keyedcurrency/day(§7b),/b2b/round/:idwithoutcome,totalsandfairness.publicPath, and/fairwithpersistent,outcome,positions,switches,exit,midRoundandverificationPage(§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 thex-timestampheader, and version 1.0 signatures are refused. Also new: close code4409when 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
- We give you, per environment: a base URL, an
operatorid, a sharedsecret, and oneroomid per game and per mode (real money, and optionally a free demo). - You give us: your
walletBaseUrl(https), your currency, and the origins of the lobby pages that will embed the game (allowedOrigins). - Implement the four wallet callbacks (§6) under
walletBaseUrl. - On "play": call
POST /b2b/launch(§4) from your backend and put the returnedgameUrlin an iframe (§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
playerid 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:
- The launch does not change: same
roomfield, same token, samegameUrl. One new refusal,503 ROOM_FULLwithRetry-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. - Ids carry the table. Table 1 keeps the room id; from table 2 the id is
<room>--t<n>.--tis reserved: no room id contains it. AroundIdis<table>:<nonce>, abetIdstarts with the table id, androomIdin the rows of/b2b/betand/b2b/settlementsis the table. Remove--t<n>to get the room./b2b/round/:idanswers for every table of your rooms. Webhook events (§8c) carry the room you launched, never a table. - 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. - Status and fairness list the tables.
GET /b2b/room/:id/statusreturns the room's tables (§7b), andGET /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
roomid does not exist in production: the launch answers403 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
gameUrlas 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 orRefererheaders. The query carries onlyroomandlang: yourlobbyUrltravels inside the token. - The token is signed, not encrypted. Whoever holds the
gameUrlcan read what the token carries — your operator id and the player's id, the alias, the room,lobbyUrl,sessionIdand the expiry — and cannot change any of it. Treat agameUrllike a session cookie: it belongs in that player's browser only. aliasis the public name other players will see:displayNameafter cleaning (control characters removed, whitespace collapsed, 24 characters max), or a name we generate. Yourplayerid is never shown to anyone.realMoneyisfalsewhen 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
sandboxattribute withoutallow-same-originandallow-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://localhostcannot 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 carriesX-Frame-Options: SAMEORIGIN. - To relaunch, replace the iframe. A new
gameUrlfor 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: settingsrcagain leaves the game on its old token. Remove the iframe element and create a new one with the newgameUrl. - The WebSocket is opened by our page, never by yours: an upgrade whose
Originis not our host is closed with4403. Per IP, 120 upgrades per minute are allowed (4429above 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-originand, over https, HSTS.
5.2 lang and lobbyUrl
langopens 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.lobbyUrlturns 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).sessionIdis your own session id for the player, if your lobby has one. It is not an identity (that staysoperator: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
lobbyUrlon its origin, or relay the events from it. - "Back to the lobby" posts
exitand then navigates the iframe tolobbyUrl. Listen forexitand 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) |
amountandbalanceare JSON numbers in major units of your currency (12.5is €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.currencyis always the one configured for you.playerIdis your rawplayerid from the launch.roundIdis global:<roomId>:<nonce>(e.g.acme-gc-eur:1042), identical on every call of that round.betIdis globally unique and carries the room id first; treat it as an opaque string of up to 256 characters. Idempotency is onbetId.multiplier(/winonly): the multiplier the position closed at, relative to entry, with four decimals and the same value on every retry (1.0is break-even before the house edge; God Candle can close below1.0), or0on 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 inGET /b2b/settlementsandGET /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).amounton/winis not always stake ×multiplier. It isstake × 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'smaxWin, wherefis1for a position opened in the betting window and the room's RTP (0.97by 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 Fishfis always1, 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 withmultiplier: 1.5andamount: 14.55(10 × 1.5 × 0.97). Reconcile onamount; rank onmultiplier.
6.3 What we require from your implementation
- Idempotency on
betIdfor/bet,/winand/rollback: a repeated call returns the same result and moves no money twice. /winwithamount: 0closes a lost bet. Accept it like any win (idempotent). Every accepted/betreceives exactly one closing/win(won or lost) or one/rollback; your ledger never has an open bet at the end of a round./rollbackof a settled bet →409 ALREADY_SETTLED. A bet already closed with/win(includingamount 0) is never refunded./rollbackof abetIdyou have never seen →200, and remember it. A/betwith thatbetIdarriving later must be refused with409 { 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/rollbackcarries no player, so there is no balance to report: answer200 { "balance": 0 }. Thebalanceof a/rollbackanswer is never used for accounting, and a0is never shown to the player.- 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). - Idempotency that holds under concurrency. The same
betIdcan reach you twice almost at once: our retry after a timeout while your first answer is still on its way, or a/rollbackracing a late/bet. Enforce it with a unique key or a lock onbetId, not with a read followed by a write. - No redirects. We do not follow
3xx: a redirect is an ambiguous answer (6.4), and on/betit triggers a rollback. - Expect bursts at the end of a round. Every open position is closed at once; our
/wincalls,amount: 0included, go through a queue with at most 32 in flight per server./bet,/rollbackand/balancefollow the players' actions. - Never credit a
/winfor abetIdyou did not debit, or rolled back. By design we never send one: a refund claim excludes any later credit for the samebetId(§7). If one reaches you, answer a signed409with areasonof your choice (UNKNOWN_BET, say) and alert your team. Like any/winwithout its200, it is retried (6.5) and ends flagged for manual settlement, where both sides reconcile it withGET /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
/winobligation before the player sees the confirmation and before the round result is published. A crash after that write leaves the/winin 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 samebetId. Your idempotency onbetIdstays 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
/rollbackbefore 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 answersROOM_RECOVERY_REQUIREDonly 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 becomeFAILEDand orphan stakes are marked exhausted, both withOPERATOR_NOT_REGISTERED, until we restore the entry and reopen them.Reconciliation. Every debited
betIdis meant to end with a/win(any amount) or a/rollback, and retries reuse the samebetId(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 /betis 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
/rollbackand the player seesBETTING_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 /betlatencybet → 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:
- 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.
- Your wallet's latency hits three separate paths.
/betis synchronous and shortens the window;/winis deferred, so the acknowledgement follows our settlement but the balance figure lags;/rollbackis 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 yourbalancewhen your/winanswers: while a/winis being retried (6.5) the game is ahead of your wallet, and a reload, which reads/balance, shows your figure until the retry lands. - 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
/betunder 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 answer503 ROOM_INACTIVE, open positions are refunded immediately through your/rollback, and connected players are disconnected with WebSocket code4503(Appendix A). Setting it back toACTIVEneeds no further action. - Operator suspension (
enabled: falsein our registry) — every room of yours in one gesture: launches and signed reads answer403 OPERATOR_SUSPENDED, new stakes are refused withMAINTENANCE, 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
POSTand no body — and nocontent-typeheader: acontent-type: application/jsonon an empty body is a400from our HTTP layer before the signature is even read.optOutis part of the signed query, so a signature foroptOut=1is not valid foroptOut=0. playeris 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 answers409 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
/betand/win(a promo reference and anisFreeflag), 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 }withamount === betValue,PROMO_UPDATE { rewards }after a free bet is accepted, andBET_REJECTEDwithPROMO_INVALIDwhen the reward is missing, spent, expired, opted-out or not valid in that room. A free bet's payout is capped bymin(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
Nbets without a single cashout.Ndefaults 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
onboardingAtRiskpost 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/launchon a room bound to your operator, withplayer= 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 nocontent-typeheader.playeris printable ASCII without spaces, at most 128 characters, exactly as in the launch.revokedAtis in epoch seconds: a token issued at or before that second is refused;closedis how many open sockets were closed. - Use it when you close or block the account, when a self-exclusion lands, or when a
gameUrlhas 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, andpersistent: truewhen the room's rounds are persisted, soGET /fair/<room>/<nonce>returns every input of any past round: the seed and its commitment, the configuration and client seed of that round, itsoutcome, itspositions(bybetId— the one your wallet saw on/bet, which starts with the table id — with no player identity) andverificationPage, 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 returnstables, one entry per table of the room in number order,{ id, number, chainTerminus, clientSeed, createdAt, retiredAt }, andtablesNext. A room's tables are created before they are needed, with their chain end already published, and used in the order they were created:createdAtlets 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:tablesNextis the number to pass as&tablesAfter=for the next page,nullon 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" | sha256sumprints the round's commitment. Re-deriving the outcome from the seed (check 3) needs our generator: the pageGET /fair/<room>/<nonce>/verificaruns 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
/betwithPLAYER_BLOCKEDorLIMIT_EXCEEDEDand 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 answersRG_ACK, new stakes are refused withREALITY_CHECK. Open positions can always be cashed outsession 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 outloss 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 asessionId, 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), getsRG_INFOwhen 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 throughopenResponsibleGamingon 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,roomids andwalletBaseUrl(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 /healthon the production base URL answersenv: "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 |