SETTO API

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

404 Not Found
{
  "statusCode": 404,
  "code": "TOURNAMENT_NOT_FOUND",
  "message": "TOURNAMENT_NOT_FOUND"
}
FieldTypeNotes
statusCodenumberMirrors the HTTP status
codestringStable, screaming snake case. Switch on this
messagestringHuman-readable; may equal code

Every code

StatuscodeMeaningRetry?
400INVALID_IDA path identifier is not a v4 UUID. Rejected before the handler runs, so it never becomes a 404No — 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 UUIDNo — fix the request
400INVALID_CURSORThe cursor sent to GET /v1/external/activity is not a nextCursor it returnedNo — drop it or resend nextCursor
401MISSING_API_TOKENNo Authorization header, or it is not Bearer setto_…No
401INVALID_API_TOKENUnknown, revoked, or wrong-environment tokenNo
401EXPIRED_API_TOKENThe token's expiresAt has passedNo — create a new token
403INSUFFICIENT_SCOPEValid token, but it lacks the permission this route needsNo
404TOURNAMENT_NOT_FOUNDNo such tournament for this tokenNo
404ROUND_NOT_FOUNDNo such round for this tokenNo
429RATE_LIMITEDMore than 120 requests in the last 60 seconds for this tokenYes — 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.

400 Bad Request
{
  "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:

  1. A populate value that is not on that endpoint's allow-list — the lists are in Populate, and the error message enumerates the legal values for you.
  2. limit outside 1..100, or offset below 0.
  3. A status or sport value that is not one of the enum members.
  4. A division filter 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:

400 Bad Request
{
  "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 idx that exists nowhere in SETTO;
  • an idx that belongs to an organization this token does not reach;
  • an idx of 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 idx that 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:

StatuscodeMeaningRetry?
400INSCRIPTION_COST_BELOW_PLATFORM_FEEThe 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 itNo — the organizer raises the price or stops absorbing the fee in the dashboard; the API never changes prices
403DIVISION_LIMIT_REACHED, TEAM_LIMIT_REACHEDThe tournament's plan holds no more categories or teams (details: limit, current, tier)No — the organizer upgrades the plan in the dashboard
404DIVISION_NOT_FOUND, TEAM_NOT_FOUND, PLAYER_NOT_FOUND, GAME_NOT_FOUNDNo such category, team, player or game in this tournament, for this tokenNo
409SCHEDULE_BUSY, TOURNAMENT_BUSYAnother change to the same tournament was being savedYes — once, the same request (and the same Idempotency-Key)
409PREVIEW_STALEThe tournament or the category changed since the preview (of a withdrawal, a format, groups or a draw)No — preview again
409FORMAT_HAS_PLAYED_GAMES, ROUND_HAS_PLAYED_GAMES, KNOCKOUT_HAS_PLAYED_GAMESA format, groups or a draw would replace games that hold results (details lists them)No — clearing those results is the organizer's decision
409DRAW_SEATS_WITHDRAWN_TEAMThe final standings put a withdrawn team in a slot of the drawNo — the organizer picks who takes its place (another team or a BYE) and the confirmation goes again with replace
409GAME_HAS_RESULTThe game already holds a resultNo — show it to the organizer; only with their yes is it replaced, with overwrite: true
409IDEMPOTENCY_IN_PROGRESSA request with the same Idempotency-Key is still runningYes — the same request, in a few seconds
409TEAM_HAS_PAYMENTSThe team carries a payment trace (a payment, a paid mark or a payment link), so the API does not delete itNo — deleting it, and any refund, happen in the dashboard
410CONFIRMATION_EXPIREDA preview's confirmationToken is older than 15 minutesNo — preview again
422IDEMPOTENCY_KEY_REUSEDThat Idempotency-Key belongs to another requestNo — a new write takes a new key
429TOURNAMENT_CREATE_DAILY_CAP20 tournaments created through the API in 24 hoursYes — 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.

On this page