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.
| Route | Body is checked by | Success | Library call |
|---|---|---|---|
POST /api/verify | zod schema verifyBody | 201 | play, commit, canonicalJson |
POST /api/verify/record | zod schema recordBody | 201 | verifyRecord |
POST /api/verify/inspect | inspectRecord itself | 201 | inspectRecord |
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
curl -s -X POST http://localhost:3000/api/verify \
-H 'content-type: application/json' \
-d '{"game":"dice","serverSeed":"5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d","clientSeed":"galabet","nonce":42}'
| Field | Required | Accepted |
|---|---|---|
game | yes | dice, limbo, roulette, wheel, plinko, mines, keno, blackjack, hilo |
serverSeed | yes | String. Trimmed, then 64 hex characters in either case, then lowercased |
clientSeed | yes | String, 1 to 64 UTF-16 code units, no :. Not trimmed |
nonce | yes | JSON integer, 0 to 1,000,000,000. "42" is refused |
params | no | Object. Defaults to {} |
params.houseEdge | no | Number, 0 to 0.5 |
params.segments | no | Integer, 2 to 100 |
params.rows | no | Integer, 8 to 16 |
params.mines | no | Integer, 1 to 24 |
params.draws | no | Integer, 1 to 40 |
params.decks | no | Integer, 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.
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));
{
"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.
| Field | Required | Accepted |
|---|---|---|
spec | yes | Exactly "GFS/1.0" |
profile | yes | Exactly "single-player" |
game | yes | The nine names above |
params | yes | Same object as above. Send {} when there are none |
serverSeed | no | 64 hex, either case, lowercased |
commitment | yes | 64 hex, either case, lowercased |
clientSeed | yes | String, 1 to 64 code units, no colon |
nonce | yes | Integer, 0 to 9,007,199,254,740,991 (Number.MAX_SAFE_INTEGER) |
cursor | yes | Integer, 0 or more |
result | yes | Any JSON value. A record without it is refused with result is required |
at | yes | Integer, 0 or more |
beacon | no | { source: "drand" or "evm", ref: string, value: string } |
signature | no | 128 lowercase hex characters. Capitals are refused |
signer | no | 64 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.
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 })));
{
"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
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}`);
}
}
colon: client seed must not contain ":" (reserved as the HMAC message separator)
no result: canonicalJson: unsupported type undefined
POST /api/verify/inspect
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.
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));
{
"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
| Status | When | error field |
|---|---|---|
| 201 | The route ran, whatever the verdict | |
| 400 | A zod schema refused the body | BAD_REQUEST |
| 400 | inspectRecord threw | Bad Request |
| 413 | Body over the size limit. Fastify refuses it before a controller runs | not captured |
| 429 | Rate limit | TOO_MANY_REQUESTS |
| 500 | Anything unexpected. No known input to these three routes produces one | Internal 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.
{
"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": "..."
}
{
"statusCode": 400,
"error": "Bad Request",
"message": "Record nesting is too deep.",
"requestId": "..."
}
{
"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
| Limit | Value |
|---|---|
| Requests per second, per address, per route | 20 |
| Requests per minute, per address, per route | 300 |
| Request body | 262,144 bytes (256 KiB) |
Body that /inspect will look at | 65,536 bytes |
| CORS origins | The configured site origin, https://galabets.org, https://www.galabets.org |
| CORS methods | GET, 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.
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.
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.
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.
