# Track

# Track

The raw ~1 Hz position history of one start group — the same points [Race live](/docs/api/races-live) only ever
shows the newest of. Use it to draw a trail behind each boat while racing, or to replay the whole race
afterwards; the capture window (gun − 5 min … gun + 3 h, or each boat's own finish) and the opt-in rule are the
same as every other `tracking:read` call, and this endpoint keeps answering after the race is over.

## Headers

| Header | Required | Value |
|---|---|---|
| `Authorization` | yes | `Bearer <access_token>` |

## Path parameters

| Param | Type | Note |
|---|---|---|
| `raceId` | UUID | from [Event live status](/docs/api/events-live); unknown, malformed, or another club's ⇒ `404 RACE_NOT_FOUND` |

## Query parameters

| Param | Type | Required | Note |
|---|---|---|---|
| `startGroupId` | UUID | only if the race has more than one start group | the group to read; omit it for a single-start-group race |
| `since` | date-time (ISO 8601 UTC) | yes | inclusive lower bound |
| `until` | date-time (ISO 8601 UTC) | no | exclusive upper bound; default and max is `since + 10 min`; must be after `since` |

`until` is silently cut at `now − delaySeconds` if your requested value would reach past it — read the
response's own `until` back, don't assume you got what you asked for.

#### Request

```bash
curl -s "https://sail-club-server.cloud.run/api/v1/races/9a1c0d2b-7e4a-46f8-9c1d-2e3f9a1c0d2b/track?startGroupId=2b6a9c4e-7f10-4a1c-8d2b-7e4a6f8c1d2e&since=2027-05-01T11:40:00Z" \
  -H "Authorization: Bearer $TOKEN"
```

```javascript
const url = new URL(`https://sail-club-server.cloud.run/api/v1/races/${raceId}/track`);
url.searchParams.set('startGroupId', startGroupId);
url.searchParams.set('since', sinceIso);
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
const page = await res.json();
```

```python
res = requests.get(
    f"https://sail-club-server.cloud.run/api/v1/races/{race_id}/track",
    params={"startGroupId": start_group_id, "since": since_iso},
    headers={"Authorization": f"Bearer {token}"},
    timeout=10,
)
page = res.json()
```

```csharp
var res = await client.GetAsync($"/api/v1/races/{raceId}/track?startGroupId={startGroupId}&since={sinceIso}");
res.EnsureSuccessStatusCode();
var page = await res.Content.ReadFromJsonAsync<PublicTrackPageDto>();
```

```php
<?php
$query = http_build_query(["startGroupId" => $startGroupId, "since" => $sinceIso]);
$ch = curl_init("https://sail-club-server.cloud.run/api/v1/races/$raceId/track?$query");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"]]);
$page = json_decode(curl_exec($ch), true);
```

#### Response

```json
{
  "raceId": "9a1c0d2b-7e4a-46f8-9c1d-2e3f9a1c0d2b",
  "startGroupId": "2b6a9c4e-7f10-4a1c-8d2b-7e4a6f8c1d2e",
  "since": "2027-05-01T11:40:00Z",
  "until": "2027-05-01T11:50:00Z",
  "nextSince": "2027-05-01T11:50:00Z",
  "delaySeconds": 30,
  "boats": [
    {
      "entryId": "e3f9a1c0-d2b7-4e4a-96f8-c1d2e3f9a1c0",
      "sailNumber": "TUR 1",
      "boatName": "Poyraz",
      "t": [1935654000000, 1935654001000],
      "lat": [40.97, 40.9701],
      "lon": [28.78, 28.7801],
      "sogKn": [6.1, null],
      "cogDeg": [215.0, 214.8]
    }
  ]
}
```

`200` — a window ending at least 2 minutes before the current cutoff is `Cache-Control: max-age=3600` (it's
immutable, that minute of track never changes again); a window that still reaches close to "now" is `max-age=2`.

## Response fields

| Field | Type | Null? | Note |
|---|---|---|---|
| `since` | date-time | no | echoes what you requested |
| `until` | date-time | no | the requested `until`, cut at `now − delaySeconds` if that was earlier — always after `since` |
| `nextSince` | date-time | **yes** | pass this straight back as `since` on your next call to keep paging forward; **null** once the start group's capture window (gun + 3 h) has closed — stop polling this group. For a group with no gun time yet, equals `until` |
| `boats[]` | array | no (may be empty) | only boats with ≥ 1 point inside `[since, until)`, by sail number |
| `boats[].entryId` / `sailNumber` / `boatName` | uuid / string / string | no | same id/fields as [Race live](/docs/api/races-live) |
| `boats[].t` | long[] | no | epoch **milliseconds**, strictly ascending, ~1 Hz; one entry per sample |
| `boats[].lat` / `lon` | double[] | no | WGS84, same index as `t` |
| `boats[].sogKn` / `cogDeg` | (double\|null)[] | elements nullable | same index as `t`; null where the phone didn't report it for that sample |

Every per-boat array (`t`, `lat`, `lon`, `sogKn`, `cogDeg`) is the same length and index-aligned — `t[i]`/`lat[i]`/
`lon[i]` are one position fix.

## Errors

| HTTP | Code |
|---|---|
| 400 | [`API_PARAM_INVALID`](/docs/errors#API_PARAM_INVALID) — `params.name` is `startGroupId`, `since`, or `until` |
| 404 | [`RACE_NOT_FOUND`](/docs/errors#RACE_NOT_FOUND) |
| 404 | [`START_GROUP_NOT_FOUND`](/docs/errors#START_GROUP_NOT_FOUND) |
| 404 | [`TRACKING_NOT_SHARED`](/docs/errors#TRACKING_NOT_SHARED) |
| 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

Each 10-minute-or-smaller window is its own cache entry. Once a window ends more than 2 minutes before the
current delayed cutoff it never changes again — cache it forever on your side too, keyed by
`raceId` + `startGroupId` + `since` + `until`.

```prompt
Implement getTrackPage(raceId, startGroupId, since, until?) for the Sail Club API.

GET https://sail-club-server.cloud.run/api/v1/races/{raceId}/track?startGroupId=&since=&until=
Authorization: Bearer <access_token>
Scope required: tracking:read

Success 200: { raceId, startGroupId, since, until, nextSince, delaySeconds,
  boats: [ { entryId, sailNumber, boatName, t: [epoch_ms...], lat: [...], lon: [...], sogKn: [...|null],
  cogDeg: [...|null] } ] } — arrays are index-aligned per boat. Fetch GET /api/v1/openapi.json for the exact
  schema.
400 API_PARAM_INVALID (params.name: startGroupId|since|until): fix that parameter, don't retry as-is.
404 RACE_NOT_FOUND / START_GROUP_NOT_FOUND: 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.

Page forward by calling again with since = the previous response's nextSince, stopping when nextSince is null
(the capture window has closed). Cache each window permanently once its own until is more than 2 minutes behind
"now minus delaySeconds" — it will never change again.

Adapt to your actual stack — the endpoint, path param and response shape above are language-neutral.
```
