DocsHTTP API

Verify Endpoints

Reference for the three POST routes under /api/verify in Galabet's demo API, with every request field, the response bodies, the error shapes, and the limits that apply.

Three routes, all POST, all stateless: /api/verify recomputes a result from seeds and a nonce, /api/verify/record checks a whole record, and /api/verify/inspect does the same for input nobody has validated yet.

None of this is deployed. The NestJS API runs on your own machine at http://localhost:3000, and it needs Postgres and Redis beside it. The verify controller uses neither, but the rate limiter in front of it keeps its counters in Redis. Self-hosting the API covers the setup. No request on this page was sent to a live server. The response bodies were produced by calling the library the way the controller does, and the error bodies by running the API's own validation pipe and exception filter classes outside the server.

Why the routes exist at all, and how to build your own, is on Exposing a Verify Endpoint. This page is the lookup table.

RouteBody is checked bySuccessLibrary call
POST /api/verifyzod schema verifyBody201play, commit, canonicalJson
POST /api/verify/recordzod schema recordBody201verifyRecord
POST /api/verify/inspectinspectRecord itself201inspectRecord

201 and not 200, because NestJS answers every POST handler with 201 unless the handler says otherwise, and none of these do. Nothing is created. Test for a 2xx status.

POST /api/verify

Needs the API on localhost:3000
curl -s -X POST http://localhost:3000/api/verify \
  -H 'content-type: application/json' \
  -d '{"game":"dice","serverSeed":"5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d","clientSeed":"galabet","nonce":42}'
FieldRequiredAccepted
gameyesdice, limbo, roulette, wheel, plinko, mines, keno, blackjack, hilo
serverSeedyesString. Trimmed, then 64 hex characters in either case, then lowercased
clientSeedyesString, 1 to 64 UTF-16 code units, no :. Not trimmed
nonceyesJSON integer, 0 to 1,000,000,000. "42" is refused
paramsnoObject. Defaults to {}
params.houseEdgenoNumber, 0 to 0.5
params.segmentsnoInteger, 2 to 100
params.rowsnoInteger, 8 to 16
params.minesnoInteger, 1 to 24
params.drawsnoInteger, 1 to 40
params.decksnoInteger, 1 to 8

Both objects are strict, so a key that isn't in this table fails the request. The schema does not tie a parameter to its game. {"game":"dice","params":{"mines":5}} passes validation, Dice ignores mines, and the record that comes back carries the stray parameter. Crash isn't in the list of games: it has no server seed or nonce, and its records go to /inspect.

The response has six keys. record is a complete GFS record with the seed already in it, and canonical is that record as canonical JSON, the exact bytes a record hash or signature is taken over.

verify-response.mjs
import { canonicalJson, commit, play } from '@galabet/fair';

// The controller's calls, in the controller's order. On the server `at` is Date.now().
async function verifyResponse(body, at) {
  const params = body.params ?? {};
  const out = await play({ game: body.game, params, serverSeed: body.serverSeed, clientSeed: body.clientSeed, nonce: body.nonce });
  const { commitment } = await commit(body.serverSeed);
  const record = {
    spec: 'GFS/1.0', profile: 'single-player', game: body.game, params,
    serverSeed: body.serverSeed, commitment, clientSeed: body.clientSeed, nonce: body.nonce,
    cursor: out.cursor, result: out.result, at,
  };
  return { result: out.result, cursor: out.cursor, floats: out.floats, commitment, record, canonical: canonicalJson(record) };
}

const body = {
  game: 'dice',
  serverSeed: '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d',
  clientSeed: 'galabet',
  nonce: 42,
};
console.log(JSON.stringify(await verifyResponse(body, 1790000000000), null, 2));
Output
{
  "result": 56.12,
  "cursor": 0,
  "floats": [
    0.5611712262034416
  ],
  "commitment": "ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7",
  "record": {
    "spec": "GFS/1.0",
    "profile": "single-player",
    "game": "dice",
    "params": {},
    "serverSeed": "5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d",
    "commitment": "ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7",
    "clientSeed": "galabet",
    "nonce": 42,
    "cursor": 0,
    "result": 56.12,
    "at": 1790000000000
  },
  "canonical": "{\"at\":1790000000000,\"clientSeed\":\"galabet\",\"commitment\":\"ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7\",\"cursor\":0,\"game\":\"dice\",\"nonce\":42,\"params\":{},\"profile\":\"single-player\",\"result\":56.12,\"serverSeed\":\"5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d\",\"spec\":\"GFS/1.0\"}"
}

at is the moment of the request, so two identical calls return different record.at values and different canonical strings. The record describes a recomputation. It is not the record the casino wrote when the bet was placed, and its hash won't match that one.

POST /api/verify/record

The body is a record. Field meanings are on Record Format, and what follows is only what this route's schema accepts.

FieldRequiredAccepted
specyesExactly "GFS/1.0"
profileyesExactly "single-player"
gameyesThe nine names above
paramsyesSame object as above. Send {} when there are none
serverSeedno64 hex, either case, lowercased
commitmentyes64 hex, either case, lowercased
clientSeedyesString, 1 to 64 code units, no colon
nonceyesInteger, 0 to 9,007,199,254,740,991 (Number.MAX_SAFE_INTEGER)
cursoryesInteger, 0 or more
resultyesAny JSON value. A record without it is refused with result is required
atyesInteger, 0 or more
beaconno{ source: "drand" or "evm", ref: string, value: string }
signatureno128 lowercase hex characters. Capitals are refused
signerno64 hex, either case, lowercased

params is optional on the first route and required on this one. Unknown keys fail here too.

The answer is whatever verifyRecord returns, serialised.

record-response.mjs
import { verifyRecord } from '@galabet/fair';

const record = {
  spec: 'GFS/1.0', profile: 'single-player', game: 'dice', params: {},
  serverSeed: '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d',
  commitment: 'ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7',
  clientSeed: 'galabet', nonce: 42, cursor: 0, result: 56.12, at: 1790000000000,
};

console.log(JSON.stringify(await verifyRecord(record), null, 2));
console.log(JSON.stringify(await verifyRecord({ ...record, result: 12.34 })));
Output
{
  "ok": true,
  "computed": 56.12,
  "claimed": 56.12,
  "commitmentOk": true,
  "cursorOk": true,
  "signatureOk": null,
  "recordHash": "4bea13903c9ef8493c60e6422b067abed2bf2c6440df1c97fbdc603cca522705",
  "reasons": []
}
{"ok":false,"computed":56.12,"claimed":12.34,"commitmentOk":true,"cursorOk":true,"signatureOk":null,"recordHash":"38e759d8caf1fb9f2cf3af03488699a0f6e2e230525d37b651d03794cc80d252","reasons":["result does not match seeds"]}

A record that fails is still a 201. The verdict is ok in the body, not the status code. A beacon passes the schema and is then ignored by verifyRecord, so a record carrying one can come back ok: true from this route while /inspect reports the same record as incomplete.

Two Inputs the Schema Allows and verifyRecord Throws On

record-throws.mjs
import { verifyRecord } from '@galabet/fair';

const record = {
  spec: 'GFS/1.0', profile: 'single-player', game: 'dice', params: {},
  serverSeed: '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d',
  commitment: 'ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7',
  clientSeed: 'galabet', nonce: 42, cursor: 0, result: 56.12, at: 1790000000000,
};
const { result, ...withoutResult } = record;

for (const [name, input] of [['colon', { ...record, clientSeed: 'lucky:seven' }], ['no result', withoutResult]]) {
  try {
    await verifyRecord(input);
  } catch (error) {
    console.log(`${name}: ${error.message}`);
  }
}
Output
colon: client seed must not contain ":" (reserved as the HMAC message separator)
no result: canonicalJson: unsupported type undefined

POST /api/verify/inspect

Needs the API on localhost:3000
curl -s -X POST http://localhost:3000/api/verify/inspect \
  -H 'content-type: application/json' \
  -d '{"gameHash":"85a96b33e69fe1bdfd99d97e021112182907781e4b501bd208e27a47a85a3739","salt":"galabet-design-review-public-salt","houseEdge":0.01,"result":5.95}'

There is no schema on this route. The parsed JSON body goes to inspectRecord as it arrived, and that function does its own bounding: 65,536 bytes, 12 levels of nesting, per-game parameter ranges. It takes single-player records, Crash records and Galabet Flight records. The report format, the four check states and the full list of thrown messages are on Inspecting Untrusted Records, and none of it changes over HTTP. The response is that report as JSON.

Here it is for the Crash record in the curl command above.

inspect-response.mjs
import { inspectRecord } from '@galabet/fair';

const report = await inspectRecord({
  gameHash: '85a96b33e69fe1bdfd99d97e021112182907781e4b501bd208e27a47a85a3739',
  salt: 'galabet-design-review-public-salt',
  houseEdge: 0.01,
  result: 5.95,
});

console.log(JSON.stringify(report, null, 2));
Output
{
  "kind": "Crash",
  "status": "incomplete",
  "computed": 5.95,
  "claimed": 5.95,
  "checks": [
    {
      "name": "Commitment",
      "state": "not-provided",
      "detail": "No saved commitment supplied."
    },
    {
      "name": "Chain link",
      "state": "not-provided",
      "detail": "Supply previousHash to check the adjacent chain link."
    },
    {
      "name": "Outcome",
      "state": "matches",
      "detail": "The supplied outcome reproduces exactly."
    }
  ],
  "difference": null,
  "note": "These checks do not establish when a commitment was published, guarantee a payout or certify an operator."
}

The crash point reproduces and the status is still incomplete, because the body gave neither a commitment nor a previousHash to tie the game hash to. Add one and it becomes matches.

Anything inspectRecord throws becomes a 400 whose message is the library's sentence.

Status Codes

StatusWhenerror field
201The route ran, whatever the verdict
400A zod schema refused the bodyBAD_REQUEST
400inspectRecord threwBad Request
413Body over the size limit. Fastify refuses it before a controller runsnot captured
429Rate limitTOO_MANY_REQUESTS
500Anything unexpected. No known input to these three routes produces oneInternal Server Error

Every error passes through one exception filter, which builds { statusCode, error, message, requestId } and then spreads the exception's own payload over it. That spread is why error is spelled two ways for the same status. A schema failure supplies message and issues and leaves the filter's enum name alone. A BadRequestException built from a plain string brings NestJS's own error: "Bad Request" and overwrites it. Match on statusCode.

400 from a schema: nonce sent as a string, plus an unknown key
{
  "statusCode": 400,
  "error": "BAD_REQUEST",
  "message": "validation failed",
  "issues": [
    { "path": "nonce", "message": "Expected number, received string" },
    { "path": "", "message": "Unrecognized key(s) in object: 'debug'" }
  ],
  "requestId": "..."
}
400 from /inspect
{
  "statusCode": 400,
  "error": "Bad Request",
  "message": "Record nesting is too deep.",
  "requestId": "..."
}
429
{
  "statusCode": 429,
  "error": "TOO_MANY_REQUESTS",
  "message": "ThrottlerException: Too Many Requests",
  "requestId": "..."
}

Every issue is reported, not only the first. path is dotted, such as params.rows, and empty for a problem with the object as a whole. The messages are zod's wording apart from two the schema sets itself: must be 64 hex chars and client seed must not contain ":". A 500 never carries a stack trace or the underlying message. The detail goes to the server log under the same requestId.

requestId is a fresh UUID per request, or the cf-ray header when one arrives. It is also sent as an x-request-id header on every response, successful ones included.

Limits

LimitValue
Requests per second, per address, per route20
Requests per minute, per address, per route300
Request body262,144 bytes (256 KiB)
Body that /inspect will look at65,536 bytes
CORS originsThe configured site origin, https://galabets.org, https://www.galabets.org
CORS methodsGET, POST, OPTIONS

The two rate windows stack. The API-wide defaults are 10 a second and 300 a minute, the verify routes raise the first to 20, and the second still applies. A client running flat out at 20 a second is cut off after 15 seconds and waits for the minute to turn over. Counters live in Redis, keyed by client address, and the API trusts exactly one proxy hop when working that address out.

CORS affects browsers only. curl and server-side callers can use any origin or none.

Nothing Is Kept

The controller has no constructor and nothing injected, so it has no database or Redis handle to write a seed to. Identical requests get identical answers apart from at and requestId, in any order, from any worker. The longer argument for keeping a verify route this way, logs included, is under Nothing Is Stored.

curl on Windows

The commands on the HTTP pages are written for a POSIX shell. Two things break them on Windows.

In Windows PowerShell, curl is an alias for Invoke-WebRequest, which takes different arguments. Type curl.exe. And single quotes don't protect the double quotes inside a JSON body: PowerShell 5.1 strips them on the way to a native program, so -d '{"nonce":42}' arrives as {nonce:42}. Escape each one with a backslash.

Windows PowerShell 5.1
curl.exe -s -X POST http://localhost:3000/api/verify -H "content-type: application/json" -d '{\"game\":\"dice\",\"serverSeed\":\"5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d\",\"clientSeed\":\"galabet\",\"nonce\":42}'

In cmd, curl is already curl.exe. Single quotes mean nothing there, so the body goes in double quotes with the inner ones escaped.

cmd
curl -s -X POST http://localhost:3000/api/verify -H "content-type: application/json" -d "{\"game\":\"dice\",\"serverSeed\":\"5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d\",\"clientSeed\":\"galabet\",\"nonce\":42}"

Past one line of JSON, save the body as body.json and send the file. This form is the same in both shells, except that PowerShell needs the quotes around @body.json and cmd doesn't care.

Either shell
curl.exe -s -X POST http://localhost:3000/api/verify -H "content-type: application/json" -d "@body.json"

All three forms were checked on Windows 11 against a local server that echoes the body back, with PowerShell 5.1 and cmd. Piping the JSON into curl.exe -d '@-' from PowerShell was also tried and failed, because the pipe put a byte-order mark in front of the JSON. PowerShell 7.3 and later changed how arguments reach native programs and are documented to pass the inner quotes through untouched, so the backslashes should come out there. We had no PowerShell 7 to try it on.