Skip to content
Developers
OpenAPI
GET /api/v1/races/{raceId}/live/insightsscope: tracking:read

Race live insights

API v1 Last updated 2026-10-01 View as Markdown

#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.