API¶
The HTTP surface of sage-tools-api. There is one REST surface, /v3, and
the older URLs, which are kept exactly as they are and never removed. A new
client uses /v3. GET /openapi.json (importable into Postman with Import →
Link) documents all of it, generated from the route files, so it cannot drift
from what is deployed.
The surfaces¶
| Surface | Paths | Status |
|---|---|---|
| Health | GET /ping |
outside any version, never changes |
/v3 |
resource-named routes, the table below | the one to build against; released in 3.0.0 |
/v1 |
attendance and score entry, from 2.6.0 and 2.8.0 | frozen |
| legacy | /sync/..., /scoresheets/..., /auth/login |
frozen |
Frozen means identical: same URL, same method, same auth, same status, same
body. The routes exist because Apps Script (pasted into every installed
workbook), archived event pages and a cached copy of Control Center call them.
Each of them answers two extra headers (RFC 9745) that point at its /v3 twin:
Deprecation: @<unix seconds of the 3.0.0 release>
Link: </v3/days/day1/visibility>; rel="successor-version"
The Link is the twin's path with that request's parameters filled in. No
Sunset header is sent, because these routes never go away. OpenAPI marks each
deprecated: true.
There is no /v2. The URL version is the package's major version, so the
surface of 3.x is /v3; /v1 is the frozen partial REST surface of the
2.x releases. Nothing answers under /v2.
The versioning rule¶
- Adding a route, an optional request member or a response member is not
breaking: it is a minor release, and stays on
/v3. - Renaming or removing a route or member, changing a type, or changing the
status code for the same outcome is breaking. It is allowed only as a new
major release with a new URL surface (4.0.0 and
/v4) that serves beside/v3. A major release never ships without its new surface, so the package major and the URL version stay equal. - An old URL is never removed.
/v3 routes¶
Auth: secret is the X-Sync-Secret header (Apps Script), operator is a
signed-in operator's bearer token, desk and scorer are the narrower
day-scoped tokens (see Auth).
| Method and path | What it does | Auth | Success | Frozen twin |
|---|---|---|---|---|
POST /v3/days/{day}/syncs |
sync every facility of the day | secret or operator | 200 sync report |
POST /sync/:day |
POST /v3/days/{day}/facilities/{facility}/syncs |
sync one facility (what Apps Script calls on an edit) | secret or operator | 200 sync report |
POST /sync/:day?facility= |
GET /v3/days/{day}/visibility |
read the day's go-live setting | operator | 200 { day, label, isLive } |
— |
PUT /v3/days/{day}/visibility |
set it, and republish | operator | 200 { day, label, isLive, republished, live?, archive? } |
POST /sync/:day/live |
GET /v3/settings/live-push |
read the live-push switch | operator | 200 { enabled, available } |
— |
PUT /v3/settings/live-push |
set it | operator | 200 { enabled, changed, available } |
POST /sync/live-push |
GET /v3/diagnostics/sync |
sync config and live-push diagnostics | secret or operator | 200 |
GET /sync/config |
POST /v3/scoresheets |
generate a scoresheet PDF | none | 200 PDF or NDJSON |
POST /scoresheets/generate, …/generate/stream |
POST /v3/sessions |
sign in | none | 201 { token, expiresAt } |
POST /auth/login (200) |
GET /v3/events/{event}/score-entry |
read the event's score-entry mode | operator | 200 { event, scoreEntry } ("console", "links" or null) |
— |
PUT /v3/events/{event}/score-entry |
set it | operator | 200 { event, scoreEntry, changed } |
PUT /v1/events/:event/score-entry |
GET /v3/days/{day}/facilities/{facility}/people |
list one facility's people and their check-in state | operator or desk | 200 an array of people, Cache-Control: no-store |
— |
PUT /v3/days/{day}/facilities/{facility}/people/{personKey}/attendance |
mark one person present or not | operator or desk | 200 the row |
PUT /v1/days/:day/facilities/:facility/attendance/:key |
POST /v3/days/{day}/attendance/desk-links |
issue a desk link; optional body { issuedTo?, note? } |
operator | 201 { token, expiresAt, day, issuedTo, note } |
POST /v1/days/:day/attendance/desk-links |
POST /v3/days/{day}/attendance/reconciliations |
update the ATTENDANCE roster |
operator | 200 |
POST /v1/days/:day/attendance/reconciliations |
PUT /v3/days/{day}/facilities/{facility}/matches/{matchNumber}/score |
enter, correct or clear a score | operator or scorer | 200 |
PUT /v1/days/:day/facilities/:facility/matches/:matchNumber/score |
POST /v3/days/{day}/scores/scorer-links |
issue a scorer link; optional body { issuedTo?, note? } |
operator | 201 { token, expiresAt, day, issuedTo, note } |
POST /v1/days/:day/scores/scorer-links |
That is seventeen routes on fourteen paths. Sixteen are the twin of a frozen route; the people list is the one that has none, because nothing before it read through the API. Details a twin does not show:
- Sync input moves into a body.
?facility=becomes a path segment, and?method=and theX-Edit-Atheader become{ "method"?: "sheets" | "csv", "editedAt"?: <ISO 8601> }. Amethodother thansheetsorcsvis a400(the legacy route silently usessheets), as is aneditedAtthat does not parse; one outside the last hour (or in the future) is ignored, asX-Edit-Atis. A day-level sync takes noeditedAt. The body is optional. - The two issue routes take an optional body.
{ "issuedTo"?: string, "note"?: string }, each at most 40 characters after trimming (inner whitespace collapsed, control characters removed, counted in code points) and signed into the token. A value that is not text, or too long, is a400; a body that is not JSON is a415. The201echoesissuedToandnote,nullwhen not given. The/v1twins ignore any body and answer exactly{ token, expiresAt, day }. - Scoresheets negotiate on
Acceptbefore the upload is read:application/pdf(or noAccept, or*/*) returns the PDF;application/x-ndjsonstreams progress lines ending with the PDF base64-encoded; anything else is406. The body must bemultipart/form-data. - Attendance is a singular sub-resource of the person
(
…/people/{personKey}/attendance), replacing/v1's…/attendance/{key}. A roster update names its facility in a body,{ "facility": "…" }; no body means every facility. - The people list is the array itself, in sheet order, one person per key:
{ key, player, teams, categories, present, timeIn, withdrawn, shirt }. It is read from the workbook at most once every 10 s per workbook however many desks ask, and again at once after a write to that tab. A workbook with noATTENDANCEtab answers[]. See Event attendance. A write that changes theATTENDANCEtab (a mark, or a roster update that created the tab or wrote rows) also nudges the live Worker, so other open pages re-read the list; no route is involved (push). - The score-entry body is
{ "scoreEntry": "console" | "links" }where/v1takes{ "mode": … }.
Where /v3 differs from the /v1 routes it replaces¶
A day, event or facility named in a /v3 path that does not exist is a 404
(/v1 answers 400); an unsupported method on a known path is a 405 with
Allow; a body that is not JSON is a 415; errors are problem details (below).
Success bodies are the same.
Conventions¶
Every /v3 route follows these. A guard test
(test/unit/guards/rest-conventions.test.mjs) checks the naming and method rules
on every registered route and the documentation rules on every OpenAPI operation.
URLs.
- Nouns, never verbs. An action becomes a noun for its result:
syncs,reconciliations,sessions. - A path segment directly before an identifier is a plural:
days/{day},facilities/{facility},matches/{matchNumber},events/{event}. - A one-per-parent thing is a singular sub-resource: a day's
visibility, a match'sscore, an event'sscore-entry, the globalsettings/live-push, thediagnostics/syncreport. - Literal segments are lowercase kebab-case, parameters camelCase. No trailing slash, no file extension.
- Nest only for real containment, from the shortest unique parent: day keys are
unique across events, so a day is
/v3/days/{day}, not under its event. - A
GETis filtered by its query string; aPOSTorPUTtakes a JSON body.
Methods. GET reads (and Express answers HEAD). PUT replaces a
resource's state and is idempotent, and every PUT resource also has a GET with
the same representation (the exceptions are below). POST to a collection
creates something or runs an action that returns a report. PATCH and DELETE
are allowed by CORS and used by no route yet.
Bodies. JSON, camelCase, no envelope: a response is the resource or the
report itself. The PUT body and the GET response use the same member names. A
PUT response is that representation plus side-effect members (changed,
republished, live, archive). Enumerations are lowercase strings. A request
with a body must say Content-Type: application/json, or it is a 415.
Status codes.
| Status | When |
|---|---|
200 |
a successful GET, PUT, or action POST that returns a report |
201 |
a POST that creates something: a session, a desk link, a scorer link |
400 |
malformed JSON, or a body or query value that fails validation |
401 |
no credentials, or invalid or expired ones; always with WWW-Authenticate: Bearer realm="sage-tools-api" |
403 |
valid credentials not allowed this action (wrong day, feature switched off) |
404 |
an unknown path, or an unknown day, event, facility or match named in the path |
405 |
a known path with an unsupported method, with Allow |
406 |
an Accept the route cannot satisfy |
409 |
the target's state conflicts: conflicting writes (conflict), a score that changed (score_conflict), an ATTENDANCE tab in the wrong shape (attendance_layout) |
415 |
a body that is not application/json (multipart/form-data for scoresheets) |
422 |
a well-formed request the workbook's state cannot take (schedule_layout) |
500 / 502 / 503 |
our failure / Google's or GitHub's / temporarily unavailable |
Exceptions¶
| Route | Rule it departs from | Why |
|---|---|---|
PUT …/people/{personKey}/attendance |
a GET for every PUT |
each person's state is read with GET …/facilities/{facility}/people, the list a desk reads; a per-person GET would have no reader |
PUT …/matches/{matchNumber}/score |
a GET for every PUT |
scores are read from the published day snapshot, which pages already poll; a Sheets read per GET would spend Google quota for no reader |
PUT …/matches/{matchNumber}/score |
concurrency through If-Match / 412 |
the precondition is the values a person saw in two cells, not a version the API issues, so there is no ETag; expected travels in the body, and the 409 carries current |
201 without Location (sessions, desk links, scorer links) |
Location on a created resource |
the result is a signed credential, not a stored resource, so there is no URL to point at |
expiresAt in the three token responses |
timestamps are ISO 8601 | it is epoch milliseconds on every surface, and the pages compare it with Date.now() |
isLive: true \| false \| "auto" |
an enumeration is not a mixed type | the same field, with the same values, as events.json and the published snapshot every page reads |
422 for schedule_layout |
— | stated so nobody "fixes" it to 409: the request is fine, the workbook's content cannot take it |
Errors¶
Two shapes, because two kinds of client read them.
Frozen routes answer the plain body, as application/json:
Apps Script prints it to operators, so it never changes. A score conflict adds
current.
/v3 answers RFC 9457 problem details, as application/problem+json:
{
"type": "about:blank",
"title": "Conflict",
"status": 409,
"detail": "The sheet changed since this match was opened",
"instance": "/v3/days/pd-day1/facilities/Main/matches/12/score",
"code": "score_conflict",
"error": "The sheet changed since this match was opened",
"current": { "teamCode1": "A", "teamCode2": "B", "team1Score": 11, "team2Score": 9 }
}
title is the status's standard reason phrase; detail is the message;
instance is the request path without its query string. error repeats
detail, so the pages that read body.error work unchanged, and any extra member
the error carries (current) follows.
Both shapes carry a stable code:
code |
Status | When |
|---|---|---|
validation_error |
400 | a body or query value fails validation |
unknown_scoresheet_type |
400 | a scoresheet type that is not registered |
unknown_day |
400 (404 on /v3) |
a day key that is not in the registry |
unknown_facility |
400 (404 on /v3) |
a facility name that is not one of the day's |
bad_request |
400 | malformed JSON in a request body |
unauthorized |
401 | no, invalid or expired credentials |
forbidden |
403 | valid credentials that may not do this |
score_entry_off |
403 | score entry is off for the event |
not_found |
404 | an unknown path, or a named resource that is not there |
unknown_event |
404 | an event key that is not in the registry |
method_not_allowed |
405 | an unsupported method on a known /v3 path |
not_acceptable |
406 | an Accept the route cannot satisfy |
conflict |
409 | three conflicting writes in a row |
score_conflict |
409 | the sheet changed since the client showed the match |
attendance_layout |
409 | the ATTENDANCE tab's header row is not the expected one |
unsupported_media_type |
415 | a body of the wrong Content-Type |
schedule_layout |
422 | no SCHEDULE tab, a duplicate match number, or a score cell holding a formula |
upstream_failure |
502 | Google Sheets failed, or every facility's fetch did |
config_unavailable |
503 | the event registry could not be loaded |
service_busy |
503 | Google Sheets answered 429 twice |
internal_error |
500 | anything unexpected |
An unknown path anywhere answers a JSON 404 (not_found), never Express's HTML
page.
Auth, by route¶
| Needs | Routes |
|---|---|
| nothing | GET /ping, GET /openapi.json, POST /v3/sessions (and /auth/login), POST /v3/scoresheets (and /scoresheets/...) |
| secret or operator | the two sync POSTs, GET /v3/diagnostics/sync |
| operator only | visibility, live-push and score-entry (GET and PUT), desk links, reconciliations, scorer links |
| operator or desk | the attendance PUT |
| operator or scorer | the score PUT |
The shared secret is never accepted where it says operator only. See
Auth for the tokens and src/auth/middleware.mjs.
Features counterpart: Control Center. Related: Architecture, Sync pipeline, Deployment.