API
Base: https://<portal>/. JSON in and out. Errors: { "error": "…" } with 401/403/404/409/422; zod validation errors return 422 with issues.
Authentication
| Form | For whom | How |
|---|---|---|
Session cookie mkr_session | web portal | POST /api/v1/auth/login |
| Bearer device token | tablets | POST /api/v1/auth/device/login |
Bearer API key mkr_… | club portals, integrations | created by a club admin, with scopes |
Only the SHA-256 hash of tokens and keys is stored.
Accounts
| Method | Path | Description |
|---|---|---|
| POST | /api/v1/auth/register | create account (email, password ≥ 10, firstName, lastName) |
| POST | /api/v1/auth/login / /logout | web session |
| POST | /api/v1/auth/device/login | tablet: { email, password, device: { deviceId, name, platform, appVersion } } → { token, expiresAt, user, memberships } |
| GET | /api/v1/auth/me | profile + club roles |
| PATCH | /api/v1/auth/me | edit own name (firstName, lastName) |
| POST | /api/v1/auth/me/password | { currentPassword, newPassword }; revokes all other sessions and device tokens |
| GET | /api/v1/auth/me/results | matches in which this account is linked as a competitor |
Clubs (any signed-in user may create a club and becomes its admin)
| Method | Path |
|---|---|
| GET/POST | /api/v1/clubs |
| GET | /api/v1/clubs/:id (id or slug) — includes the caller's role |
| PATCH | /api/v1/clubs/:id — club admin: name, slug, country, website |
| GET/PUT | /api/v1/clubs/:id/members — { email, role: admin|staff|member } |
| DELETE | /api/v1/clubs/:id/members/:userId |
| GET/POST/DELETE | /api/v1/clubs/:id/api-keys — scopes registrations:write, registrations:read, results:read, matches:read, matches:write, * |
| GET/POST | /api/v1/clubs/:id/integrations — webhooks (url, events[]) → secret shown once |
Matches
| Method | Path | Permissions | ||||||
|---|---|---|---|---|---|---|---|---|
| GET | /api/v1/matches?clubId=&kind=match|training | visibility — events of both kinds unless kind filters | ||||||
| POST | /api/v1/matches | any signed-in user — clubId? (omit for a club-less match), name, discipline (ipsc_handgun|fds|lvr|multigun), kind (match|training, default match), startDate, endDate, visibility, externalId?, scoring?, divisions?, categories?; a club match requires club admin | ||||||
| GET/PATCH | /api/v1/matches/:id | PATCH (match admin or owner): name, kind, visibility, status (draft|registration|live|finished|archived), startDate, endDate, divisions[], categories[] (the scoring profile itself comes from the central rules) | ||||||
| POST | /api/v1/matches/:id/claim | staff/admin of clubId — a club claims a club-less match | ||||||
| GET | /api/v1/matches/:id/stages · PUT /stages/:stageId | stage upsert (number, name, targets[], parTimeSeconds?, dnfTimeSeconds?, briefing?, active); the stage type always follows the match scoring method (Comstock or time plus) | ||||||
| PUT | /api/v1/matches/:id/squads/:squadId | 409 if the club portal manages the registrations | ||||||
| GET/PUT | /api/v1/matches/:id/competitors[/:competitorId] | same | ||||||
| GET | /api/v1/matches/:id/results | standings per division + overall | ||||||
| DELETE | /api/v1/matches/:id | delete a match or training with its stages, squads, competitors, scores and bracket (match admin; the web portal asks to type the name, the tablet confirms) | ||||||
| PATCH | /api/v1/matches/:id | name, kind, status, visibility, dates, divisions, categories; clubId (null releases the event, a club id claims or transfers it to a club you are staff of; visibility "club" falls back to "private" without a club); discipline (only while no approved score or decided bout exists: the scoring profile, divisions and categories are re-applied from the new discipline's central rules, stage types follow, a shootoff bracket is removed) | ||||||
| GET | /api/v1/matches/:id/competitors/suggest?q= | autocomplete for the competitor form: names this club (or, for a club-less event, this organiser) registered before, with club, division, power factor, categories and linked account; never another club's entries (staff) | ||||||
| GET | /api/v1/clubs/:id/roster?q= | the club's competitor directory (staff) | ||||||
| GET | /api/v1/matches/:id/bracket | shootoff: the bracket with all results plus the resolved view (named sides, status, placements) | ||||||
| POST | /api/v1/matches/:id/bracket/start | shootoff: generate the bracket once (staff); body `{ lives: 1 | 2 | 3, bestOf: 1 | 3 | 5, finalBestOf: 1 | 3 | 5 }`, defaults 2/1/1 |
| POST | /api/v1/matches/:id/bracket/results | shootoff: bout decisions `{ results: [{ boutId, winnerId | null, heats?: competitorId[], decidedAt, deviceId }] }`; best-of bouts send the heat winners in order; latest per bout wins, each validated (staff; tablets and the portal) | |||||
| POST | /api/v1/matches/:id/bracket/restart | shootoff: regenerate the bracket from the current competitors with a new format (same body as start; defaults to the current format), discarding all decisions (admin; the UIs confirm first) | ||||||
| DELETE | /api/v1/matches/:id/bracket | shootoff: remove the bracket while no bout is decided (admin) | ||||||
| GET | /api/v1/matches/:id/results/detail?division= | standings with the result per stage (time, hits, penalties with the procedural reasons, hit factor, stage points, rank, revision of the counted score); feeds the detail view and the side-by-side comparison | ||||||
| GET | /api/v1/matches/:id/bundle | everything for tablets/dashboards | ||||||
| POST | /api/v1/matches/:id/live/refresh | staff: recalculate live standings |
Discipline rules (central, versioned)
| Method | Path | Rights |
|---|---|---|
| GET | /api/v1/rules | any signed-in user — all disciplines + versions map (tablets compare this with their local copy) |
| GET | /api/v1/rules/:discipline | any signed-in user |
| PUT | /api/v1/rules/:discipline | MakeReady admin — name?, scoring?, divisions?, categories?, notes?; increments version |
| POST | /api/v1/matches/:id/rules/reapply | match admin — copies the current rules into a match without approved scores |
Sync (tablets, staff)
| Method | Path |
|---|---|
| GET | /api/v1/sync/matches — events this user may score or manage (all statuses except archived, drafts included) |
| GET | /api/v1/sync/matches/:id?since= — bundle/incremental |
| POST | /api/v1/sync/push — { deviceId, matchId, scores[] } → { accepted[], ignored[], serverTime } |
Integrations (API key)
| Method | Path | Scope |
|---|---|---|
| GET | /api/v1/integrations/matches | matches:read — all events of the club |
| POST | /api/v1/integrations/matches | matches:write — create an event for the club: name, discipline, kind (default training), startDate, endDate?, visibility (default club), externalId; idempotent on externalId (returns the existing event with created: false) |
| GET | /api/v1/integrations/matches/:ref | matches:read — one event |
| PATCH | /api/v1/integrations/matches/:ref | matches:write — name, startDate, endDate, visibility, status, externalId |
| PUT | /api/v1/integrations/matches/:ref/registrations | registrations:write — :ref = MakeReady id or externalId |
| GET | /api/v1/integrations/matches/:ref/registrations | registrations:read |
| GET | /api/v1/integrations/matches/:ref/results | results:read — standings + raw scores |
Details and the webhook format: docs/club-portal-integration.md.
Web portal
The web portal is the site's home page: a single-page app served at / (client script at /app.js, no build step) that uses the JSON API below with the session cookie. /matches/:id redirects into it and /app redirects to /. It offers: sign in and registration, the match list and "my results", a match wizard with stage editor (targets, par/DNF time, briefing), squads and competitors, results per division with a detail per stage for every competitor and a side-by-side comparison of two competitors, score corrections (new revisions via the sync API; a corrected score carries a coloured "Corrected · rev n" pill in the results and scores tabs), club management (members and roles, API keys, webhooks) and, for the MakeReady admin, clubs and the discipline rules editor.
| Method | Path | Rights |
|---|---|---|
| GET | /api/v1/matches/:id/role | visibility — the caller's role on the match (platform, owner, admin, staff, member, null) |
| GET | /api/v1/matches/:id/squads | visibility |
| DELETE | /api/v1/matches/:id/stages/:stageId | match admin — only without scores |
| DELETE | /api/v1/matches/:id/squads/:squadId | match admin — not when the club portal manages registrations |
| DELETE | /api/v1/matches/:id/competitors/:competitorId | match admin — only without scores |
Website and versions (no authentication)
| Method | Path |
|---|---|
| GET | / — the web portal (SPA) |
| GET | /faq — FAQ with public matches and version numbers |
| GET | /docs, /docs/:slug — documentation, generated from docs/*.md on every deploy |
| GET | /api/v1/version — { portal, android: { version, versionCode }, ios: { version, buildNumber }, build } |
| GET | /health |
Public (no authentication, only visibility = public)
| Method | Path |
|---|---|
| GET | /api/public/matches?kind=match|training |
| GET | /api/public/matches/:id/results (cache 5 s while live, 5 min when finished) |
| GET | /scoreboard/:id?division=OPEN — HTML scoreboard for TV/kiosk |
| WS | /live/matches/:id?role=board — live protocol (packages/shared/src/live.ts) |
| GET | /live/matches/:id/state — latest snapshot (debug) |
Matches with visibility club/private are reachable through the same paths for anyone who is logged in and has the permission.
Result format
{
"match": { "id": "…", "name": "…", "discipline": "ipsc_handgun", "status": "live" },
"results": {
"ALL": [{ "rank": 1, "competitorId": "…", "name": "Stijn Maes", "division": "OPEN", "club": "Example Club", "total": 25, "percent": 100, "stagesScored": 1, "disqualified": false }],
"OPEN": [ … ]
},
"generatedAt": "2026-10-04T13:54:34.208Z"
}
total is match points (IPSC/LVR), percentage points (Multigun time_percentage) or total time in seconds (total_time).
An OpenAPI document (/api/v1/openapi) is on the roadmap; until then the zod schemas in packages/shared are the contract.