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 |
400 | INVALID_CURSOR | The cursor sent to GET /v1/external/activity is not a nextCursor it returned | No — drop it or resend nextCursor |
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 permission this route needs | No |
404 | TOURNAMENT_NOT_FOUND | No such tournament for this token | No |
404 | ROUND_NOT_FOUND | No such round for this token | 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 token cannot reach 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 token cannot reach such a resource. It does not
distinguish between:
- an
idxthat exists nowhere in SETTO; - an
idxthat belongs to an organization this token does not reach; - an
idxof a tournament the token's owner does not manage — or no longer manages: a personal token's reach is worked out again on every request; - an
idxthat belongs to a personal tournament, which only its owner's personal token reaches.
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 a token that reaches it: the organization token of the organization that owns it, or the personal token of someone who manages it.
Refused writes
A write that is refused wrote nothing, and its body adds details: one entry
per game, team or player that blocked it, each { code, … } with the keys that
apply (gameIdx, teamIdx, index, field, limit, current, step,
reason, …). Each operation's page in the API reference
lists the codes it can answer. The ones that shape a client's logic:
| Status | code | Meaning | Retry? |
|---|---|---|---|
400 | INSCRIPTION_COST_BELOW_PLATFORM_FEE | The dashboard's own price check refuses every change to the tournament (an edit, publishing, unpublishing, archiving, restoring) while the organizer absorbs SETTO's fee («Yo absorbo la comisión») and the price («Costo de inscripción») does not cover it | No — the organizer raises the price or stops absorbing the fee in the dashboard; the API never changes prices |
403 | DIVISION_LIMIT_REACHED, TEAM_LIMIT_REACHED | The tournament's plan holds no more categories or teams (details: limit, current, tier) | No — the organizer upgrades the plan in the dashboard |
404 | DIVISION_NOT_FOUND, TEAM_NOT_FOUND, PLAYER_NOT_FOUND, GAME_NOT_FOUND | No such category, team, player or game in this tournament, for this token | No |
409 | SCHEDULE_BUSY, TOURNAMENT_BUSY | Another change to the same tournament was being saved | Yes — once, the same request (and the same Idempotency-Key) |
409 | PREVIEW_STALE | The tournament or the category changed since the preview (of a withdrawal, a format, groups or a draw) | No — preview again |
409 | FORMAT_HAS_PLAYED_GAMES, ROUND_HAS_PLAYED_GAMES, KNOCKOUT_HAS_PLAYED_GAMES | A format, groups or a draw would replace games that hold results (details lists them) | No — clearing those results is the organizer's decision |
409 | DRAW_SEATS_WITHDRAWN_TEAM | The final standings put a withdrawn team in a slot of the draw | No — the organizer picks who takes its place (another team or a BYE) and the confirmation goes again with replace |
409 | GAME_HAS_RESULT | The game already holds a result | No — show it to the organizer; only with their yes is it replaced, with overwrite: true |
409 | IDEMPOTENCY_IN_PROGRESS | A request with the same Idempotency-Key is still running | Yes — the same request, in a few seconds |
409 | TEAM_HAS_PAYMENTS | The team carries a payment trace (a payment, a paid mark or a payment link), so the API does not delete it | No — deleting it, and any refund, happen in the dashboard |
410 | CONFIRMATION_EXPIRED | A preview's confirmationToken is older than 15 minutes | No — preview again |
422 | IDEMPOTENCY_KEY_REUSED | That Idempotency-Key belongs to another request | No — a new write takes a new key |
429 | TOURNAMENT_CREATE_DAILY_CAP | 20 tournaments created through the API in 24 hours | Yes — after Retry-After |
Every other refusal of a write (a name taken, a full category, a team that
already has games, a schedule conflict, …) is a state the organizer decides on:
show them the details rather than retrying.
What to do on each status
const MAX_RETRIES = 3;
async function settoFetch(path, { token, attempt = 0, ...init } = {}) {
const res = await fetch(
`https://api.setto.io/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.