# HTTP and WebSocket endpoints All pages are server-rendered HTML except where noted. `` is the URL-safe tournament id created from its name (e.g. `labor-day-2s`). ## Public | Method | Path | Returns | | --- | --- | --- | | GET | `/` | List of tournaments. | | GET | `/healthz` | `{ ok, tournaments, uptime }` JSON. Used by the Docker healthcheck. | | GET | `/t/` | The QR landing page. Registration form while phase is `checkin`; otherwise the live board / results (client-rendered from state JSON). | | POST | `/t//register` | Form fields `name`, `captain`, `phone`, `players`, `playerNames` (newline-separated). 303 to the team page on success, 400 with the form re-rendered on error. | | GET | `/t//teams/` | The same personalized board addressed by public team id; this is what team-name links use. | | GET | `/t//team/` | Personalized board for the team with that 4-character code. `?new=1` shows the post-registration confirmation instead. | | GET | `/t//display` | TV display mode of the board. | | GET | `/t//state.json` | The public state document (below). | | WS | `/ws/` | Sends the public state document immediately on connect and again on every change. Clients send nothing. Server pings every 30 s. | | GET | `/static/*` | CSS, JS, icons. | ### Public state document ```jsonc { "slug": "labor-day-2s", "name": "Labor Day 2s", "date": "2026-09-07", "notes": "", "phase": "live", // checkin | closed | live | final "banner": null, "rules": { "teamSize": 2, "pointsTo": 21, "winBy": 2, "cap": 25, "bestOf": 1, "tiebreaks": ["headToHead","setRatio","pointRatio","pointDiff","seed"] }, "stages": ["pool","bracket"], "courtCount": 2, "teams": [{ "id": "t1", "name": "Net Gains", "players": 2, "seed": 1, "status": "registered", "poolId": "A" }], "withdrawn": [], "pools": [{ "id": "A", "standings": [{ "teamId": "t1", "name": "Net Gains", "w": 2, "l": 1, "setsW": 2, "setsL": 1, "pf": 59, "pa": 45, "pointDiff": 14, "decidedBy": "pointRatio", "status": "registered" }], "matches": [ /* match objects */ ] }], "bracket": [ /* match objects with stage bracket | losers | final */ ], "courts": [{ "court": 1, "status": "open", "match": { /* match object */ } }, { "court": 2, "status": "paused", "match": null }], "upNext": [{ /* match object */ , "court": 2, "etaMin": 12 }], "avgMatchMin": 14, "champion": null, "recentAlerts": [{ "kind": "on_deck", "text": "Net Gains vs Block Party: you're ON DECK for Court 2 (~12 min).", "at": 1757260000000, "teams": ["t1","t2"], "match": "m7" }], "generatedAt": 1757260000000 } ``` A match object: ```jsonc { "id": "m7", "stage": "pool", "round": 2, "slot": null, "label": null, "status": "live", // pending | on_deck | live | final | forfeit | void | bye "court": 1, "a": { "id": "t1", "name": "Net Gains" }, "b": { "id": "t2", "name": "Block Party" }, // null while TBD "winner": null, "sets": [], "live": [14, 11], // running score while live, else null "feeds": "m12", "feedsSide": "A", "conditional": false } ``` Phone numbers, captain names, and team codes never appear in the public document. ## Organizer All organizer routes require the session cookie set by `/admin/login` (or Cloudflare Access when `TRUST_CF_ACCESS=true`). Unauthenticated requests are redirected to the login page. | Method | Path | Body (form or JSON) | Effect | | --- | --- | --- | --- | | GET | `/admin/login` | | Login form. | | POST | `/admin/login` | `password`, `next` | Sets cookie, redirects to `next`. | | GET | `/admin/logout` | | Clears cookie. | | GET | `/admin` | | Desk home: tournament list and creation form. | | POST | `/admin/new` | `name`, `date`, `courtCount`, `teamSize`, `pointsTo`, `winBy`, `cap` (0 = none), `bestOf`, `stages` (`pool,bracket` / `pool` / `bracket`), `notes` | Creates a tournament. | | GET | `/admin/t/` | | Tournament desk. | | GET | `/admin/t//qr` | | Printable QR page. `qr.png` returns the PNG. | | POST | `/admin/t//phase` | `phase` = `checkin` / `closed` / `live` / `final` | Change phase. `live` requires generated play and starts court assignment. | | POST | `/admin/t//banner` | `banner` | Set (or clear with empty) the persistent banner. | | POST | `/admin/t//broadcast` | `text` | One-time alert to all team pages. | | POST | `/admin/t//generate` | `what=pools`, `poolSize` — or `what=bracket`, `perPool`, `wildcards`, `type` (`single`/`double`), `thirdPlace` | Generate pools or bracket. | | POST | `/admin/t//regenerate` | | Clear all generated play (only before any score). | | POST | `/admin/t//score` | `match`, `sets` (`"21-18, 19-21, 15-9"`) | Record or correct a result. Validated against the rules. | | POST | `/admin/t//live` | `match`, `a`, `b` | Update the running score of a live match (JSON, used by the score pad). | | POST | `/admin/t//forfeit` | `match`, `team` | Record a forfeit by `team`. | | POST | `/admin/t//swap` | `match`, `court` | Move a live match to another open court. | | POST | `/admin/t//pushback` | `match` | Push a pending match two places back in the queue. | | POST | `/admin/t//court` | `court`, `op` = `pause`/`resume`, `reason` | Pause or resume a court. | | POST | `/admin/t//team` | `op` = `add` (`name`,`captain`,`players`) / `edit` (`id`,`name`,`captain`,`players`) / `seed` (`id`,`seed`) / `remove` (`id`) | Roster management. | | POST | `/admin/t//withdraw` | `id`, `mode` = `forfeit`/`void` | Withdraw a team. | | POST | `/admin/t//delete` | `confirm=yes` | Delete the tournament and its data. | Form posts respond with a 303 back to the desk carrying `?msg=` or `?error=`. Requests with `Content-Type: application/json` get `{ ok: true, msg }` or `{ ok: false, error }` with a 400 on failure. Every action is recorded in the `events` table with the organizer identity as `actor`.