# SETTO External API — error reference

Canonical page: <https://docs.setto.io/docs/concepts/errors>

## Body shape

Every deliberate error:

```json
{
  "statusCode": 404,
  "code": "TOURNAMENT_NOT_FOUND",
  "message": "TOURNAMENT_NOT_FOUND"
}
```

| Field | Type | Notes |
| --- | --- | --- |
| `statusCode` | number | Mirrors the HTTP status |
| `code` | string | Stable, screaming snake case. **Branch on this** |
| `message` | string | Human-readable; may equal `code` |

Request-validation failures are the one exception: `message` is an **array of
strings** and there is an `error` field instead of `code`.

```json
{
  "statusCode": 400,
  "message": [
    "must be a valid enum value,club,club.courts,divisions,divisions.rounds,sponsors,circuit,circuitCategory,rankedTiebreakRules"
  ],
  "error": "Bad Request"
}
```

Read it defensively:

```js
const message = Array.isArray(body.message) ? body.message.join('; ') : body.message;
```

A malformed path id is the OTHER `400`, and it uses the uniform shape — it is
rejected before the handler runs, so it never becomes a `404`:

```json
{
  "statusCode": 400,
  "code": "INVALID_ID",
  "message": "INVALID_ID"
}
```

So `INVALID_ID` means the id string is wrong, not that the resource is missing.
Do not retry it and do not report it as "not found".

## Every code

| Status | `code` | Meaning | Retry? | Do this |
| --- | --- | --- | --- | --- |
| `400` | `INVALID_ID` | A path `idx` is not a v4 UUID. Rejected before the handler, so it is never a `404` | No | Fix the id — do NOT treat it as "not found" |
| `400` | *(validation — see above)* | Bad `populate` value, `limit` outside 1–100, `offset` < 0, non-enum `status`/`sport`, non-UUID `division` | No | Fix the request; the message lists the legal values |
| `401` | `MISSING_API_TOKEN` | No `Authorization` header, or it is not `Bearer setto_…` | No | Set `SETTO_API_TOKEN` and resend |
| `401` | `INVALID_API_TOKEN` | Unknown, revoked, or wrong-environment token (`setto_test_` against production) | No | Ask the user for a new token |
| `401` | `EXPIRED_API_TOKEN` | The token's `expiresAt` has passed | No | Ask the user to create a new token |
| `403` | `INSUFFICIENT_SCOPE` | Valid token, but it lacks the scope this route needs | No | Stop and report it |
| `404` | `TOURNAMENT_NOT_FOUND` | No such tournament **for this organization** | No | Re-check the `idx` and which organization the token belongs to |
| `404` | `ROUND_NOT_FOUND` | No such round **for this organization** | No | Same |
| `429` | `RATE_LIMITED` | More than 120 requests in 60 s for this token | Yes | Wait `Retry-After` seconds, then retry once |
| `5xx` | — | Fault on SETTO's side | Yes | Exponential backoff with jitter |

## `404` hides more than it says

A `404` means *this organization has no such resource*. It does not distinguish
between an `idx` that exists nowhere, one that belongs to another organization,
and one that belongs to a personal tournament outside any organization. That is
deliberate — ids cannot be probed. If you are certain the resource exists, the
token is almost certainly for the wrong organization.

## Never retry

`400`, `401`, `403` and `404` will fail identically on a retry. A retry loop on
`401` only burns the rate limit. Retry `429` (after `Retry-After`) and `5xx`
(with backoff), nothing else.

## Handling sketch

```js
const MAX_RETRIES = 3;

async function settoFetch(path, { token, attempt = 0, ...init } = {}) {
  const res = await fetch(`https://api-production-ea80.up.railway.app/v1${path}`, {
    ...init,
    headers: { ...init.headers, Authorization: `Bearer ${token}` },
  });

  if (res.ok) return res.json();

  const body = await res.json().catch(() => ({}));
  const canRetry = attempt < MAX_RETRIES;
  const retry = async (waitMs) => {
    await new Promise((r) => setTimeout(r, waitMs));
    return settoFetch(path, { ...init, token, attempt: attempt + 1 });
  };

  switch (res.status) {
    case 401:
      throw new Error(`SETTO credential problem: ${body.code}`);
    case 403:
      throw new Error('SETTO token lacks the required scope');
    case 404:
      return null;
    case 429:
      // Bounded: never spin forever on a token that is permanently over budget.
      if (!canRetry) throw new Error('SETTO API: still rate limited');
      return retry(Number(res.headers.get('Retry-After') ?? 1) * 1000);
    default:
      // 5xx only, exponential backoff with jitter.
      if (res.status >= 500 && canRetry) {
        return retry(2 ** attempt * 500 + Math.random() * 250);
      }
      throw new Error(`SETTO API ${res.status}: ${JSON.stringify(body)}`);
  }
}
```
