Overview
#Sail Club API
The Sail Club API lets a club's own website (or any server-side integration the club authorises) read the club's published regatta data — events, races, race results and event results — the same data the club's public results pages already show, as clean JSON instead of scraped HTML.
- Server-to-server only. No browser calls, no CORS. Call it from your backend, not from client-side JavaScript in a visitor's browser.
- Read-only. There is nothing to write today; every endpoint is a
GET(except the token endpoint). - Scoped to one club. A credential belongs to one club. It only ever returns that club's data — there is no club id parameter to get wrong.
- Cached at our end, at least 1 hour. Results can be up to an hour old. Respect the
Cache-ControlandETagheaders on your side too (see Caching & freshness) so you never poll harder than you need to. - Versioned. Everything here is
/api/v1/…. See Versioning & changelog.
#Base URL
https://sail-club-server.cloud.runEvery path on this site (/api/v1/…) is relative to that host.
#The five-minute path
- Ask your club's admin for Settings → API access credentials (a
client_idand aclient_secret, shown once). See Quickstart. - Exchange them for a bearer token:
POST /api/v1/oauth/token. - Call the data endpoints with
Authorization: Bearer <token>: events · event detail · races · race results · event results. - Cache the response yourself for at least the
Cache-Control: max-ageyou receive back, and useIf-None-Matchwith theETagon your next call — see Caching & freshness.
#Scopes
Two read scopes exist today: events:read (events, event detail, races) and results:read (race results,
event results). Full table: Scopes.
#Errors
Every non-2xx response on /api/v1 (except the token endpoint, which follows RFC 6749) is
application/problem+json with a machine-readable code. Full table with fixes:
Errors.
#Data models
Every response is exactly the same DTO shape the public results pages already render — nothing new to learn if you've looked at a club's public event page. Full field tables: Data models.
#Build with AI
Every method page on this site carries a ready-made Prompt block for handing to an AI coding agent — see Build with AI for the idea, or use the full-integration prompt below to have an agent build the whole client in one pass.
#Full-integration prompt
This prompt is deliberately self-contained — a coding agent that reads only this block (no other page on this site) can build a working client. It builds a small results-page integration: the club's upcoming events, and for a finished event, its overall standings — the same shape of page a club typically wants on its own website.
Prompt for AI agents ready to paste into an agent
You are building a server-side client for the Sail Club API — a read-only, server-to-server REST API for
club regatta data. Do not put credentials or tokens in any client-side/browser code; this API has no CORS
and must only ever be called from a backend.
BASE URL
https://sail-club-server.cloud.run
AUTHENTICATION (OAuth 2.0 client credentials, RFC 6749 §4.4)
You have a client_id (starts "scc_") and a client_secret (starts "scs_") from the club admin. Read both
from environment variables — never hard-code or log them. Both only ever use the base64url alphabet
(letters, digits, "-", "_") plus their fixed prefix, so send them as-is — no percent-encoding step needed.
Request:
POST /api/v1/oauth/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id + ":" + client_secret)
Body: grant_type=client_credentials
Response 200 (Cache-Control: no-store):
{ "access_token": "sca_...", "token_type": "Bearer", "expires_in": 3600, "scope": "events:read results:read" }
The token is opaque — do not decode it. It is valid for expires_in seconds (1 hour). Cache it in memory
(per process) and reuse it; fetch a new one only when a call returns 401, or 60 seconds before expires_in
runs out. Never request a new token per API call — the token endpoint is rate-limited.
Errors: RFC 6749 JSON body { "error": "...", ... } plus our own "code" field. A 429 "rate_limited" means
back off for Retry-After seconds (see RATE LIMIT HANDLING below) and retry; every other non-200 is a hard
failure for this run — surface the "code" field to logs and stop.
DATA CALLS
Authorization: Bearer <access_token> on every call below. No club id parameter — the token determines the
club. All 4xx/5xx bodies are application/problem+json: { "type", "status", "code", "params"?, "traceId"? }.
1. GET /api/v1/events?page=1&pageSize=20
-> { "items": [ { "id", "name", "location", "startDate", "endDate", "status", "coverUrl", "posterUrl",
"entryCount" } ], "nextPage": <int|null>, "totalItems": <int> }
Newest startDate first. 404 is never returned here; an empty/no-op club returns an empty items array.
2. GET /api/v1/events/{eventId}
-> full event detail: name, dates, location, description, links, races[] (id, name, scheduledDate,
status, hasResults, resultCount, ...). 404 { "code": "EVENT_NOT_FOUND" } if the id is unknown, not
this club's, or not published.
3. GET /api/v1/events/{eventId}/races
-> [ { "id", "name", "scheduledDate", "status", "hasResults", "isOfficial", "resultCount", "raceNo",
"raceType", "boatModel" } ] — the same list as the event detail's races[], as its own endpoint.
404 { "code": "EVENT_NOT_FOUND" } under the same rule as above. Use an item's "id" as raceId below
once its "hasResults" is true.
4. GET /api/v1/races/{raceId}/results
-> { "id", "name", "eventId", "eventName", "scheduledDate", "results": [ { "rank", "sailNumber",
"boatName", "skipper", "owner", "status" ("Finished"|"DNF"|"DNS"|"OCS"|"RET"|"DSQ"), "elapsedTime",
"correctedTime", "points", ... } ] }. 404 { "code": "RACE_NOT_FOUND" } if the id is unknown,
malformed, or another club's.
5. GET /api/v1/events/{eventId}/results
-> { "results": [...], "overall": [...], "ratingGroups": [...], "specialCategories": [...] } — each
entry has rank, sailNumber, boatName, skipper/owner, score/points (specialCategories: totalScore),
per-race points with isDiscarded. 404 { "code": "EVENT_NOT_FOUND" } under the same rule as above.
The shapes above are enough to build a working client — nothing else is required. If your stack has an
OpenAPI code generator, GET /api/v1/openapi.json (OpenAPI 3.1, no auth needed) is an optional,
always-current source to generate exact types from instead of hand-typing the shapes above.
CACHING
Every 200/304 carries Cache-Control (private, max-age=<seconds>), ETag, Last-Modified, X-Data-As-Of. Store
the ETag per URL; send it back as If-None-Match on your next call to that same URL. A 304 has no body —
keep using your cached copy. Never poll more often than the max-age you were given; data changes at most
once an hour at our end.
ERROR + RATE LIMIT HANDLING
401 API_TOKEN_MISSING / API_TOKEN_INVALID -> fetch a fresh token and retry the call once; if it still
fails, stop and alert a human (the credential itself is the problem).
401 API_CLIENT_DISABLED / API_CLIENT_EXPIRED -> do NOT refresh: the token endpoint answers 400
unauthorized_client for a disabled/expired client, so a refresh cannot help. Stop immediately and alert
a human (needs a club-admin fix in the panel).
403 API_SCOPE_MISSING / API_IP_NOT_ALLOWED -> not fixable by retrying; alert a human (needs a club-admin
change in the panel).
404 EVENT_NOT_FOUND / RACE_NOT_FOUND -> the id doesn't exist for this club (or isn't published) — do not
retry, show a normal "not found".
429 RATE_LIMIT_EXCEEDED (Retry-After: 60) / 503 API_UNAVAILABLE (Retry-After: 30) -> Retry-After is always
an integer number of seconds, never an HTTP date. Back off for exactly that long before retrying, with
jitter if this runs on a schedule for many events at once.
Anything else (5xx) -> log the traceId field and retry up to 3 attempts total, with exponential backoff
(1s, then 2s, then 4s).
WHAT TO BUILD
1. A small typed API client module covering all five calls above: getToken() (caches + refreshes),
listEvents({page,pageSize}), getEvent(eventId), listRaces(eventId), getRaceResults(raceId),
getEventResults(eventId) — each handling the caching/ETag and error/retry rules above.
2. A minimal test (mocked HTTP) proving: a 401 triggers exactly one token refresh and retry; a 304 reuses
the cached body; a 429 waits for Retry-After before retrying.
3. A tiny example that prints the next 5 upcoming events, and for the most recent finished one, its overall
top 10 (rank, sailNumber, boatName, score) plus its most recent race's own top 3 finishers.
Adapt the above to whatever stack you are actually working in (Node/TypeScript, Python, C#/.NET, PHP, Go,
etc.) — the flow, headers, error codes and caching rules are the same regardless of language.