# 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=<n>` | seconds left in the **current** server-side cache window — cache the response yourself for at least this long |
| `ETag: "<hash>"` | 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: <your stored ETag>
    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.
