DocsHTTP API

Demo Game Endpoints

The session, seed and practice-chip routes under /api/demo in Galabet's demo API, in the order a client calls them, from opening a session to checking its history after rotation.

These are the routes behind the free games: a session that holds a committed server seed, a way to draw results from it, chip games that stake practice credits on those results, and a rotation that reveals the seed so everything can be checked. Chips have no value and can't be bought or withdrawn.

The API isn't deployed. Everything here runs against http://localhost:3000 on a machine with the repository, Postgres and Redis, as Self-hosting the API describes, and every session, balance and record on this page lives in Redis. None of the requests below was sent. The bodies and responses are read from the controllers and services, and where a JSON sample has numbers in it, they came from calling the API's own rule functions and services directly. Windows users should read curl on Windows first.

If you only want to play, the game pages are these routes with buttons on. Galabet Flight is a separate endpoint with its own credits and shares nothing with this one.

Successful POSTs answer 201, the NestJS default, except the Blackjack analysis, which sets 200. Errors have the shape described under Status Codes on the verify page.

1. Start a Session

Needs the API, Postgres and Redis
curl -s -X POST http://localhost:3000/api/demo/session
Response shape. Values are illustrative
{
  "id": "0b6f6f0e-5c0a-4a53-9d0e-2f1c5a7e9b11",
  "commitment": "ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7",
  "clientSeed": "9f2c4e6a8b0d1f3a5c7e9b1d3f5a7c9e",
  "betsUnderSeed": 0,
  "createdAt": 1790000000000,
  "nonce": 0
}

No body and no account. The server makes a server seed, keeps it, and returns its commitment with a random client seed of 32 hex characters. nonce is the one the next bet will use.

Send this request as written, with no content-type. Fastify answers 400 Body cannot be empty when content-type is set to 'application/json' to a JSON request with nothing in it, and that applies to every bodyless POST on this page: session, rotate, refill, and the Mines cash-out and forfeit. We checked that against the Fastify version the API installs, 4.29.1, on its own.

id is the whole credential. Send it as an X-Demo-Session header on everything else, and treat it as a password for the day: whoever holds it can spend the chips and rotate the seed. It has to be a UUID, or the answer is 400 X-Demo-Session header must be a session id. A well-formed id the server doesn't know gets 404 demo session not found or expired. A session whose nonce counter has gone missing in Redis is refused with 409 session nonce missing; start a new demo session, and the message means it: the server won't guess a nonce, so that session can't be played again.

Keep the id in a variable for the rest of the page
SESSION=$(curl -s -X POST http://localhost:3000/api/demo/session | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).id")
curl -s http://localhost:3000/api/demo/session -H "x-demo-session: $SESSION"

GET /api/demo/session returns the same view at any time, with the live nonce. The server seed is never in it.

Sessions last 86,400 seconds by default (DEMO_SESSION_TTL), counted from the last request that loads the session. A bet, a seed change and a rotation do, and so do GET /api/demo/session and the history. Each pushes the expiry out for the seed, the nonce counter, the revealed seeds, the records and an open Mines board together. The chip balance keeps its own expiry, renewed by stakes and payouts. One address can create 30 sessions in a 24-hour window (DEMO_SESSIONS_PER_IP_PER_DAY), and the window opens at the first of them, not at midnight. After that the route answers 429 too many demo sessions from this address today. Refused attempts still count.

2. Choose a Client Seed

Optional. The generated one is fine, and the point of the route is that a player who doesn't trust it can replace it.

curl -s -X POST http://localhost:3000/api/demo/client-seed \
  -H "content-type: application/json" -H "x-demo-session: $SESSION" \
  -d '{"clientSeed":"galabet"}'

The body is { "clientSeed": string }, 1 to 64 characters, nothing else. The response is the session view again, and three fields in it have changed: clientSeed, commitment, and nonce, which is back to 0. A new client seed always comes with a new server seed. If the old server seed stayed, a player could switch back to a client seed they had used before and watch results they had already seen come round again. Client Seeds has the longer version.

If any bets were placed under the old server seed, it is revealed at this moment, though not in this response. It shows up attached to those records in the history. Sending the seed the session already has changes nothing and returns the view.

3. Play

There are two ways to get a result out of a session, and they share one nonce counter.

/bet Draws a Result and Nothing Else

curl -s -X POST http://localhost:3000/api/demo/bet \
  -H "content-type: application/json" -H "x-demo-session: $SESSION" \
  -d '{"game":"blackjack","params":{"decks":1}}'

game is one of the nine seed-based names and params is the same strict object the verify route takes, defaulting to {}. No stake, no chips, no win or loss. The answer is { "record": { ... }, "nonceNext": 1 }, where record is a GFS record without serverSeed, and it goes into the session history.

This is the route a card game would use: it hands over a shuffled deck for the browser to play. The site's own Blackjack and Hi-Lo pages don't call it; they work the deck out from the public seed. A client that did use it would have the whole deck from the first card, which is fine for a practice table and is the reason the last section of this page exists.

Chip Games

Everything under /api/demo/games stakes chips. A session starts with 1,000 (DEMO_START_CHIPS), created the first time anything asks about them.

curl -s http://localhost:3000/api/demo/games/chips -H "x-demo-session: $SESSION"
curl -s -X POST http://localhost:3000/api/demo/games/chips/refill -H "x-demo-session: $SESSION"

Both answer { "chips": 1000 }. Refill sets the balance to the starting stack whatever it was, so a player sitting on 5,000 who presses it loses 4,000. It's limited to 3 a minute.

Six games settle in one request. Each takes amount, a whole number from 1 to 100,000, plus its selection, and nothing else: the objects are strict.

RouteBody besides amountRecord paramsResponse besides the common fields
POST /dicetarget 2 to 98 in steps of 0.01, over boolean{}roll, win, multiplier, chance
POST /limbotarget 1.01 to 1,000,000 in steps of 0.01{ houseEdge: 0.01 }result, win
POST /roulettebet, see below{}pocket, win
POST /wheelnone{ segments: 10 }segment, multiplier
POST /plinkorows: 8, 12 or 16{ rows }path, bucket, multiplier, table
POST /kenopicks: 1 to 10 distinct numbers from 1 to 40{ draws: 10 }drawn, hits, multiplier

The common fields are record, payout, chips (the balance afterwards) and settlement.

A Roulette bet is one of six objects: {"type":"straight","number":0..36}, {"type":"color","color":"red"|"black"}, {"type":"parity","parity":"odd"|"even"}, {"type":"range","range":"low"|"high"}, {"type":"dozen","dozen":1|2|3} or {"type":"column","column":1|2|3}. The total returns are 36, 2, 2, 2, 3 and 3 times the stake.

curl -s -X POST http://localhost:3000/api/demo/games/dice \
  -H "content-type: application/json" -H "x-demo-session: $SESSION" \
  -H "idempotency-key: dice-7b0c6c1e-0001" \
  -d '{"amount":25,"target":50,"over":true}'

Here is that response as it would be if the session happened to hold the public server seed, client seed galabet, nonce 42, and a fresh stack of chips. The numbers were produced by the API's own diceWin, diceMultiplier, diceChance and chipReceipt functions, called outside the server.

POST /api/demo/games/dice
{
  "record": {
    "spec": "GFS/1.0",
    "profile": "single-player",
    "game": "dice",
    "params": {},
    "commitment": "ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7",
    "clientSeed": "galabet",
    "nonce": 42,
    "cursor": 0,
    "result": 56.12,
    "at": 1790000000000
  },
  "roll": 56.12,
  "win": true,
  "multiplier": 1.9801,
  "payout": 49,
  "chips": 1024,
  "chance": 0.49995000497438014,
  "settlement": {
    "stake": 25,
    "multiplier": 1.9801,
    "returned": 49,
    "net": 24,
    "ruleVersion": "galabet-demo/2026-09-20.1",
    "selection": { "target": 50, "over": true }
  }
}

The Settlement Object

The record says what the seeds produced. It has no idea a bet was placed on it. settlement is the other half: stake, the multiplier applied, returned (whole chips, rounded down), net, the ruleVersion the payout table belongs to, and the player's selection. Keno adds hits. Wheel has no selection and carries configuration in its place. A client that wants to let a player re-check a payout later has to keep both objects, because 56.12 alone can't tell anyone whether the bet was over or under. Settling Bets explains the split.

payout is always the same number as returned, and both come from one function, chipPayout in the demo's rules. It takes the multiplier as a whole number of ten-thousandths, multiplies by the stake and divides with integer arithmetic. The reason is floating point. Math.floor(100 * 2.01) is 200, because 2.01 is stored as a shade under itself, and a Limbo win at 2.01 on a stake of 100 would lose a chip. chipPayout returns 201. Every multiplier in the demo has four decimals or fewer. One with more makes chipPayout throw multiplier must have at most four decimals rather than round it.

For Wheel, Plinko and Keno the receipt is always marked as won and a losing round is a multiplier of 0. GET /api/demo/games/rules returns the tables behind every multiplier, with each game's computed return, under the same version string. It needs no session.

Not enough chips is 402 not enough chips, and nothing is drawn: the debit comes before the nonce is reserved. Two requests for one session at the same moment don't both run. The second gets 409 another action is being processed; retry after it completes, and it is refused before its stake is touched.

Mines Takes Several Requests

GAMES=http://localhost:3000/api/demo/games

curl -s -X POST $GAMES/mines/start -H "content-type: application/json" -H "x-demo-session: $SESSION" -d '{"amount":25,"mines":3}'
curl -s -X POST $GAMES/mines/pick -H "content-type: application/json" -H "x-demo-session: $SESSION" -d '{"tile":12}'
curl -s -X POST $GAMES/mines/cashout -H "x-demo-session: $SESSION"
curl -s -X POST $GAMES/mines/forfeit -H "x-demo-session: $SESSION"
curl -s $GAMES/mines -H "x-demo-session: $SESSION"

start takes amount and mines (1 to 24), debits the stake and fixes the board. pick takes a tile from 0 to 24. cashout and forfeit take nothing. The GET returns the current game, which is how a reloaded page finds its board again, or 404 no mines game in progress. A pick, cashout or forfeit with no board gets the same 404.

All five return one view: amount, mines, picks, over, record, multiplier for the picks made so far, and next, the multiplier one more safe pick would bring, or null when the round is over or there's nothing left to pick. A finished board also carries outcome, which is "cashout", "mine" or "forfeit", and payout, and the GET keeps showing both until the next start. A pick adds boom. Any request that ends the round adds boom and payout to its answer: a pick that hits a mine, the pick that clears the last safe tile, cashout and forfeit.

forfeit gives the board up. The round ends as lost, the stake stays gone, the record is published to the history, and the seed routes work again. It needs no picks. This is what one returned when we ran the API's MinesService on the public inputs, with a 25-chip stake and tile 12 opened first. at is illustrative.

POST /api/demo/games/mines/forfeit
{
  "amount": 25,
  "mines": 3,
  "picks": [12],
  "over": true,
  "outcome": "forfeit",
  "payout": 0,
  "record": {
    "spec": "GFS/1.0",
    "profile": "single-player",
    "game": "mines",
    "params": { "mines": 3 },
    "commitment": "ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7",
    "clientSeed": "galabet",
    "nonce": 42,
    "cursor": 2,
    "result": [9, 17, 22],
    "at": 1790000000000
  },
  "multiplier": 1.125,
  "next": null,
  "boom": false
}

multiplier is still the one for the picks made, 1.125 for one safe tile, though nothing was paid on it. A cash-out of the same board answers in the same shape with "outcome": "cashout" and "payout": 28.

While over is false, record has no result. The board is the result, and the Mines page is about keeping it that way: the record stays out of the history until the round ends, and both seed routes refuse with 400 finish the active Mines board before changing or revealing seeds. One thing is missing from these responses that the other six games have, and that's the settlement object. A Mines payout comes with its multiplier and no receipt.

mines-ladder.mjs
// minesMultiplier and chipPayout from the demo's rules, copied out, with most of chipPayout's input checks left off.
// The multiplier has a 1% edge and is floored to four decimals.
function minesMultiplier(mines, picks) {
  let survive = 1;
  for (let i = 0; i < picks; i++) survive *= (25 - mines - i) / (25 - i);
  return picks === 0 ? 1 : Math.floor((0.99 / survive) * 10000) / 10000;
}
function chipPayout(stake, multiplier) {
  const scaled = multiplier * 10000, units = Math.round(scaled);
  if (Math.abs(scaled - units) > 8 * Number.EPSILON * Math.max(1, scaled)) throw new RangeError('multiplier must have at most four decimals');
  const total = stake * units;
  return (total - (total % 10000)) / 10000;
}

for (const picks of [0, 1, 2, 3, 22]) console.log(picks, minesMultiplier(3, picks), chipPayout(25, minesMultiplier(3, picks)));
Output
0 1 25
1 1.125 28
2 1.2857 32
3 1.4785 36
22 2277 56925

Picks made, the view's multiplier, and what a 25-chip stake would cash out for. Cashing out with no picks is refused (pick at least one tile first), so the first row is never paid. The other refusals are finish the current game first, tile already picked, game is over, and round already cashed out; repeat the cash-out to close the board, all 400.

The last of those belongs to a cash-out that paid and then failed before it could close the board. Redis already holds a marker with the chips paid and the picks they paid for, so a pick or a forfeit on that board is refused, and repeating cashout closes it. The repeat pays nothing more and reports the stored payout, not one worked out again from the board.

4. Rotate

curl -s -X POST http://localhost:3000/api/demo/rotate -H "x-demo-session: $SESSION"
Response shape
{
  "revealed": {
    "serverSeed": "5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d",
    "commitment": "ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7",
    "bets": 43
  },
  "next": { "id": "...", "commitment": "...", "clientSeed": "galabet", "betsUnderSeed": 0, "createdAt": 1790000000000, "nonce": 0 }
}

The old seed comes out, a new one goes in, the nonce returns to 0 and the client seed stays. bets is how many results were drawn under the revealed seed. With none drawn there is nothing worth revealing and the route says so: 400 nothing to reveal: no bets under this seed yet. It also refuses while a Mines board is live, because the seed is the board. Finish the board, or forfeit it, and rotation goes through. Rotation and Reveal covers why reveal has to wait for rotation at all.

Nothing here rotates on a schedule. A session that never calls this route never reveals its seed, and its records can't be checked.

5. Review the History

curl -s "http://localhost:3000/api/demo/history?limit=20" -H "x-demo-session: $SESSION"

An array of records, newest first. limit runs from 1 to 200 and defaults to 50, but the server keeps only the last 100 per session, so 100 is the real ceiling. Anything that isn't a number falls back to 50.

Every record whose server seed has been revealed, however many rotations ago, comes back with serverSeed added. The rest come back as they were written. That difference is the whole verification story in two objects:

history-entry.mjs
import { commit, play, verifyRecord } from '@galabet/fair';

const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const { commitment } = await commit(serverSeed);
const { result, cursor } = await play({ game: 'roulette', params: {}, serverSeed, clientSeed: 'galabet', nonce: 42 });

// As /history returns it before rotation. The server builds exactly these ten fields.
const before = {
  spec: 'GFS/1.0', profile: 'single-player', game: 'roulette', params: {},
  commitment, clientSeed: 'galabet', nonce: 42, cursor, result, at: 1790000000000,
};
console.log((await verifyRecord(before)).reasons);

// As /history returns it after rotation.
const after = { ...before, serverSeed };
const verdict = await verifyRecord(after);
console.log(verdict.ok, verdict.computed);
Output
[ 'server seed not revealed yet; verify after rotation' ]
true 20

Paste an entry from the second kind into the verifier, or post it to /api/verify/record, and it checks out with no further input. History holds records only. Stakes, selections and payouts aren't stored with them, so the settlement from the bet response is the client's to keep.

Idempotency-Key

Every POST on this page accepts an Idempotency-Key header of 8 to 128 letters, digits, _ or -. Make up a new key for each attempt and send the same key when retrying it. A retry then has one of three outcomes.

The server hasIt answers
A stored response for this key, and the body matchesThat response again, with the header idempotency-replayed: true. No second bet
A stored response, and the body differs422 idempotency key reused with a different request body
The first request still running, or it ended in a server error whose outcome is unknown409 request outcome pending; inspect the session before retrying

Stored responses last 24 hours and are filed per session and per route. Without the header a retry is a new bet. A header that doesn't fit the pattern, 8 to 128 letters, digits, _ or -, is refused with a 400 invalid Idempotency-Key, so a typo can't turn into an unprotected bet. Retried Requests has the detail.

Rate Limits

Per address, counted in Redis. The per-minute ceiling of 300 applies to every route that doesn't set its own.

RoutePer secondPer minute
POST /api/demo/session330
POST /api/demo/bet5300
POST /api/demo/rotate2300
Everything under /api/demo/games5300
POST /api/demo/games/chips/refill53
Other /api/demo routes10300

Over the limit is a 429. Bodies are capped at 262,144 bytes, which no request here comes near. A browser on another origin can't call any of this: CORS admits the configured site origin and the two galabets.org hosts, and allows the X-Demo-Session and Idempotency-Key request headers.

No Concealed Card Games

Mines is the only game here that the server plays one move at a time against a hidden result. There is no equivalent for Blackjack or Hi-Lo. No route deals a card, holds a hand, takes a stake on one or settles one. The rules catalog says as much in its own entries: both card games are listed with settlement: false.

What exists for cards is /bet, which hands over the entire shuffled deck, and one analysis route.

curl -s -X POST http://localhost:3000/api/demo/games/analyze/blackjack \
  -H "content-type: application/json" \
  -d '{"hand":["TS","6H"],"following":["3D","KC","9S"]}'
200. Computed by calling the API's dealerTrace function directly
{
  "ruleVersion": "galabet-cards/2026-09-20.1",
  "mode": "public-input analysis",
  "cards": ["TS", "6H", "3D"],
  "steps": [{ "card": "3D", "position": 0, "total": 19, "soft": false }],
  "total": 19,
  "soft": false,
  "bust": false,
  "natural": false,
  "complete": true,
  "used": 1,
  "rule": "S17"
}

You give it a dealer hand and the cards that follow, and it plays the dealer's fixed rule forward: draw below 17, stand on 17, and with "hitSoft17": true draw on a soft 17 as well. hand is 2 to 20 cards, following up to 52, each a rank from 23456789TJQKA and a suit from CDHS, all distinct across both lists since the analysis assumes one deck. complete: false means the dealer still had to draw and following ran out.

It takes no session, reads no seed and creates no randomness. Every input is something the caller already knows, which is what mode is saying. It is arithmetic on public cards, useful for showing a player why the dealer stopped where it did. A real-stakes Blackjack would need what Mines has: the deck held server-side, one card released per request, the record withheld until the hand is over. Concealed Games describes how to build that. This API hasn't.