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
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 scope this route needsNo
404TOURNAMENT_NOT_FOUNDNo such tournament for this organizationNo
404ROUND_NOT_FOUNDNo such round for this organizationNo
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 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 idx that exists nowhere in SETTO;
  • an idx that belongs to another organization;
  • an idx that 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.

On this page