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