Race live insights
#Race live insights
A feed of short, human-readable commentary lines for a live race — lead changes, mark roundings, places gained,
finishes and wind shifts from the last 15 minutes, newest first. Each line is phrased by the server (by a
language model when quota allows, otherwise a template) in the language you ask for; the machine-readable half
of each line is kind plus its numeric fields, for building your own sentence instead of using text.
#Headers
| Header | Required | Value |
|---|---|---|
Authorization |
yes | Bearer <access_token> |
#Path parameters
| Param | Type | Note |
|---|---|---|
raceId |
UUID | from Event live status; unknown, malformed, or another club's ⇒ 404 RACE_NOT_FOUND |
#Query parameters
| Param | Type | Required | Note |
|---|---|---|---|
lang |
string | no | en, tr, de, fr, es, it, ru, el, nb (a region suffix such as en-GB is dropped, case-insensitive). Anything else, or omitted, falls back to en — this parameter never answers 400 |
#Request
curl -s "https://sail-club-server.cloud.run/api/v1/races/9a1c0d2b-7e4a-46f8-9c1d-2e3f9a1c0d2b/live/insights?lang=tr" \
-H "Authorization: Bearer $TOKEN"const url = new URL(`https://sail-club-server.cloud.run/api/v1/races/${raceId}/live/insights`);
url.searchParams.set('lang', 'tr');
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
const insights = await res.json();res = requests.get(
f"https://sail-club-server.cloud.run/api/v1/races/{race_id}/live/insights",
params={"lang": "tr"},
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
insights = res.json()var res = await client.GetAsync($"/api/v1/races/{raceId}/live/insights?lang=tr");
res.EnsureSuccessStatusCode();
var insights = await res.Content.ReadFromJsonAsync<PublicApiRaceInsightsDto>();<?php
$ch = curl_init("https://sail-club-server.cloud.run/api/v1/races/$raceId/live/insights?lang=tr");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"]]);
$insights = json_decode(curl_exec($ch), true);#Response
{
"raceId": "9a1c0d2b-7e4a-46f8-9c1d-2e3f9a1c0d2b",
"eventId": "1143117a-9c2b-4e7a-8f1d-2b6a9c4e7f10",
"isLive": true,
"lang": "tr",
"delaySeconds": 30,
"dataAsOf": "2027-05-01T11:59:30.000Z",
"generatedAtUtc": "2027-05-01T12:00:00.000Z",
"insights": [
{
"id": "3f9c1a0b2d4e5f60",
"kind": "leadChange",
"startGroupId": "2b6a9c4e-7f10-4a1c-8d2b-7e4a6f8c1d2e",
"startGroupName": "Start A",
"atUtc": "2027-05-01T11:58:10Z",
"text": "TUR 2 Poyraz, liderliği TUR 1 Poyraz teknesinden aldı (Start A).",
"source": "template",
"boats": [
{ "entryId": "e3f9a1c0-d2b7-4e4a-96f8-c1d2e3f9a1c0", "sailNumber": "TUR 2", "boatName": "Poyraz" },
{ "entryId": "a1c0d2b7-e4a9-46f8-9c1d-2e3f9a1c0d2b", "sailNumber": "TUR 1", "boatName": "Poyraz" }
],
"markLabel": null, "rank": null, "placesGained": null,
"twdFrom": null, "twdTo": null, "twsFrom": null, "twsTo": null
}
]
}200 — a shared cache per race, max-age up to 60 seconds (see Caching).
#Response fields
| Field | Type | Null? | Note |
|---|---|---|---|
raceId / eventId |
uuid / string | no | |
isLive |
bool | no | the race's broadcast-active flag; false ⇒ insights is always empty |
lang |
string | no | the language text is actually in — your normalised request, or en |
delaySeconds |
int | no | 0–600, the event's chosen delay |
dataAsOf |
date-time | no | the generation's cutoff = its time minus the delay; no fact in the body is newer. Also X-Data-As-Of |
generatedAtUtc |
date-time | yes | when the server last generated this race's insights; null while the race isn't live |
insights[] |
array | no (may be empty) | facts with atUtc in the last 15 minutes before dataAsOf, newest first, max 30 |
insights[].id |
string | no | 16 hex characters, stable — the same fact keeps the same id on every later call. Show each id once in your UI even if text changes between polls |
insights[].kind |
string | no | leadChange · markRounding · gain · finish · windShift — see Kinds |
insights[].startGroupId / startGroupName |
uuid / string | no | |
insights[].atUtc |
date-time | no | when the underlying event happened (order read / GPS sample / phone finish / wind entry) |
insights[].text |
string | no | one sentence in lang, ≤ 160 characters; may be rephrased later for the same id (e.g. a template line upgraded to a model line on the next generation) |
insights[].source |
string | no | model or template |
insights[].boats[] |
array | no | { entryId, sailNumber, boatName }, in the order kind defines below; empty for windShift |
insights[].markLabel |
string | yes | markRounding only — the course step's label, or its mark key if unlabelled |
insights[].rank |
int | yes | gain — the live place now; finish — the unofficial live place if known |
insights[].placesGained |
int | yes | gain only, always ≥ 2 |
insights[].twdFrom / twdTo |
int | yes | windShift only, degrees 0–359 |
insights[].twsFrom / twsTo |
double | yes | windShift only, knots; null means the reading was direction-only |
#Kinds
kind |
boats[] |
Reported when |
|---|---|---|
leadChange |
[new leader, previous leader] |
rank 1 of the unofficial live order changed |
markRounding |
[boat] |
the boat's track enters 50 m of a rounding mark on the applied course (start/finish line ends are skipped); up to 3 boats per mark per detection window |
gain |
[boat] |
the boat gained ≥ 2 places versus the start of the detection window; top 3, at most one per boat per 5-minute bucket |
finish |
[boat] |
a phone-recorded finish time falls inside the detection window |
windShift |
[] |
a new committee wind reading turned ≥ 10° or changed ≥ 3 kn versus the previous one |
The detection window is the 5 minutes before dataAsOf, per start group.
#text is pre-rendered — don't re-translate it
text is written by the server in the lang you asked for; it's meant for a third-party broadcaster or
overlay with no string catalogue of its own. If you're building your own sentences instead (your own
templates, your own locale list), ignore text and compose from kind + the numeric/boat fields above — don't
feed text back through your own translation layer.
#Errors
| HTTP | Code |
|---|---|
| 404 | RACE_NOT_FOUND |
| 404 | TRACKING_NOT_SHARED |
| 401 | API_TOKEN_MISSING / API_TOKEN_INVALID |
| 403 | API_SCOPE_MISSING / API_IP_NOT_ALLOWED |
| 429 | RATE_LIMIT_EXCEEDED |
| 503 | API_UNAVAILABLE |
lang itself never produces a 400 — an unrecognised value just falls back to en.
#Caching
The server detects facts for a race at most once every 60 seconds (shared by every client and every language
asked for in the last 10 minutes), and independently phrases each language's lines at most once every 60
seconds — insights are cached per race and language (60 s each), so a language asked for the first time
gets model lines in the same window the facts were detected in, not the next one; quota is also counted per
language. Poll every 15–60 seconds with If-None-Match rather than on every map redraw. This endpoint shares the same 120 requests/minute per client bucket as the other
four tracking:read endpoints — see
Rate limits.
Prompt for AI agents ready to paste into an agent
Implement getRaceInsights(raceId, lang?) for the Sail Club API.
GET https://sail-club-server.cloud.run/api/v1/races/{raceId}/live/insights?lang=
Authorization: Bearer <access_token>
Scope required: tracking:read
Success 200: { raceId, eventId, isLive, lang, delaySeconds, dataAsOf, generatedAtUtc,
insights: [ { id, kind, startGroupId, startGroupName, atUtc, text, source, boats: [ { entryId, sailNumber,
boatName } ], markLabel, rank, placesGained, twdFrom, twdTo, twsFrom, twsTo } ] } — fetch
GET /api/v1/openapi.json for the exact schema.
404 RACE_NOT_FOUND: unknown/malformed/another club's race id — not retryable.
404 TRACKING_NOT_SHARED: sharing is off for this event — not retryable, needs an organiser change.
401/403/429/503: same handling as every other call — see the full-integration prompt on /docs/overview.
kind is one of leadChange | markRounding | gain | finish | windShift — build your own sentence from kind plus
the numeric/boat fields if you don't want the server's pre-rendered text. Each id is stable: show it once, and
only redraw a line if its text actually changed on a later poll. Poll no faster than every 15 seconds; the
server only regenerates once a minute per race.
Adapt to your actual stack — the endpoint, path param and response shape above are language-neutral.