Errors
The error body shape, every error code the SETTO External API returns, what each one means, and whether retrying will help.
Errors come back as JSON with the HTTP status you would expect and a machine-
readable code you can switch on. Branch on code, not on the human-readable
message — messages are free to change, codes are part of the contract. There is
one shape for every deliberate error and a second, slightly different one for
request-validation failures.
The error body
{
"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. Switch on this |
message | string | Human-readable; may equal code |
Every code
| Status | code | Meaning | Retry? |
|---|---|---|---|
400 | INVALID_ID | A path identifier is not a v4 UUID. Rejected before the handler runs, so it never becomes a 404 | No — fix the request |
400 | (validation, see below) | A query parameter failed validation — an unknown populate value, a limit outside 1..100, a division that is not a UUID | No — fix the request |
401 | MISSING_API_TOKEN | No Authorization header, or it is not Bearer setto_… | No |
401 | INVALID_API_TOKEN | Unknown, revoked, or wrong-environment token | No |
401 | EXPIRED_API_TOKEN | The token's expiresAt has passed | No — create a new token |
403 | INSUFFICIENT_SCOPE | Valid token, but it lacks the scope this route needs | No |
404 | TOURNAMENT_NOT_FOUND | No such tournament for this organization | No |
404 | ROUND_NOT_FOUND | No such round for this organization | No |
429 | RATE_LIMITED | More than 120 requests in the last 60 seconds for this token | Yes — after Retry-After |
Anything in the 5xx range is a fault on SETTO's side. Retry with exponential
backoff and jitter; if it persists, the payload you were asking for is probably
not the problem.
Validation errors
A 400 from the global validation layer is the one response that does not use
the uniform shape: its message is an array of strings, one per failed rule.
{
"statusCode": 400,
"message": [
"must be a valid enum value,club,club.courts,divisions,divisions.rounds,sponsors,circuit,circuitCategory,rankedTiebreakRules"
],
"error": "Bad Request"
}Handle it defensively:
const body = await res.json();
const message = Array.isArray(body.message)
? body.message.join('; ')
: body.message;The usual causes, in order of frequency:
- A
populatevalue that is not on that endpoint's allow-list — the lists are in Populate, and the error message enumerates the legal values for you. limitoutside1..100, oroffsetbelow0.- A
statusorsportvalue that is not one of the enum members. - A
divisionfilter that is not a UUID v4.
A malformed path id is the other 400
400 covers two different shapes. The array-of-strings body above comes from
the query-parameter layer. A path identifier that is not a v4 UUID — say
/v1/external/tournaments/not-a-uuid — is rejected earlier, and answers in the
API's uniform shape instead:
{
"statusCode": 400,
"code": "INVALID_ID",
"message": "INVALID_ID"
}The distinction that matters: a malformed idx is a 400, never a 404. Only
a well-formed id that this organization cannot see gets the 404 below. So
INVALID_ID always means your string is wrong, not the resource is missing.
404 hides more than it says
A 404 means this organization has no such resource. It does not
distinguish between:
- an
idxthat exists nowhere in SETTO; - an
idxthat belongs to another organization; - an
idxthat belongs to a personal tournament, outside any organization.
That is deliberate: the API never confirms the existence of data your token cannot read, so ids cannot be probed. Practically, if you are certain the tournament exists, check that you are using the token for the organization that owns it.
What to do on each status
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:
// MISSING_API_TOKEN | INVALID_API_TOKEN | EXPIRED_API_TOKEN
throw new Error(`SETTO credential problem: ${body.code}`);
case 403:
throw new Error('SETTO token lacks the required scope');
case 404:
return null; // caller decides whether a miss is fatal
case 429:
// Honour Retry-After, but give up rather than loop forever: a token that
// is over budget every time would otherwise spin here indefinitely.
if (!canRetry) throw new Error('SETTO API: still rate limited');
return retry(Number(res.headers.get('Retry-After') ?? 1) * 1000);
default:
// 5xx only, with exponential backoff and jitter so a fleet of clients
// does not come back in lockstep.
if (res.status >= 500 && canRetry) {
return retry(2 ** attempt * 500 + Math.random() * 250);
}
throw new Error(`SETTO API ${res.status}: ${JSON.stringify(body)}`);
}
}Retry 429 and 5xx, and bound it — three attempts here. Never retry
400, 401, 403 or 404: the same request will fail the same way, and a
retry loop on 401 just burns your rate limit.
Related
- Authentication — the full
401vs403breakdown. - Rate limits — the headers that let you avoid
429altogether.