# API Terms # API Terms Using this API — as a club or as an integrator a club has given credentials to — is governed by the **Sail Club API Terms**, a legal document (not part of this technical reference): **[Read the API Terms →](/api-terms.html)** Highlights (the linked document is authoritative): - Use the data only for the club that issued your credentials, for that club's own events. - No resale or bulk harvesting of the data. - Attribute results you display ("Results by Sail Club", linked) where practical. - Keep credentials secret and report a suspected leak so the club admin can rotate them. - Honour the same personal-data duties as the club itself — display only, no profiling or marketing use. - We may rate-limit, suspend or revoke access for misuse — see [Rate limits](/docs/rate-limits) for the automatic limits, independent of this. A club admin accepts these terms once, in the panel, the first time they create an API client — see [Quickstart](/docs/quickstart). See also: [Privacy policy](/privacy-policy.html) §"Public results and data shared with clubs and their partners" — what data is public, what never is, and how someone can ask for a correction. --- # Get access token # Get access token Exchanges a client's `client_id`/`client_secret` for a short-lived bearer access token. The first call of every integration, and the only endpoint that follows RFC 6749 instead of `problem+json` for its errors. ## Headers | Header | Required | Value | |---|---|---| | `Content-Type` | yes | `application/x-www-form-urlencoded` | | `Authorization` | one of these two | `Basic base64(client_id + ":" + client_secret)` — see [Authentication](/docs/authentication#a-note-on-encoding) for why no percent-encoding step is needed | ## Body (form fields) | Field | Required | Value | |---|---|---| | `grant_type` | yes | `client_credentials` | | `client_id` + `client_secret` | only if not using `Authorization: Basic` | sending both the header and the body fields is `400 invalid_request` | | `scope` | no | space-separated subset of the client's scopes; omitted = every scope the client holds | #### Request ```bash curl -s -X POST https://sail-club-server.cloud.run/api/v1/oauth/token \ -u "scc_3f9a1c0d2b7e4a6f8c1d2e3f:scs_q0vXexampleSecretDoNotUseThisOne43chars" \ -d grant_type=client_credentials \ -d "scope=events:read results:read" ``` ```javascript const creds = Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); const res = await fetch('https://sail-club-server.cloud.run/api/v1/oauth/token', { method: 'POST', headers: { Authorization: `Basic ${creds}`, 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ grant_type: 'client_credentials', scope: 'events:read results:read' }), }); if (!res.ok) throw new Error(`token request failed: ${res.status}`); const token = await res.json(); ``` ```python import requests from requests.auth import HTTPBasicAuth res = requests.post( "https://sail-club-server.cloud.run/api/v1/oauth/token", auth=HTTPBasicAuth(client_id, client_secret), data={"grant_type": "client_credentials", "scope": "events:read results:read"}, timeout=10, ) res.raise_for_status() token = res.json() ``` ```csharp using var client = new HttpClient { BaseAddress = new Uri("https://sail-club-server.cloud.run") }; var authHeader = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authHeader); var form = new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "client_credentials", ["scope"] = "events:read results:read", }); var res = await client.PostAsync("/api/v1/oauth/token", form); res.EnsureSuccessStatusCode(); var token = await res.Content.ReadFromJsonAsync(); ``` ```php true, CURLOPT_USERPWD => "$clientId:$clientSecret", CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query([ 'grant_type' => 'client_credentials', 'scope' => 'events:read results:read', ]), ]); $token = json_decode(curl_exec($ch), true); ``` #### Response ```json { "access_token": "sca_5f2e1a9c8d7b6f4e3a2c1d0e9f8a7b6c5d4e3f2a1b0c", "token_type": "Bearer", "expires_in": 3600, "scope": "events:read results:read" } ``` `Cache-Control: no-store` — never cache this response. The token is opaque (do not attempt to decode it); treat it as a random string valid for `expires_in` seconds. ## Response fields | Field | Type | Note | |---|---|---| | `access_token` | string | pass as `Authorization: Bearer ` on data calls | | `token_type` | string | always `"Bearer"` | | `expires_in` | int | seconds from now; always `3600` today, but read the field | | `scope` | string | the token's actual (possibly narrowed) scopes, space-separated | ## Errors RFC 6749 style — an `error` field, not `type`/`status`; **no** `error_description`. Our own machine `code` is included alongside for exact branching: | HTTP | `error` | `code` | |---|---|---| | 400 | `invalid_request` | [`API_TOKEN_REQUEST_INVALID`](/docs/errors#API_TOKEN_REQUEST_INVALID) | | 400 | `unsupported_grant_type` | [`API_GRANT_TYPE_UNSUPPORTED`](/docs/errors#API_GRANT_TYPE_UNSUPPORTED) | | 401 | `invalid_client` | [`API_CLIENT_CREDENTIALS_INVALID`](/docs/errors#API_CLIENT_CREDENTIALS_INVALID) | | 400 | `unauthorized_client` | [`API_CLIENT_DISABLED`](/docs/errors#API_CLIENT_DISABLED) / [`API_CLIENT_EXPIRED`](/docs/errors#API_CLIENT_EXPIRED) / [`API_IP_NOT_ALLOWED`](/docs/errors#API_IP_NOT_ALLOWED) | | 400 | `invalid_scope` | [`API_SCOPE_INVALID`](/docs/errors#API_SCOPE_INVALID) (+ `params.scope`) | | 429 | `rate_limited` | [`RATE_LIMIT_EXCEEDED`](/docs/errors#RATE_LIMIT_EXCEEDED) (+ `Retry-After: 60`) | A 401 with `invalid_client` also carries `WWW-Authenticate: Basic realm="sailclub-api"` when Basic auth was used (or when no credentials were sent at all). ## Caching Never cache a token response (`Cache-Control: no-store`). Cache the **token itself**, in your process's memory, for its `expires_in` — see [Authentication](/docs/authentication#refresh-before-expiry-pattern). ```prompt Implement a getAccessToken() function for the Sail Club API (OAuth2 client credentials, RFC 6749 §4.4). POST https://sail-club-server.cloud.run/api/v1/oauth/token Content-Type: application/x-www-form-urlencoded Authorization: Basic base64(client_id + ":" + client_secret) (read client_id/client_secret from env vars) Body: grant_type=client_credentials Success (200, Cache-Control: no-store): { "access_token": string, "token_type": "Bearer", "expires_in": number, "scope": string } Treat access_token as opaque — do not decode or parse it. Failure: RFC 6749 error body { "error": "invalid_request"|"unsupported_grant_type"|"invalid_client"| "unauthorized_client"|"invalid_scope"|"rate_limited", ... } plus a "code" field with our machine code (API_TOKEN_REQUEST_INVALID, API_GRANT_TYPE_UNSUPPORTED, API_CLIENT_CREDENTIALS_INVALID, API_CLIENT_DISABLED, API_CLIENT_EXPIRED, API_IP_NOT_ALLOWED, API_SCOPE_INVALID, RATE_LIMIT_EXCEEDED). On 429, read Retry-After (seconds) and wait that long before retrying. On any other failure, do not retry in a loop — surface it, since it usually means the credential or its scopes need a club-admin fix. Requirements: - Cache the token in memory: { access_token, expiresAt: now + expires_in * 1000 - 60000 } (60s safety margin). Reuse the cached token while expiresAt is in the future; fetch a new one otherwise. - Never fetch a new token per outgoing API call. - A minimal test: two calls to getAccessToken() within the same expiry window make exactly one HTTP request to the token endpoint. Adapt to your actual stack (Node/TypeScript, Python, C#/.NET, PHP, ...) — the endpoint, headers, body, caching rule and error codes above are language-neutral. ``` --- # Build with AI # Build with AI This site is written to be handed to an AI coding agent, not just read by a person. ## Prompt blocks Every method page ([Get access token](/docs/api/token), [Check a token](/docs/api/token-info), [List events](/docs/api/events), [Get event](/docs/api/event), [List races](/docs/api/races), [Race results](/docs/api/race-results), [Event results](/docs/api/event-results)) has a **Prompt for AI agents** panel near the bottom — click to expand, then **Copy**. It's already full text in the page's HTML (never generated on click), so a crawling agent or an `llms-full.txt` reader gets it without any interaction at all. Each prompt is self-contained: base URL, the auth flow, that method's exact request/response shape, its error codes with what to do about each, caching/`ETag` guidance, and what to build (a typed client function plus a minimal test). It ends with "adapt to your actual stack" — the prompts are language-neutral by design; paste the same one into an agent working in Python, TypeScript, C#, PHP, Go, or anything else. ## The full-integration prompt [Overview → Full-integration prompt](/docs/overview#full-integration-prompt) covers the whole flow in one prompt — auth, all five data calls, caching, error handling — enough for an agent to build a small working integration (an events list + one event's results page) in a single pass, without reading any other page on this site. ## Machine-readable companions | File | Use | |---|---| | [`/llms.txt`](/llms.txt) | an index of every page on this site (per [llmstxt.org](https://llmstxt.org)) — point an agent at this to let it choose what to fetch | | [`/llms-full.txt`](/llms-full.txt) | every page's full markdown, prompts included, concatenated into one file — feed this directly into a long-context model instead of crawling | | `/docs/.md` (e.g. [`/docs/api/events.md`](/docs/api/events.md)) | the raw markdown source of any single page, linked from its HTML page via `` | | [`/api/v1/openapi.json`](/api/v1/openapi.json) | OpenAPI 3.1 — the exact, current schema of every endpoint; every field this site documents is checked against it at build time | ## A good workflow 1. Point your agent at [`/llms.txt`](/llms.txt) or the [Overview](/docs/overview) page. 2. For a specific endpoint, hand it that method page's **Prompt** block directly — it's the fastest path to a correct client for just that call. 3. For a whole small integration, use the [full-integration prompt](/docs/overview#full-integration-prompt). 4. Have the agent generate types from [`/api/v1/openapi.json`](/api/v1/openapi.json) rather than hand-transcribing fields from this site, if your stack has an OpenAPI code generator — it'll never drift from what the API actually returns. --- # Data models # Data models Every response is exactly the DTO the equivalent public results page already renders — nothing added, and personal fields (user ids, emails, phone numbers) are never included. This page documents the fields you'll actually use; the definitive, always-current schema is [`GET /api/v1/openapi.json`](/api/v1/openapi.json) — generate your types from it directly if your stack supports that (e.g. `openapi-typescript`, `openapi-python-client`, NSwag for C#). ## Event summary Returned by [List events](/docs/api/events) (`items[]`). | Field | Type | Null? | Meaning | |---|---|---|---| | `id` | string (UUID) | no | use as `eventId` on every other endpoint | | `name` | string | no | | | `location` | string | no | free-text venue | | `startDate` / `endDate` | string (ISO 8601 UTC) | no | | | `status` | string | no | one of `Canceled`, `Completed`, `Registration Open`, `Live`, `Upcoming` (computed server-side from the event's dates and registration window, in that priority order — treat an unrecognized value as `Upcoming` for forward compatibility) | | `coverUrl` | string (URL) | yes | falls back to the poster image server-side; never null just because there's no dedicated cover | | `posterUrl` | string (URL) | yes | | | `entryCount` | number | no | | ## Event detail Returned by [Get event](/docs/api/event). Everything from **Event summary** above, plus: | Field | Type | Null? | Meaning | |---|---|---|---| | `logoUrl` | string (URL) | yes | | | `description` | string | yes | | | `registrationStart` / `registrationEnd` | string (ISO 8601) | yes | | | `website`, `noticeOfRaceUrl`, `resultsUrl`, `liveTrackUrl`, `galleryUrl` | string (URL) | yes | external links the event set | | `externalRegistrationFormUrl`, `externalPaymentUrl` | string (URL) | yes | | | `instagram`, `whatsapp` | string (URL) | yes | the event's own public contact links, not a person's | | `isOfficial` | boolean | yes | | | `type` | string | yes | e.g. `Regatta`, `Series` | | `classes` | string[] | yes | boat classes racing | | `organizerId` / `organizer` | string / object | yes | the main organising club | | `coOrganizers` | array | no (`[]`) | co-host clubs — an event is visible to a co-host's API clients too | | `divisions`, `specialCategories`, `discardRules` | array | yes | see below | | `raceOfficeEnabled` | boolean | yes | optional-on-purpose — check `=== true`, never `!== false` | | `races` | array | no (`[]`) | same shape as **Race summary** below | ## Race summary Returned by [List races](/docs/api/races) and as `races[]` on **Event detail**. | Field | Type | Null? | Meaning | |---|---|---|---| | `id` | string (UUID) | no | use as `raceId` on [Race results](/docs/api/race-results) | | `name` | string | no | | | `scheduledDate` | string (ISO 8601) | no | | | `status` | string | yes | free text set by race management, not a closed enum — values seen today: `Scheduled`, `OnGoing`, `Completed`, `Cancelled` (double-L; the event `status` above uses `Canceled`, single-L — two different fields, two different spellings, both intentional). Treat as display text and drive logic off `hasResults` instead. | | `hasResults` | boolean | no | call race results only once this is `true` | | `isOfficial` | boolean | yes | | | `resultCount` | number | no | | | `raceNo` | number | yes | sequence number within the event | | `raceType` | string | yes | e.g. `Fleet`, `Pursuit` | | `boatModel` | string | yes | set for one-design races | ## Race results Returned by [Race results](/docs/api/race-results). | Field | Type | Null? | Meaning | |---|---|---|---| | `id`, `name`, `eventId`, `eventName`, `scheduledDate` | — | no | race identity | | `courseDistanceNm` | number | yes | nautical miles | | `resultsUpdatedAtUtc` | string (ISO 8601) | yes | | | `results` | array | no (`[]`) | one row per boat, see below | ### Race result row | Field | Type | Null? | Meaning | |---|---|---|---| | `rank` | number | no | | | `sailNumber`, `boatName` | string | no | | | `teamName`, `skipper`, `owner` | string | yes | entered names, not user accounts | | `className`, `divisionName`, `divisionColorHexCode`, `overallGroupName`, `modelClass` | string | yes | | | `status` | string | no | `Finished` or a scoring code — see **Scoring vocabulary** below | | `startTimeUtc`, `finishTimeUtc` | string (ISO 8601) | yes | absent for a non-finishing boat | | `elapsedTime`, `correctedTime` | string (`HH:MM:SS`) | yes | corrected time is after rating adjustment | | `raiting`, `raitingLabel` | number / string | yes | the boat's rating factor used for this race (sic — matches the wire field name) | | `points` | number | no | scoring points for this race | | `penaltyPoints`, `timePenaltySeconds` | number | yes | set when a redress/penalty applied | ## Event results Returned by [Event results](/docs/api/event-results) — four lists, all the same shape family. | List | Row shape | Meaning | |---|---|---| | `results` | scoring result | per-division series standings | | `overall` | overall result | combined standings across divisions in one overall group | | `ratingGroups` | scoring result + `ratingGroup: string` | standings within one rating band (e.g. an IRC class split) | | `specialCategories` | special-category result | standings for an award category (e.g. "Best Corinthian") | ### Scoring / overall result row | Field | Type | Null? | Meaning | |---|---|---|---| | `rank`, `sailNumber`, `boatName` | — | no | | | `skipper`, `owner`, `teamName`, `modelClass` | string | yes | | | `divisionName` | string | yes | absent on `overall` rows | | `score` | number | no | total after discards | | `gross` | number | yes | total before discards, when different | | `points` | array | no (`[]`) | one entry per race — see below | ### Points entry (`points[]`, every list) | Field | Type | Null? | Meaning | |---|---|---|---| | `raceNo` | number | no | | | `score` | number | no | this race's contribution | | `isDiscarded` | boolean | no | dropped under the event's discard rule — excluded from `score`/`gross` when `true` | | `penalty`, `penaltyPoints`, `timePenaltySeconds` | — | yes | | | `isPlaceholder` | boolean | yes | a race the boat is scored for but that hasn't happened/been entered yet | ### Special-category result row | Field | Type | Null? | Meaning | |---|---|---|---| | `categoryName` | string | no | | | `rank`, `sailNumber`, `boatName` | — | no | | | `skipper`, `owner`, `teamName`, `modelClass` | string | yes | | | `totalScore` | number | no | total after discards — same concept as `score` on the scoring/overall/ratingGroups rows above; special-category rows name it `totalScore` instead (sic — matches the wire field name; not worth a breaking rename) | | `points` | array | no (`[]`) | same shape as above | ## Scoring vocabulary For someone who hasn't seen sailboat racing scoring before: - **Corrected time** — a boat's elapsed time adjusted by its rating (handicap), so boats of different speed potential can be compared fairly on one results list. Ranking within a race is by corrected time, not raw elapsed time. - **Discard** — most multi-race series let a sailor drop their worst race(s) from the total; a discarded race still appears in `points[]` (with `isDiscarded: true`) but doesn't count toward `score`. - **DNF** (Did Not Finish), **DNS** (Did Not Start), **OCS** (On Course Side — started early), **RET** (Retired), **DSQ** (Disqualified) — status codes in place of a finish time; each carries its own scoring penalty under the event's rules (typically "finishers + 1" or similar), reflected in `points`/`score` rather than a time. - **Rating** (`raiting`/`raitingLabel`) — the handicap factor applied to a boat's elapsed time to produce its corrected time; lower is generally faster-rated. --- # 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-Control` and `ETag` headers on your side too (see [Caching & freshness](/docs/caching)) so you never poll harder than you need to. - **Versioned.** Everything here is `/api/v1/…`. See [Versioning & changelog](/docs/versioning). ## Base URL ```text https://sail-club-server.cloud.run ``` Every path on this site (`/api/v1/…`) is relative to that host. ## The five-minute path 1. Ask your club's admin for **Settings → API access** credentials (a `client_id` and a `client_secret`, shown once). See [Quickstart](/docs/quickstart). 2. Exchange them for a bearer token: [`POST /api/v1/oauth/token`](/docs/api/token). 3. Call the data endpoints with `Authorization: Bearer `: [events](/docs/api/events) · [event detail](/docs/api/event) · [races](/docs/api/races) · [race results](/docs/api/race-results) · [event results](/docs/api/event-results). 4. Cache the response yourself for at least the `Cache-Control: max-age` you receive back, and use `If-None-Match` with the `ETag` on your next call — see [Caching & freshness](/docs/caching). ## Scopes Two read scopes exist today: `events:read` (events, event detail, races) and `results:read` (race results, event results). Full table: [Scopes](/docs/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](/docs/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](/docs/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](/docs/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 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 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": , "totalItems": } 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=), 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. ``` --- # Scopes # Scopes A client holds one or more scopes, chosen by the club admin when it's created (or changed later). A token inherits its client's scopes, optionally narrowed with `scope=` at the token request. Calling an endpoint without the scope it requires is `403 API_SCOPE_MISSING`. | Scope | Grants | Endpoints | |---|---|---| | `events:read` | the club's published events, event detail, race list | [`GET /api/v1/events`](/docs/api/events) · [`GET /api/v1/events/{eventId}`](/docs/api/event) · [`GET /api/v1/events/{eventId}/races`](/docs/api/races) | | `results:read` | race results, event (series) results | [`GET /api/v1/races/{raceId}/results`](/docs/api/race-results) · [`GET /api/v1/events/{eventId}/results`](/docs/api/event-results) | Both are read-only — there is no write scope today. New endpoint families get new scopes as they ship; an existing scope never silently widens to cover new data. ## Checking a token's scopes Full reference: [Check a token](/docs/api/token-info). ```bash curl -s https://sail-club-server.cloud.run/api/v1/token-info \ -H "Authorization: Bearer $TOKEN" ``` ```json { "client_id": "scc_3f9a…", "club_id": "TRX1001CL", "scope": "events:read results:read", "expires_at": "2026-09-30T10:00:00Z" } ``` Any valid token can call this — it needs no scope of its own, and is never rate-limited the way data calls are. Useful for a health check or for debugging "why am I getting 403s." ## Narrowing a request ```bash curl -s -X POST https://sail-club-server.cloud.run/api/v1/oauth/token \ -u "$SAIL_CLUB_CLIENT_ID:$SAIL_CLUB_CLIENT_SECRET" \ -d grant_type=client_credentials -d "scope=events:read" ``` Requesting a scope the client doesn't hold is `400 invalid_scope` (`API_SCOPE_INVALID`). Omitting `scope` entirely returns a token with every scope the client has. ## Changing scopes The club admin can add or remove scopes for a client at any time (Settings → API access → the client → Edit). A removed scope is also removed from the client's **live** tokens within 60 seconds — you don't need to wait for the token to expire to see the change. --- # Check a token # Check a token Returns what a bearer token actually grants right now: its client, club, scopes and expiry. It needs no scope of its own — any valid token can call it. Useful as a health check, or to debug "why am I getting 403s" by comparing the `scope` field here against what a call actually needs — see [Scopes](/docs/scopes). ## Headers | Header | Required | Value | |---|---|---| | `Authorization` | yes | `Bearer ` | #### Request ```bash curl -s https://sail-club-server.cloud.run/api/v1/token-info \ -H "Authorization: Bearer $TOKEN" ``` ```javascript const res = await fetch('https://sail-club-server.cloud.run/api/v1/token-info', { headers: { Authorization: `Bearer ${token}` }, }); const info = await res.json(); ``` ```python res = requests.get( "https://sail-club-server.cloud.run/api/v1/token-info", headers={"Authorization": f"Bearer {token}"}, timeout=10, ) info = res.json() ``` ```csharp var res = await client.GetAsync("/api/v1/token-info"); res.EnsureSuccessStatusCode(); var info = await res.Content.ReadFromJsonAsync(); ``` ```php true, CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"]]); $info = json_decode(curl_exec($ch), true); ``` #### Response ```json { "client_id": "scc_3f9a1c0d2b7e4a6f8c1d2e3f", "club_id": "TRX1001CL", "scope": "events:read results:read", "expires_at": "2026-09-30T10:00:00Z" } ``` `200` — `Cache-Control: no-store` (this reflects the token's live state, so it is never cached). ## Response fields | Field | Type | Note | |---|---|---| | `client_id` | string | the token's client (`scc_…`) | | `club_id` | string | the club the token's data calls are scoped to | | `scope` | string | the token's actual, space-separated scopes (may be narrower than the client's) | | `expires_at` | string (ISO 8601 UTC) | when this token stops working | ## Errors | HTTP | Code | |---|---| | 401 | [`API_TOKEN_MISSING`](/docs/errors#API_TOKEN_MISSING) / [`API_TOKEN_INVALID`](/docs/errors#API_TOKEN_INVALID) / [`API_CLIENT_DISABLED`](/docs/errors#API_CLIENT_DISABLED) / [`API_CLIENT_EXPIRED`](/docs/errors#API_CLIENT_EXPIRED) | | 403 | [`API_IP_NOT_ALLOWED`](/docs/errors#API_IP_NOT_ALLOWED) | ## Caching Never cached (`no-store`) — it exists to show live state, so a stale copy would defeat its purpose. Call it only when you actually need to check a token, not on every request. ```prompt Implement getTokenInfo() for the Sail Club API — a lightweight check of a bearer token's own state, useful for a health check or to debug "why am I getting 403s". GET https://sail-club-server.cloud.run/api/v1/token-info Authorization: Bearer Success 200 (Cache-Control: no-store, never cache this): { "client_id", "club_id", "scope", "expires_at" }. This endpoint needs no scope of its own — any valid token can call it. 401 API_TOKEN_MISSING/API_TOKEN_INVALID/API_CLIENT_DISABLED/API_CLIENT_EXPIRED, 403 API_IP_NOT_ALLOWED: same handling as every other call — see the full-integration prompt on /docs/overview. Adapt to your actual stack — the endpoint and response shape above are language-neutral. ``` --- # List events # List events Returns the calling club's **published** events (main club or co-host), same data as its public results page's event list. ## Headers | Header | Required | Value | |---|---|---| | `Authorization` | yes | `Bearer ` | ## Query parameters | Param | Required | Default | Range | |---|---|---|---| | `page` | no | `1` | ≥ 1 | | `pageSize` | no | `20` | 1–100 | Full paging rules: [Pagination](/docs/pagination). #### Request ```bash curl -s "https://sail-club-server.cloud.run/api/v1/events?page=1&pageSize=20" \ -H "Authorization: Bearer $TOKEN" ``` ```javascript const res = await fetch('https://sail-club-server.cloud.run/api/v1/events?page=1&pageSize=20', { headers: { Authorization: `Bearer ${token}` }, }); if (res.status === 304) { /* use your cached copy */ } const page = await res.json(); ``` ```python res = requests.get( "https://sail-club-server.cloud.run/api/v1/events", params={"page": 1, "pageSize": 20}, headers={"Authorization": f"Bearer {token}"}, timeout=10, ) res.raise_for_status() page = res.json() ``` ```csharp var res = await client.GetAsync("/api/v1/events?page=1&pageSize=20"); res.EnsureSuccessStatusCode(); var page = await res.Content.ReadFromJsonAsync(); ``` ```php true, CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"], ]); $page = json_decode(curl_exec($ch), true); ``` #### Response ```json { "items": [ { "id": "1143117a-9c2b-4e7a-8f1d-2b6a9c4e7f10", "name": "Cup Regatta 2026", "location": "Datça Marina", "startDate": "2026-10-11T09:00:00Z", "endDate": "2026-10-12T16:00:00Z", "status": "Completed", "coverUrl": "https://cdn.sailracing.club/clubs/TRX1001CL/events/cup-regatta-2026/cover.jpg", "posterUrl": "https://cdn.sailracing.club/clubs/TRX1001CL/events/cup-regatta-2026/poster.jpg", "entryCount": 42 } ], "nextPage": null, "totalItems": 1 } ``` `200` — `Cache-Control: private, max-age=` · `ETag` · `Last-Modified` · `X-Data-As-Of`. See [Caching & freshness](/docs/caching). Never 404s or 503s cached. ## Response fields Full field table: [Data models → Event summary](/docs/data-models#event-summary). Sort: newest `startDate` first, ties broken by id. ## Errors | HTTP | Code | |---|---| | 400 | [`API_PARAM_INVALID`](/docs/errors#API_PARAM_INVALID) — bad `page`/`pageSize` | | 401 | [`API_TOKEN_MISSING`](/docs/errors#API_TOKEN_MISSING) / [`API_TOKEN_INVALID`](/docs/errors#API_TOKEN_INVALID) | | 403 | [`API_SCOPE_MISSING`](/docs/errors#API_SCOPE_MISSING) / [`API_IP_NOT_ALLOWED`](/docs/errors#API_IP_NOT_ALLOWED) | | 429 | [`RATE_LIMIT_EXCEEDED`](/docs/errors#RATE_LIMIT_EXCEEDED) | | 503 | [`API_UNAVAILABLE`](/docs/errors#API_UNAVAILABLE) | ## Caching Cache per `page`/`pageSize` combination you actually call; send `If-None-Match` on repeat calls. See [Caching & freshness](/docs/caching). ```prompt Implement listEvents({ page = 1, pageSize = 20 } = {}) for the Sail Club API. GET https://sail-club-server.cloud.run/api/v1/events?page=&pageSize= Authorization: Bearer (see the token prompt on /docs/api/token) Success 200: { "items": [ { "id": string, "name": string, "location": string, "startDate": string (ISO 8601), "endDate": string, "status": string, "coverUrl": string|null, "posterUrl": string|null, "entryCount": number } ], "nextPage": number|null, "totalItems": number } Response headers: Cache-Control: private, max-age=; ETag: ""; Last-Modified; X-Data-As-Of. Store the ETag per (page,pageSize) key; send it back as If-None-Match on the next call to the same key. A 304 has no body — keep serving your cached items for that key. Errors: 400 API_PARAM_INVALID (bad page/pageSize) · 401 API_TOKEN_MISSING/API_TOKEN_INVALID (refresh token, retry once) · 403 API_SCOPE_MISSING/API_IP_NOT_ALLOWED (not retryable — alert a human) · 429 RATE_LIMIT_EXCEEDED / 503 API_UNAVAILABLE (read Retry-After, back off exactly that long). Requirements: - A function that pages through every event (loop while nextPage is not null) and returns the full list. - Respect ETag/If-None-Match per page as described above. - A minimal test: a mocked 304 response reuses the previously cached items without discarding them. Adapt to your actual stack — the endpoint, params, response shape and header rules above are language-neutral. ``` --- # IP allowlist # IP allowlist Optional, per client, set by the club admin in **Settings → API access**. Empty (the default) means any source IP may use the credential. When set, it is enforced on **every** call that authenticates with that client — the token endpoint and every data endpoint. ## What counts as "the caller's IP" The address compared against the allowlist (and recorded as the client's `usage.lastUsedIp` in the panel) is the address our infrastructure's own edge saw your connection come from — not a header you can set. There is nothing to configure on your side; just make sure the **outbound** IP your server actually uses to reach us is the one on the allowlist (not, for example, a different NAT/proxy egress than you expect). ## Entry formats - A single address: `203.0.113.7` (stored/normalized as `/32`) or an IPv6 address (`/128`). - A CIDR range: `198.51.100.0/24`, `2001:db8::/32`. The base address is masked server-side, so `10.1.2.3/8` is stored (and matches) as `10.0.0.0/8`. - Up to 50 entries per client. Zone ids (`%eth0`) are rejected. Duplicates are dropped automatically. ## What a denial looks like | Where | Response | |---|---| | Token request | `400 unauthorized_client`, `code: "API_CLIENT_DISABLED"` is *not* this — IP denial on the token endpoint is `code: "API_IP_NOT_ALLOWED"` | | Data call | `403`, `application/problem+json`, `code: "API_IP_NOT_ALLOWED"` | Both are indistinguishable from "this credential genuinely doesn't have access" from the outside on purpose — don't build logic that tries to detect "IP vs. scope" from the HTTP status alone; the `code` field is the one to branch on. See [Errors](/docs/errors#API_IP_NOT_ALLOWED). ## Changing the list Effective **within 60 seconds** everywhere (client state is cached at our end for up to a minute) — no need to wait for a token to expire after the club admin adds or removes an address. --- # Quickstart # Quickstart ## 1. Get credentials from your club admin Only a **club admin** can create API credentials, in the panel under **Settings → API access**. Ask them to: 1. Open **Settings → API access** and click **Create client**. 2. Give it a name (e.g. "Club website"), pick scopes (`events:read`, `results:read`), optionally restrict caller IPs, accept the [API Terms](/docs/api-terms) the first time. 3. Copy the **client ID** and **client secret** shown on the confirmation screen — the secret is shown **once** and cannot be retrieved again (only rotated, which invalidates the old one). Store both as environment variables (`SAIL_CLUB_CLIENT_ID`, `SAIL_CLUB_CLIENT_SECRET`). Never commit them, never send them to a browser. ## 2. Get a token ```prompt Exchange a Sail Club API client_id/client_secret for a bearer token: POST https://sail-club-server.cloud.run/api/v1/oauth/token as application/x-www-form-urlencoded, with grant_type=client_credentials and the client_id/client_secret sent as HTTP Basic auth. Cache the returned access_token in memory for its expires_in seconds; do not fetch a new one per call. ``` #### Request ```bash curl -s -X POST https://sail-club-server.cloud.run/api/v1/oauth/token \ -u "$SAIL_CLUB_CLIENT_ID:$SAIL_CLUB_CLIENT_SECRET" \ -d grant_type=client_credentials ``` ```javascript const creds = Buffer.from(`${process.env.SAIL_CLUB_CLIENT_ID}:${process.env.SAIL_CLUB_CLIENT_SECRET}`).toString('base64'); const res = await fetch('https://sail-club-server.cloud.run/api/v1/oauth/token', { method: 'POST', headers: { Authorization: `Basic ${creds}`, 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=client_credentials', }); const { access_token, expires_in } = await res.json(); ``` ```python import os, requests res = requests.post( "https://sail-club-server.cloud.run/api/v1/oauth/token", auth=(os.environ["SAIL_CLUB_CLIENT_ID"], os.environ["SAIL_CLUB_CLIENT_SECRET"]), data={"grant_type": "client_credentials"}, ) token = res.json()["access_token"] ``` ```csharp using var client = new HttpClient { BaseAddress = new Uri("https://sail-club-server.cloud.run") }; var clientId = Environment.GetEnvironmentVariable("SAIL_CLUB_CLIENT_ID"); var clientSecret = Environment.GetEnvironmentVariable("SAIL_CLUB_CLIENT_SECRET"); var authHeader = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authHeader); var res = await client.PostAsync("/api/v1/oauth/token", new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "client_credentials" })); var body = await res.Content.ReadFromJsonAsync(); var token = body.GetProperty("access_token").GetString(); ``` ```php true, CURLOPT_USERPWD => "$clientId:$clientSecret", CURLOPT_POST => true, CURLOPT_POSTFIELDS => 'grant_type=client_credentials', ]); $token = json_decode(curl_exec($ch), true)['access_token']; ``` Full reference: [`POST /api/v1/oauth/token`](/docs/api/token). ## 3. Make your first call ```bash curl -s https://sail-club-server.cloud.run/api/v1/events \ -H "Authorization: Bearer $TOKEN" ``` You'll get back the club's published events, the same list its public results pages show — see [List events](/docs/api/events) for the full shape. ## 4. Next - [Authentication](/docs/authentication) — token lifetime, refresh pattern, secret handling. - [Caching & freshness](/docs/caching) — don't poll harder than the data actually changes. - [Errors](/docs/errors) — what every `code` means and how to react to it. - [Build with AI](/docs/build-with-ai) — hand a coding agent the [full-integration prompt](/docs/overview#full-integration-prompt). --- # Support # Support ## Credential and access questions Your **club admin** manages your credentials, scopes and IP allowlist in the panel's **Settings → API access** — see [Quickstart](/docs/quickstart). Most "why am I getting a 401/403" questions are answered there faster than by contacting us (a disabled client, an expired client, a missing scope, an IP not on the allowlist — the admin can see and fix all four directly). ## Something on this site is wrong If a docs page and [`GET /api/v1/openapi.json`](/api/v1/openapi.json) disagree, the spec is correct — please tell us so we can fix the page; see [Versioning & changelog](/docs/versioning). ## Everything else Reach us at the club's support contact shown in the panel, or via the help pages at [/help](/help) — the panel's help assistant can answer questions about the **API access** module itself (creating, rotating, disabling clients) using the same corpus as this site's [Overview](/docs/overview). If you're reporting an error, include the response's `traceId` field (present on `500 INTERNAL_ERROR` and worth grabbing on any unexpected failure) — see [Errors](/docs/errors). --- # Get event # Get event Full detail for one event: the same data its public event page renders, including its race list. ## Headers | Header | Required | Value | |---|---|---| | `Authorization` | yes | `Bearer ` | ## Path parameters | Param | Type | Note | |---|---|---| | `eventId` | UUID | from [List events](/docs/api/events); another club's id, a draft, or an unknown id is `404 EVENT_NOT_FOUND` (never 403) | #### Request ```bash curl -s https://sail-club-server.cloud.run/api/v1/events/1143117a-9c2b-4e7a-8f1d-2b6a9c4e7f10 \ -H "Authorization: Bearer $TOKEN" ``` ```javascript const res = await fetch(`https://sail-club-server.cloud.run/api/v1/events/${eventId}`, { headers: { Authorization: `Bearer ${token}` }, }); if (res.status === 404) { /* unknown / not this club's / not published */ } const event = await res.json(); ``` ```python res = requests.get( f"https://sail-club-server.cloud.run/api/v1/events/{event_id}", headers={"Authorization": f"Bearer {token}"}, timeout=10, ) if res.status_code == 404: ... # unknown / not this club's / not published res.raise_for_status() event = res.json() ``` ```csharp var res = await client.GetAsync($"/api/v1/events/{eventId}"); if (res.StatusCode == HttpStatusCode.NotFound) { /* unknown / not this club's / not published */ } res.EnsureSuccessStatusCode(); var evt = await res.Content.ReadFromJsonAsync(); ``` ```php true, CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"], ]); $event = json_decode(curl_exec($ch), true); ``` #### Response ```json { "id": "1143117a-9c2b-4e7a-8f1d-2b6a9c4e7f10", "name": "Cup Regatta 2026", "logoUrl": "https://cdn.sailracing.club/clubs/TRX1001CL/logo.png", "location": "Datça Marina", "description": "Three-day fleet-racing regatta for ORC and one-design classes.", "startDate": "2026-10-11T09:00:00Z", "endDate": "2026-10-12T16:00:00Z", "registrationStart": "2026-08-01T00:00:00Z", "registrationEnd": "2026-10-05T23:59:00Z", "status": "Completed", "coverUrl": "https://cdn.sailracing.club/clubs/TRX1001CL/events/cup-regatta-2026/cover.jpg", "posterUrl": "https://cdn.sailracing.club/clubs/TRX1001CL/events/cup-regatta-2026/poster.jpg", "website": "https://example-club.org/cup-regatta-2026", "noticeOfRaceUrl": "https://example-club.org/cup-regatta-2026/nor.pdf", "resultsUrl": null, "liveTrackUrl": null, "instagram": "https://instagram.com/exampleclub", "whatsapp": "https://wa.me/905551234567", "isOfficial": true, "entryCount": 42, "type": "Regatta", "classes": ["ORC", "J/70"], "organizerId": "TRX1001CL", "organizer": { "id": "TRX1001CL", "name": "Example Sailing Club" }, "coOrganizers": [], "races": [ { "id": "9a1c0d2b-7e4a-46f8-9c1d-2e3f9a1c0d2b", "name": "Race 1", "scheduledDate": "2026-10-11T10:00:00Z", "status": "Completed", "hasResults": true, "resultCount": 42, "raceNo": 1 } ] } ``` `200` — same caching headers as every data endpoint; see [Caching & freshness](/docs/caching). ## Response fields Full field table: [Data models → Event detail](/docs/data-models#event-detail). `races[]` is the same shape [List races](/docs/api/races) returns for this event. ## Errors | HTTP | Code | |---|---| | 404 | [`EVENT_NOT_FOUND`](/docs/errors#EVENT_NOT_FOUND) | | 401 | [`API_TOKEN_MISSING`](/docs/errors#API_TOKEN_MISSING) / [`API_TOKEN_INVALID`](/docs/errors#API_TOKEN_INVALID) | | 403 | [`API_SCOPE_MISSING`](/docs/errors#API_SCOPE_MISSING) / [`API_IP_NOT_ALLOWED`](/docs/errors#API_IP_NOT_ALLOWED) | | 429 | [`RATE_LIMIT_EXCEEDED`](/docs/errors#RATE_LIMIT_EXCEEDED) | | 503 | [`API_UNAVAILABLE`](/docs/errors#API_UNAVAILABLE) | ## Caching One entry per `eventId` in your cache; an event's detail rarely changes after it starts, so this is a very cheap one to hold onto and revalidate with `If-None-Match` on your own schedule. ```prompt Implement getEvent(eventId) for the Sail Club API. GET https://sail-club-server.cloud.run/api/v1/events/{eventId} Authorization: Bearer Success 200: the full event detail object (id, name, description, startDate/endDate, status, cover/poster URLs, website/noticeOfRaceUrl links, entryCount, races: [{ id, name, scheduledDate, status, hasResults, resultCount, raceNo, ... }], and more — fetch GET /api/v1/openapi.json for the exact schema, do not guess fields not listed there). 404 EVENT_NOT_FOUND: unknown id, another club's event, or not published — do not retry, treat as "not found". 401/403/429/503: same handling as every other call — see the full-integration prompt on /docs/overview. Cache the response per eventId with its ETag; send If-None-Match on revalidation. Adapt to your actual stack — the endpoint, path param and response shape above are language-neutral. ``` --- # Authentication # Authentication The API uses **OAuth 2.0 Client Credentials** (RFC 6749 §4.4) — the standard machine-to-machine grant every HTTP library and OAuth SDK already supports. There is no user login, no redirect flow, no refresh token. ## The two credentials | Credential | Looks like | Lifetime | Who has it | |---|---|---|---| | `client_id` | `scc_3f9a1c0d2b7e4a6f8c1d2e3f` | until the client is deleted | public — identifies the client, not a secret itself | | `client_secret` | `scs_q0vX…` (43 chars) | until rotated | **secret** — shown once at creation/rotation, stored at our end only as a salted hash | | access token | `sca_…` (43 chars) | **1 hour** | your server, in memory — never persisted, never logged | Losing the `client_secret`? It cannot be retrieved — ask the club admin to **rotate** it (Settings → API access → the client → Rotate secret). Rotation immediately invalidates the old secret and every access token issued from it. ## A note on encoding `client_id`/`client_secret` only ever use the base64url alphabet (letters, digits, `-`, `_`) plus their fixed `scc_`/`scs_` prefix — they never contain a `:` or any character that needs percent-encoding. Every sample on this site sends them as-is, `client_id:client_secret`, base64-encoded for the `Authorization: Basic` header; you do not need to URL-encode either value first. ## Getting a token `POST /api/v1/oauth/token` — see the full reference: [Get access token](/docs/api/token). Two equivalent ways to send the credentials: HTTP Basic (recommended — never appears in a request body log), or as `client_id`/`client_secret` form fields. Sending both at once is a `400 API_TOKEN_REQUEST_INVALID`. ```bash curl -s -X POST https://sail-club-server.cloud.run/api/v1/oauth/token \ -u "scc_3f9a…:scs_q0vX…" \ -d grant_type=client_credentials ``` The response carries `expires_in` (always `3600`, but read the field — don't hard-code it) and `scope` (the token's actual scopes, which may be a client asked for a narrower `scope=` than it holds). ## Refresh-before-expiry pattern ```text if no cached token OR cached token expires within the next ~60s: fetch a new token, cache { access_token, expires_at = now + expires_in } use the cached access_token on the call if the call returns 401: fetch exactly one new token and retry the call once if it still 401s, stop and alert a human — the credential itself is the problem ``` Don't fetch a token per API call — the token endpoint has its own, tighter rate limit (20/min per client and per caller IP — see [Rate limits](/docs/rate-limits)) and every extra round-trip is latency you don't need, since one token covers an hour of calls. ## Secret handling - Read `client_id`/`client_secret` from environment variables or a secrets manager — never commit them, never put them in a repository, never send them to a browser (this API has no CORS and rejects the Firebase user tokens the panel itself uses, so there is no browser-safe way to call it anyway). - Never log the access token or the client secret, including in error messages or crash reports. - One client per integration/environment (e.g. a separate client for staging vs. production) makes rotation and revocation blast-radius small. ## Rotation The club admin can rotate a secret at any time from the panel. Effect: the old secret stops working for new token requests immediately, and every access token already issued from it stops working **within 60 seconds** (client state is cached at our end for up to a minute). Build your token refresh path to treat a sudden run of 401s as "credential changed, alert a human, don't just retry forever." ## What a Firebase (panel) token cannot do The panel's own users authenticate with Firebase ID tokens. Those are a **completely separate scheme** from this API: a Firebase token is rejected on every `/api/v1/*` route (`401 API_TOKEN_INVALID`), and an API access token is rejected everywhere else. If you're building against this API, you should never see or handle a Firebase token at all. --- # Errors # Errors Every response on `/api/v1` other than the token endpoint is `application/problem+json` on failure: ```json { "type": "https://sailracing.club/docs/errors#API_TOKEN_INVALID", "status": 401, "code": "API_TOKEN_INVALID", "params": {}, "traceId": "00-4bf9…-01" } ``` `type` is this page, anchored at the code (the anchors below match exactly). `params` is only present when the code carries one (e.g. `API_SCOPE_MISSING`'s `scope`). There is no `title`/`detail` sentence to parse — build your own copy from the `code`, using the table below. The token endpoint follows RFC 6749 instead (an `error` field, not `type`/`status`) — see [Get access token](/docs/api/token) for its error shape specifically. ## Quick table | HTTP | Code | Where | |---|---|---| | 400 | [`API_TOKEN_REQUEST_INVALID`](#API_TOKEN_REQUEST_INVALID) | token endpoint | | 400 | [`API_GRANT_TYPE_UNSUPPORTED`](#API_GRANT_TYPE_UNSUPPORTED) | token endpoint | | 401 | [`API_CLIENT_CREDENTIALS_INVALID`](#API_CLIENT_CREDENTIALS_INVALID) | token endpoint | | 400 | [`API_SCOPE_INVALID`](#API_SCOPE_INVALID) | token endpoint | | 401 | [`API_TOKEN_MISSING`](#API_TOKEN_MISSING) | data calls | | 401 | [`API_TOKEN_INVALID`](#API_TOKEN_INVALID) | data calls | | 401 | [`API_CLIENT_DISABLED`](#API_CLIENT_DISABLED) | token endpoint + data calls | | 401 | [`API_CLIENT_EXPIRED`](#API_CLIENT_EXPIRED) | token endpoint + data calls | | 403 | [`API_IP_NOT_ALLOWED`](#API_IP_NOT_ALLOWED) | token endpoint + data calls | | 403 | [`API_SCOPE_MISSING`](#API_SCOPE_MISSING) | data calls | | 400 | [`API_PARAM_INVALID`](#API_PARAM_INVALID) | list paging | | 404 | [`EVENT_NOT_FOUND`](#EVENT_NOT_FOUND) | event/race lookups | | 404 | [`RACE_NOT_FOUND`](#RACE_NOT_FOUND) | race lookups | | 429 | [`RATE_LIMIT_EXCEEDED`](#RATE_LIMIT_EXCEEDED) | any call | | 503 | [`API_UNAVAILABLE`](#API_UNAVAILABLE) | data calls | | 500 | [`INTERNAL_ERROR`](#INTERNAL_ERROR) | any call | ## Token endpoint errors ### API_TOKEN_REQUEST_INVALID **400.** The request wasn't a valid `client_credentials` form post — e.g. credentials sent in both the `Authorization` header and the form body, or no `Content-Type: application/x-www-form-urlencoded`. **Fix:** send exactly one of Basic auth or form-body credentials, as `application/x-www-form-urlencoded`. ### API_GRANT_TYPE_UNSUPPORTED **400.** `grant_type` was missing or wasn't `client_credentials` (this API doesn't support any other grant). **Fix:** always send `grant_type=client_credentials`. ### API_CLIENT_CREDENTIALS_INVALID **401.** The `client_id` is unknown, or the `client_secret` is wrong — the same answer either way, so a brute-force attempt can't tell which half failed. **Fix:** double-check both values with the club admin; if correct, the secret may have been rotated — ask for a fresh one. ### API_SCOPE_INVALID **400.** The `scope=` you asked for at the token endpoint includes something the client doesn't hold (or doesn't exist). `params.scope` names the offending value. **Fix:** request a subset of the client's actual scopes, or omit `scope` to get all of them. See [Scopes](/docs/scopes). ## Shared (token endpoint + data calls) ### API_CLIENT_DISABLED **401** on data calls, **400** (`unauthorized_client`) on the token endpoint. The club admin disabled this client. **Fix:** ask the club admin to re-enable it (Settings → API access) — nothing you can do from the integration side. ### API_CLIENT_EXPIRED **401** on data calls, **400** (`unauthorized_client`) on the token endpoint. The client passed its `expiresAt` date. **Fix:** ask the club admin to extend or remove the expiry, or issue a new client. ### API_IP_NOT_ALLOWED **403** on data calls, **400** (`unauthorized_client`) on the token endpoint. The caller's IP isn't on the client's [IP allowlist](/docs/ip-allowlist). **Fix:** ask the club admin to add your server's outbound IP, or remove the restriction if it isn't needed. ## Data call errors ### API_TOKEN_MISSING **401.** No `Authorization` header was sent. **Fix:** send `Authorization: Bearer ` on every data call — see [Authentication](/docs/authentication). ### API_TOKEN_INVALID **401.** The bearer token is unknown, expired, was killed by a secret rotation, or isn't one of ours at all (e.g. you sent a Firebase/panel-user token by mistake — those are rejected here). **Fix:** fetch a fresh token from [`POST /api/v1/oauth/token`](/docs/api/token) and retry once; if it still fails, the credential itself needs attention. ### API_SCOPE_MISSING **403.** The endpoint needs a scope your token doesn't have; `params.scope` names it. **Fix:** ask the club admin to add the scope to the client (Settings → API access → the client → Edit), then fetch a new token. See [Scopes](/docs/scopes). ### API_PARAM_INVALID **400.** A paging parameter on [`GET /api/v1/events`](/docs/api/events) was out of range; `params.name` is `"page"` or `"pageSize"`. **Fix:** `page` ≥ 1, `pageSize` 1–100. ### EVENT_NOT_FOUND **404.** The event id doesn't exist, isn't this club's (main or co-host), isn't published, or is a legacy id — all answered identically as "not found" rather than leaking which case it was. **Fix:** re-check the id came from [`GET /api/v1/events`](/docs/api/events) for this same credential; don't retry with the same id. ### RACE_NOT_FOUND **404.** Same rule as `EVENT_NOT_FOUND`, for a race id. A malformed (non-UUID) id is also a 404, not a 400. ## Cross-cutting ### RATE_LIMIT_EXCEEDED **429.** Too many requests. Data calls: 60/min per client (default, may be raised per club) — **never** per IP. The token endpoint: 20/min per `client_id` **and** 20/min per caller IP, whichever is hit first. `Retry-After: 60` on both — always an integer number of seconds, never an HTTP date. **Fix:** back off for exactly `Retry-After`, with jitter if you're calling on behalf of many clients/events from one process; see [Rate limits](/docs/rate-limits). ### API_UNAVAILABLE **503.** The read model behind `/api/v1` didn't answer in time — nothing is cached for this response. `Retry-After: 30` (same integer-seconds format as `429`, a shorter number because this is a different, usually brief problem). **Fix:** treat exactly like `RATE_LIMIT_EXCEEDED` — wait and retry; this is not your integration's fault. ### INTERNAL_ERROR **500.** An unexpected server error; `traceId` is included. **Fix:** retry up to 3 attempts total, with exponential backoff (1s, then 2s, then 4s); if it still fails, contact [support](/docs/support) with the `traceId`. --- # List races # List races An event's races. Identical data to the `races` field on [Get event](/docs/api/event) — a separate endpoint for callers who only need the race list without the rest of the event detail. ## Headers | Header | Required | Value | |---|---|---| | `Authorization` | yes | `Bearer ` | ## Path parameters | Param | Type | Note | |---|---|---| | `eventId` | UUID | `404 EVENT_NOT_FOUND` if unknown / another club's / not published | #### Request ```bash curl -s https://sail-club-server.cloud.run/api/v1/events/1143117a-9c2b-4e7a-8f1d-2b6a9c4e7f10/races \ -H "Authorization: Bearer $TOKEN" ``` ```javascript const res = await fetch(`https://sail-club-server.cloud.run/api/v1/events/${eventId}/races`, { headers: { Authorization: `Bearer ${token}` }, }); const races = await res.json(); ``` ```python res = requests.get( f"https://sail-club-server.cloud.run/api/v1/events/{event_id}/races", headers={"Authorization": f"Bearer {token}"}, timeout=10, ) races = res.json() ``` ```csharp var res = await client.GetAsync($"/api/v1/events/{eventId}/races"); res.EnsureSuccessStatusCode(); var races = await res.Content.ReadFromJsonAsync>(); ``` ```php true, CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"]]); $races = json_decode(curl_exec($ch), true); ``` #### Response ```json [ { "id": "9a1c0d2b-7e4a-46f8-9c1d-2e3f9a1c0d2b", "name": "Race 1", "scheduledDate": "2026-10-11T10:00:00Z", "status": "Completed", "hasResults": true, "isOfficial": true, "resultCount": 42, "raceNo": 1, "raceType": "Fleet", "boatModel": null }, { "id": "2b6a9c4e-7f10-4a1c-8d2b-7e4a6f8c1d2e", "name": "Race 2", "scheduledDate": "2026-10-11T13:00:00Z", "status": "Completed", "hasResults": true, "isOfficial": true, "resultCount": 41, "raceNo": 2, "raceType": "Fleet", "boatModel": null } ] ``` `200` — same caching headers as every data endpoint; the array itself carries the `ETag`/`Last-Modified`. ## Response fields Full field table: [Data models → Race summary](/docs/data-models#race-summary). Use each item's `id` as the `raceId` for [Race results](/docs/api/race-results) once `hasResults` is `true`. ## Errors | HTTP | Code | |---|---| | 404 | [`EVENT_NOT_FOUND`](/docs/errors#EVENT_NOT_FOUND) | | 401 | [`API_TOKEN_MISSING`](/docs/errors#API_TOKEN_MISSING) / [`API_TOKEN_INVALID`](/docs/errors#API_TOKEN_INVALID) | | 403 | [`API_SCOPE_MISSING`](/docs/errors#API_SCOPE_MISSING) / [`API_IP_NOT_ALLOWED`](/docs/errors#API_IP_NOT_ALLOWED) | | 429 | [`RATE_LIMIT_EXCEEDED`](/docs/errors#RATE_LIMIT_EXCEEDED) | | 503 | [`API_UNAVAILABLE`](/docs/errors#API_UNAVAILABLE) | ## Caching One cache entry per `eventId`; races only stop changing once `hasResults` is `true` for all of them, so it's safe to revalidate on your normal schedule throughout an event and less often afterwards. ```prompt Implement listRaces(eventId) for the Sail Club API. GET https://sail-club-server.cloud.run/api/v1/events/{eventId}/races Authorization: Bearer Success 200: an array of { id, name, scheduledDate, status, hasResults, isOfficial, resultCount, raceNo, raceType, boatModel, ... } — fetch GET /api/v1/openapi.json for the exact schema. 404 EVENT_NOT_FOUND: unknown/other club's/unpublished event — not retryable. 401/403/429/503: same handling as every other call — see the full-integration prompt on /docs/overview. For each race where hasResults is true, this is the id to pass to the race-results call (GET /api/v1/races/{raceId}/results). Adapt to your actual stack — the endpoint, path param and response shape above are language-neutral. ``` --- # Rate limits # Rate limits | Endpoint | Limit | Partitioned by | |---|---|---| | `POST /api/v1/oauth/token` | 20 requests / minute | per `client_id` **and** per caller IP (whichever is hit first) | | every other `/api/v1/*` call | 60 requests / minute by default (may be raised per club) | per client only — never per IP | Exceeding either is `429 RATE_LIMIT_EXCEEDED`, with `Retry-After: 60` on both. `Retry-After` is always an integer number of seconds (never an HTTP date). There is no burst allowance beyond the stated rate. ```json { "type": "https://sailracing.club/docs/errors#RATE_LIMIT_EXCEEDED", "status": 429, "code": "RATE_LIMIT_EXCEEDED", "traceId": "…" } ``` A separate case, [`503 API_UNAVAILABLE`](/docs/errors#API_UNAVAILABLE) (the read model didn't answer — nothing to do with your call rate), carries a shorter `Retry-After: 30` — same integer-seconds format, different number because it's a different, usually brief problem. Both `429` and `503` are handled the same way: wait exactly `Retry-After` seconds, then retry. ## Staying under the limit You almost never need 60 calls/min for one club's data — results are cached for up to an hour at our end ([Caching & freshness](/docs/caching)), so polling faster than your `Cache-Control: max-age` just spends your rate-limit budget on 304s (which still count against the limit). A sensible integration: - Poll each endpoint you use **once**, on your own schedule (e.g. every 5–15 minutes), not per page view. - Serve your own visitors from your own cache/database, not from a live call per request. - If you operate many clients (an agency serving several clubs), give each club its own credential rather than fanning one client's limit out across all of them. ## What backs off automatically The token you hold does not need refreshing more than once an hour, so a correct [refresh-before-expiry](/docs/authentication#refresh-before-expiry-pattern) implementation naturally stays far under the token endpoint's 20/min limit even under load. --- # Race results # Race results Full results for one race — the same page a club's public "race results" tab shows. ## Headers | Header | Required | Value | |---|---|---| | `Authorization` | yes | `Bearer ` | ## Path parameters | Param | Type | Note | |---|---|---| | `raceId` | UUID | from [List races](/docs/api/races); unknown, non-UUID, or another club's ⇒ `404 RACE_NOT_FOUND` | #### Request ```bash curl -s https://sail-club-server.cloud.run/api/v1/races/9a1c0d2b-7e4a-46f8-9c1d-2e3f9a1c0d2b/results \ -H "Authorization: Bearer $TOKEN" ``` ```javascript const res = await fetch(`https://sail-club-server.cloud.run/api/v1/races/${raceId}/results`, { headers: { Authorization: `Bearer ${token}` }, }); const results = await res.json(); ``` ```python res = requests.get( f"https://sail-club-server.cloud.run/api/v1/races/{race_id}/results", headers={"Authorization": f"Bearer {token}"}, timeout=10, ) results = res.json() ``` ```csharp var res = await client.GetAsync($"/api/v1/races/{raceId}/results"); res.EnsureSuccessStatusCode(); var page = await res.Content.ReadFromJsonAsync(); ``` ```php true, CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"]]); $results = json_decode(curl_exec($ch), true); ``` #### Response ```json { "id": "9a1c0d2b-7e4a-46f8-9c1d-2e3f9a1c0d2b", "name": "Race 1", "eventId": "1143117a-9c2b-4e7a-8f1d-2b6a9c4e7f10", "eventName": "Cup Regatta 2026", "scheduledDate": "2026-10-11T10:00:00Z", "isOfficial": true, "courseDistanceNm": 12.4, "resultsUpdatedAtUtc": "2026-10-11T12:41:09Z", "results": [ { "id": "e3f9a1c0-d2b7-4e4a-96f8-c1d2e3f9a1c0", "rank": 1, "sailNumber": "TUR 501", "boatName": "Poyraz", "teamName": null, "skipper": "A. Demir", "owner": "A. Demir", "className": "ORC", "divisionId": "d1c0e9f8-a7b6-4c5d-8e3f-2a1b0c9d8e7f", "divisionName": "ORC A", "modelClass": "First 40.7", "status": "Finished", "startTimeUtc": "2026-10-11T10:00:00Z", "finishTimeUtc": "2026-10-11T12:14:33Z", "elapsedTime": "02:14:33", "correctedTime": "02:01:47", "listOrderNo": 1, "raiting": 0.912, "raitingLabel": "0.912", "points": 1, "penaltyPoints": null, "timePenaltySeconds": null } ] } ``` `200` — same caching headers as every data endpoint; see [Caching & freshness](/docs/caching). ## Response fields Full field table: [Data models → Race results](/docs/data-models#race-results). `status` values include `Finished`, `DNF`, `DNS`, `OCS`, `RET`, `DSQ` — see [Data models](/docs/data-models#scoring-vocabulary) for what each means. ## Errors | HTTP | Code | |---|---| | 404 | [`RACE_NOT_FOUND`](/docs/errors#RACE_NOT_FOUND) | | 401 | [`API_TOKEN_MISSING`](/docs/errors#API_TOKEN_MISSING) / [`API_TOKEN_INVALID`](/docs/errors#API_TOKEN_INVALID) | | 403 | [`API_SCOPE_MISSING`](/docs/errors#API_SCOPE_MISSING) / [`API_IP_NOT_ALLOWED`](/docs/errors#API_IP_NOT_ALLOWED) | | 429 | [`RATE_LIMIT_EXCEEDED`](/docs/errors#RATE_LIMIT_EXCEEDED) | | 503 | [`API_UNAVAILABLE`](/docs/errors#API_UNAVAILABLE) | ## Caching One entry per `raceId`. A race stops changing once its jury window closes, at which point you can safely stop revalidating it altogether. ```prompt Implement getRaceResults(raceId) for the Sail Club API. GET https://sail-club-server.cloud.run/api/v1/races/{raceId}/results Authorization: Bearer Success 200: { id, name, eventId, eventName, scheduledDate, results: [ { id, rank, sailNumber, boatName, skipper, owner, divisionName, status ("Finished"|"DNF"|"DNS"|"OCS"|"RET"|"DSQ"|...), startTimeUtc, finishTimeUtc, elapsedTime, correctedTime, points, penaltyPoints, timePenaltySeconds, ... } ], ... } — fetch GET /api/v1/openapi.json for the exact schema. 404 RACE_NOT_FOUND: unknown/malformed/another club's race id — not retryable. 401/403/429/503: same handling as every other call — see the full-integration prompt on /docs/overview. Requirements: a typed client function, and a small render/format helper that turns one result row into a human-readable line, e.g. "1. Poyraz (TUR 501) — 02:01:47 corrected — 1 pt", treating a non-Finished status (DNF/DNS/OCS/RET/DSQ) as its own label instead of a time. Adapt to your actual stack — the endpoint, path param and response shape above are language-neutral. ``` --- # Caching & freshness # Caching & freshness Every `/api/v1` data response is served from a server-side cache, refreshed **at least once an hour** (3600 seconds by default — a club can be configured for a shorter window on race days, never a longer one). Results can be up to that old. Four headers tell you exactly how old: | Header | Meaning | |---|---| | `Cache-Control: private, max-age=` | seconds left in the **current** server-side cache window — cache the response yourself for at least this long | | `ETag: ""` | a strong hash of the exact body — send it back as `If-None-Match` next time | | `Last-Modified` | when the underlying data last changed (list responses: the newest item) | | `X-Data-As-Of` | ISO-8601 UTC timestamp (with milliseconds) of when this body was read — the "as of" time to show a viewer if you display it | ## Conditional requests ```bash # first call curl -s -D- https://sail-club-server.cloud.run/api/v1/events/EVENT_ID \ -H "Authorization: Bearer $TOKEN" -o event.json | grep -i etag # ETag: "3f9a1c0d2b7e4a6f8c1d2e3f9a1c0d2" # later — send it back curl -s -o /dev/null -w '%{http_code}\n' https://sail-club-server.cloud.run/api/v1/events/EVENT_ID \ -H "Authorization: Bearer $TOKEN" \ -H 'If-None-Match: "3f9a1c0d2b7e4a6f8c1d2e3f9a1c0d2"' # 304 — your cached copy is still correct, no body sent ``` A matching `If-None-Match` returns **304 with no body** — cheaper for both sides than re-downloading unchanged JSON. It still counts as a call against your rate limit, the same as a 200 would — a conditional request saves bandwidth, not rate-limit budget. See [Rate limits](/docs/rate-limits). ## How fresh a change actually is A change made in the panel (a race scored, a result corrected) reaches `/api/v1` after: the internal read model rebuilds (roughly 30 seconds, with a safety re-check at 5 minutes) **plus** whatever remains of the current cache window (up to the full hour). There is no way to force an immediate refresh from the API side — if a club needs sub-hour freshness during a regatta, that's a cache-window change on our side, not something an integration can request per-call. ## Recommended integrator-side cache Don't call `/api/v1` live per visitor request. A simple, correct pattern: ```text on your own schedule (e.g. every 15 min, well under max-age): call the endpoint with If-None-Match: if 304: keep serving your existing cached copy, done if 200: replace your cached copy and stored ETag with the new body serve every visitor from your own cache/database, never from a live call ``` This also means a temporary outage on our side ([`503 API_UNAVAILABLE`](/docs/errors#API_UNAVAILABLE)) never has to be visible to your visitors — you already have the last good copy. --- # Event results # Event results An event's series (multi-race) results — the four lists a club's public "results" tab shows: the main scoring results, overall standings, rating-group standings, and special-category standings. ## Headers | Header | Required | Value | |---|---|---| | `Authorization` | yes | `Bearer ` | ## Path parameters | Param | Type | Note | |---|---|---| | `eventId` | UUID | `404 EVENT_NOT_FOUND` if unknown / another club's / not published | #### Request ```bash curl -s https://sail-club-server.cloud.run/api/v1/events/1143117a-9c2b-4e7a-8f1d-2b6a9c4e7f10/results \ -H "Authorization: Bearer $TOKEN" ``` ```javascript const res = await fetch(`https://sail-club-server.cloud.run/api/v1/events/${eventId}/results`, { headers: { Authorization: `Bearer ${token}` }, }); const results = await res.json(); ``` ```python res = requests.get( f"https://sail-club-server.cloud.run/api/v1/events/{event_id}/results", headers={"Authorization": f"Bearer {token}"}, timeout=10, ) results = res.json() ``` ```csharp var res = await client.GetAsync($"/api/v1/events/{eventId}/results"); res.EnsureSuccessStatusCode(); var results = await res.Content.ReadFromJsonAsync(); ``` ```php true, CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"]]); $results = json_decode(curl_exec($ch), true); ``` #### Response ```json { "results": [ { "id": "4c5d8e3f-2a1b-40c9-8d8e-7f6c5d4e3f2a", "eventId": "1143117a-9c2b-4e7a-8f1d-2b6a9c4e7f10", "divisionId": "d1c0e9f8-a7b6-4c5d-8e3f-2a1b0c9d8e7f", "divisionName": "ORC A", "overallGroupId": "b6a9c4e7-f10a-41c0-8d2b-7e4a6f8c1d2e", "overallGroupName": "ORC", "rank": 1, "sailNumber": "TUR 501", "boatName": "Poyraz", "skipper": "A. Demir", "owner": "A. Demir", "modelClass": "First 40.7", "score": 4, "points": [ { "raceNo": 1, "score": 1, "isDiscarded": false }, { "raceNo": 2, "score": 3, "isDiscarded": false } ] } ], "overall": [ { "id": "4c5d8e3f-2a1b-40c9-8d8e-7f6c5d4e3f2a", "overallGroupId": "b6a9c4e7-f10a-41c0-8d2b-7e4a6f8c1d2e", "overallGroupName": "ORC", "rank": 1, "sailNumber": "TUR 501", "boatName": "Poyraz", "skipper": "A. Demir", "score": 4, "points": [ { "raceNo": 1, "score": 1, "isDiscarded": false }, { "raceNo": 2, "score": 3, "isDiscarded": false } ] } ], "ratingGroups": [ { "id": "4c5d8e3f-2a1b-40c9-8d8e-7f6c5d4e3f2a", "ratingGroup": "IRC 1", "rank": 1, "sailNumber": "TUR 501", "boatName": "Poyraz", "owner": "A. Demir", "score": 4, "points": [ { "raceNo": 1, "score": 1, "isDiscarded": false }, { "raceNo": 2, "score": 3, "isDiscarded": false } ] } ], "specialCategories": [ { "id": "9d8e7f6c-5d4e-43f2-8a1b-0c9d8e7f6c5d", "specialCategoryId": "8e7f6c5d-4e3f-42a1-8b0c-9d8e7f6c5d4e", "categoryName": "Best Corinthian", "rank": 1, "sailNumber": "TUR 501", "boatName": "Poyraz", "skipper": "A. Demir", "totalScore": 4, "points": [ { "raceNo": 1, "score": 1, "isDiscarded": false }, { "raceNo": 2, "score": 3, "isDiscarded": false } ] } ] } ``` `200` — same caching headers as every data endpoint; see [Caching & freshness](/docs/caching). ## Response fields Full field tables: [Data models → Event results](/docs/data-models#event-results). Each of the four lists uses the same `points[]` shape (one entry per race, `isDiscarded` marking a dropped race under the event's discard rule); a list is `[]` (not omitted) if the event has no data for it — e.g. `specialCategories: []` when the event defines none. ## Errors | HTTP | Code | |---|---| | 404 | [`EVENT_NOT_FOUND`](/docs/errors#EVENT_NOT_FOUND) | | 401 | [`API_TOKEN_MISSING`](/docs/errors#API_TOKEN_MISSING) / [`API_TOKEN_INVALID`](/docs/errors#API_TOKEN_INVALID) | | 403 | [`API_SCOPE_MISSING`](/docs/errors#API_SCOPE_MISSING) / [`API_IP_NOT_ALLOWED`](/docs/errors#API_IP_NOT_ALLOWED) | | 429 | [`RATE_LIMIT_EXCEEDED`](/docs/errors#RATE_LIMIT_EXCEEDED) | | 503 | [`API_UNAVAILABLE`](/docs/errors#API_UNAVAILABLE) | ## Caching One entry per `eventId`. During a live regatta this changes as races are scored (bounded by the cache window, up to an hour — see [Caching & freshness](/docs/caching)); once an event is finished and awards are published it effectively stops changing. ```prompt Implement getEventResults(eventId) for the Sail Club API. GET https://sail-club-server.cloud.run/api/v1/events/{eventId}/results Authorization: Bearer Success 200: { results: [...], overall: [...], ratingGroups: [...], specialCategories: [...] } — four lists, each entry has rank, sailNumber, boatName, skipper/owner, a score/totalScore, and points: [{ raceNo, score, isDiscarded, ... }]. Any list may be an empty array. Fetch GET /api/v1/openapi.json for the exact schema — do not invent fields not listed there. 404 EVENT_NOT_FOUND: unknown/other club's/unpublished event — not retryable. 401/403/429/503: same handling as every other call — see the full-integration prompt on /docs/overview. Build a typed client function plus a small formatter that renders the "overall" list as a standings table: rank, boat name, sail number, skipper, score, with discarded races shown struck through / annotated. Adapt to your actual stack — the endpoint, path param and response shape above are language-neutral. ``` --- # Pagination # Pagination Only [`GET /api/v1/events`](/docs/api/events) paginates — every other endpoint returns a single object or a small, already-bounded list (a race's or event's own results). ## Parameters | Query param | Default | Range | Invalid value | |---|---|---|---| | `page` | `1` | ≥ 1 | `400 API_PARAM_INVALID`, `params.name: "page"` | | `pageSize` | `20` | 1–100 | `400 API_PARAM_INVALID`, `params.name: "pageSize"` | ```bash curl -s "https://sail-club-server.cloud.run/api/v1/events?page=2&pageSize=50" \ -H "Authorization: Bearer $TOKEN" ``` ## Response envelope ```json { "items": [ { "id": "…", "name": "…", "startDate": "2026-10-11T09:00:00Z", "…": "…" } ], "nextPage": 3, "totalItems": 128 } ``` `nextPage` is `null` on the last page — loop while it isn't: ```javascript let page = 1; const all = []; while (page !== null) { const res = await fetch(`https://sail-club-server.cloud.run/api/v1/events?page=${page}&pageSize=100`, { headers: { Authorization: `Bearer ${token}` }, }); const body = await res.json(); all.push(...body.items); page = body.nextPage; } ``` Sort order is fixed: newest `startDate` first (events with no start date sort last), ties broken by id — so paging through the whole list twice in a row returns items in the same relative order even if a new event was added in between (it appears wherever its date puts it, without reshuffling the rest). At most 1000 events are reachable through paging for one club (an internal index cap on our side) — a club running more than that should talk to us; this hasn't come up in practice. --- # Versioning & changelog # Versioning & changelog Every path on this site is under `/api/v1/…`. While `v1` exists, we will not change: - an existing field's type or meaning, or remove a field a documented response already has, - an existing endpoint's URL, required scope, or success status code, - the shape of an error response (`application/problem+json` with a `code`) for an existing `code`. We **will**, without a version bump (these are additive and safe to ignore if you don't use them): - add new optional fields to existing responses, - add new endpoints and new scopes, - add new error `code` values for situations that previously didn't exist. A breaking change ships as `/api/v2/…` alongside `v1`, with `v1` kept running for a announced deprecation window — never turned off underneath an integration without notice. ## Machine-readable spec [`GET /api/v1/openapi.json`](/api/v1/openapi.json) (OpenAPI 3.1, no auth needed) is the definitive, current shape of every `v1` endpoint — every field on this site is generated or checked against it at our build time. If this page and the spec ever disagree, the spec is correct; please [tell us](/docs/support) so we can fix the docs. ## Changelog | Date | Change | |---|---| | 2026-09-30 | `v1` launched: token endpoint, `events`, event detail, races, race results, event results. |