Skip to content

HTTP API

Every route in src/server/index.js, in the order the server matches them. Most routes do not check the HTTP method, so "GET" below means "the method the client uses". Rate-limit buckets depend on method and path: see Rate limiting.

JSON responses carry cache-control: no-store and the security headers, unless stated otherwise.

Summary

methodpathpurposerate bucket
POST/gammonet/acquireworker: lease positions to analysenone (key required)
POST/gammonet/submitworker: return results for a leasenone (key required)
any/gammonet/statusanalysis queue and cache statisticsnone
any/healthzhealth and deploy checksnone
GET/api/leaderboardrated players by ratingread
GET/api/player/<pub>one player's rating card and recent matchesread
GET/api/fairnessaggregate dice histogram and chi-squareread
GET/api/puzzlea daily puzzle cardread
GET/email/verify?t=<token>confirm a puzzle subscription (HTML page)none
GET/email/unsubscribe?t=<token>stop the daily puzzle mail (HTML page)none
POST/api/login-claimredeem a sign-in linkwrite
POST/api/cron/daily?key=<CRON_KEY>send today's puzzle mailwrite
GET/api/analysis/<matchId>analysis of a finished matchanalysis
GET/api/recordsids of the 50 most recent matchesread
GET/api/matchesthe 25 most recent matchesread
GET/records/<matchId>.jsondownload a signed recordread
GET/, /*static client from public/, and /src/* from the repositorynone

Worker endpoints

POST /gammonet/acquire

Header x-gammonet-key: <GAMMONET_KEY>. Body { engineId, count, workerId }. count defaults to 8, maximum 64.

json
{ "token": "…uuid…", "jobs": [{ "key": "…", "req": { "pos": {}, "onRoll": 0, "dice": [3, 1], "cube": {}, "score": [0, 0], "matchLength": 0, "crawford": false }, "ply": 2, "engineId": "gnubg-1.07" }] }

{ "jobs": [] } means there is no work for that engine.

POST /gammonet/submit

Header x-gammonet-key. Body { token, results: [{ key, result }], workerId }. The response is { ok: true, accepted, ignored? } or { ok: false, error } (unknown token, that token belongs to another worker). Results for positions not in the lease are ignored and counted as rejected. See Analysis & PR for the checks.

Both worker endpoints answer 503 { error: 'this instance accepts no volunteer workers' } when GAMMONET_KEY is unset, and 401 for a wrong or missing key.

/gammonet/status

No key required. Returns { depth, queued, served, dropped, rejected, cache: { …, hitRate }, annotations: { hits, misses, coalesced, hitRate } }.

Health

/healthz

{ ok, uptime, online, matches, rssMB, heapMB, botEngine, dice, serverKey, queue, errors, backup, limiterKeys }. See Monitoring for what each field means.

Players and ratings

GET /api/leaderboard

Query: minGames (default 3) and limit (default 50, maximum 200). Returns { players: [{ pub, name, games, wins, rating, rd, provisional, conservative }], system }. In this response pub is shortened to its first 16 hex characters.

GET /api/player/<pub>

The full 64-hex public key. Returns { rating, rd, provisional, conservative, name, games, wins, matches }, where matches holds the player's 25 most recent rows from the match index. Returns 404 { error: 'no such player' } for an unknown key.

Dice

GET /api/fairness

json
{
  "protocol": "hmac-sha256/hashchain-reveal-v2",
  "rolls": 0,
  "counts": { "1": 0, "2": 0, "3": 0, "4": 0, "5": 0, "6": 0 },
  "chi2": 0, "df": 5, "critical95": 11.07,
  "uniform": null,
  "note": "…this histogram is a sanity check, not the proof…"
}

uniform stays null until at least 600 dice have been counted. After that it is chi2 < 11.07. Counts are added when a match finishes.

Puzzle

GET /api/puzzle

Query: date (YYYY-MM-DD, defaults to today in UTC). A pub parameter is ignored.

  • Returns { puzzle, stats: { tries, solved }, streak, yours }. puzzle is the public view: position, dice, cube, score and number of choices, with no answers.
  • Over HTTP streak is always 0 and yours is always null: this route cannot tell who is asking, and public keys are public (/api/matches lists them), so it never reveals any player's scored answer. An authenticated client gets its own yours and streak from the WebSocket puzzle message.
  • Only today's puzzle is ever built on demand. A past date with no stored puzzle returns 404, and a future date also returns 404. Otherwise one unauthenticated request per past date could queue many multi-second builds in parallel and freeze every live match.
  • If a build fails, the response is 503 { error }.

Answers are submitted over the WebSocket (puzzle-attempt), because that connection has already proved its key.

Both routes return a small standalone HTML page, since they are opened from a mail client.

  • GET /email/verify?t=<token>: confirms a pending subscription. 200 "you are subscribed", or 400 "that link has expired".
  • GET /email/unsubscribe?t=<token>: sets the subscription's daily flag to off. 200 "unsubscribed", or 400 "nothing to unsubscribe". Rating and match history are not affected.

Accounts

POST /api/login-claim

Body { token }. The token is consumed on first use, and it expires 30 minutes after it was issued.

json
{ "pub": "…", "username": "…", "email": "…", "jwk": { "kty": "OKP", "crv": "Ed25519", "…": "…" } }

jwk is the account's private key, which the browser installs. An unknown, used or expired token returns 400 { error: 'that link has expired — ask for a new one' }.

Cron

POST /api/cron/daily?key=<CRON_KEY>

Sends today's puzzle to every due subscriber, up to MAIL_DAILY_CAP. Safe to call repeatedly. Returns 403 { error: 'bad key' } if CRON_KEY is unset or does not match. Response shapes:

  • { skipped: 'mail provider not configured' }
  • { sent: 0, due: 0 } when nobody is due
  • { date, ok, failed, skipped, outcomes, due } after a send, where ok and failed are counts, skipped is how many were not attempted because the batch aborted, and outcomes is a per-address true/false list
  • 500 { error } on failure

Matches and records

GET /api/analysis/<matchId>

Query: deep=1 for engine analysis by a worker. Otherwise the server's built-in heuristic is used. Returns 404 for an unknown match. Otherwise the response is { matchId, engineId, ply, players, result, totals, moves, provisional, scoredIn, prNote }, computed once per match and tier. When deep analysis cannot complete, the response is 503 { error, hint: 'no gammonet worker is connected for deep analysis' }. See Analysis & PR.

GET /api/records

An array of the 50 most recent match ids, newest first, read from the SQLite index.

GET /api/matches

The 25 most recent rows of the match index: id, created, length, p0, p1 (full public keys), name0, name1, score0, score1, winner, rated, reason, ratings before and after for both players, and final_hash.

GET /records/<matchId>.json

The full signed record as JSON (the server decompresses it), with content-disposition: attachment. The .json suffix is optional. An unknown id falls through to the static handler and returns 404 not found. This response does not carry the security headers or a cache-control header.

Static files

  • / serves public/index.html. Other paths are served from public/.
  • Paths starting with /src/ are served from the repository root. This is how the browser imports the same src/core/* and src/verify/* modules the server runs. It also means every file under src/, server code included, can be fetched. The code is open source, and no secrets live in src/.
  • Any path containing .. returns 400. A missing file returns 404.
  • .html, .js and .css are sent with cache-control: no-cache. Other assets get public, max-age=86400.
  • /privacy.html and /terms.html are the legal pages. Edit them to name yourself as the operator.

Rules engine, fairness protocol, verifier, analysis and worker: MIT. Server and client: AGPL-3.0-or-later.