SETTO API

Rate limits

120 requests per 60 seconds per token, a second per-IP ceiling, the X-RateLimit response headers, how to back off on 429, and how conditional requests with ETag save bandwidth.

Each API token may make 120 requests per 60 seconds. Every authenticated response carries three headers telling you where you stand in the current window, and a 429 carries a fourth telling you how long to wait. That budget is per token, not per organization, so splitting a workload across two tokens doubles it — up to the second, per-IP ceiling below. Caching is the cheaper fix.

The budget

Limit120 requests
Window60 seconds
ScopeOne API token

GET /v1/external/me reports the same numbers, so a client can discover them at startup instead of hard-coding them:

{ "rateLimit": { "limit": 120, "windowSeconds": 60 } }

A second ceiling, per IP

Before any token is read, the API also caps 300 requests per 60 seconds per IP address. Every token calling from the same host shares it. Crossing it is the same 429 with the same RATE_LIMITED code and a Retry-After, but — because no token has been identified yet — without the X-RateLimit-* headers. A 429 whose response carries no X-RateLimit-Remaining is the IP ceiling, not the token one.

Response headers

HeaderOnMeaning
X-RateLimit-LimitauthenticatedRequests allowed per window — 120
X-RateLimit-RemainingauthenticatedRequests left in the current window
X-RateLimit-ResetauthenticatedUnix timestamp, in seconds, when the window resets
Retry-After429 onlySeconds to wait before retrying
HTTP/1.1 200 OK
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 1789238460

All four are exposed to browsers via CORS, so a front-end proxy can read them too.

Handling 429

429 Too Many Requests
{
  "statusCode": 429,
  "code": "RATE_LIMITED",
  "message": "RATE_LIMITED"
}

Respect Retry-After. Do not poll the endpoint until it relents.

async function withRateLimit(fn, { attempts = 5 } = {}) {
  for (let attempt = 0; attempt < attempts; attempt++) {
    const res = await fn();
    if (res.status !== 429) return res;

    const retryAfter = Number(res.headers.get('Retry-After') ?? 1);
    const jitter = Math.random() * 0.3 * retryAfter;
    await new Promise((r) => setTimeout(r, (retryAfter + jitter) * 1000));
  }
  throw new Error('SETTO API: still rate limited after retries');
}

Two rules that matter more than the retry loop:

  • Serialise, do not fan out. Ten parallel requests that each retry on 429 will synchronise and hammer the reset boundary together. Add jitter, or run a small concurrency limit (2–4) and let it drain.
  • Slow down before you hit the wall. If X-RateLimit-Remaining drops below ~10, pause until X-RateLimit-Reset. Preventing the 429 is free; recovering from it is not.

Conditional requests

Responses carry a strong ETag. Send it back in If-None-Match and an unchanged resource answers 304 Not Modified with no body — which is the cheapest way to poll a round that has not had a score entered yet. A 304 still spends one request of both rate limits; what it saves is the payload and the parsing on your side, so pair it with a server-side cache rather than treating it as free.

# First read: capture the ETag
curl -sD - -o round.json \
  https://api-production-ea80.up.railway.app/v1/external/rounds/9d3c6a84-7e15-4b02-8f6d-1c4a9e7b53f2 \
  -H "Authorization: Bearer $SETTO_API_TOKEN" | grep -i '^etag:'
# etag: "2f31-Xy7Qr0hV3n2mJk8sLdTg5pWcAe4"

# Later reads: ask only for changes
curl -s -o /dev/null -w '%{http_code}\n' \
  https://api-production-ea80.up.railway.app/v1/external/rounds/9d3c6a84-7e15-4b02-8f6d-1c4a9e7b53f2 \
  -H "Authorization: Bearer $SETTO_API_TOKEN" \
  -H 'If-None-Match: "2f31-Xy7Qr0hV3n2mJk8sLdTg5pWcAe4"'
# 304
const cache = new Map(); // url -> { etag, body }

async function getCached(url, token) {
  const hit = cache.get(url);
  const res = await fetch(url, {
    headers: {
      Authorization: `Bearer ${token}`,
      ...(hit ? { 'If-None-Match': hit.etag } : {}),
    },
  });

  if (res.status === 304 && hit) return hit.body;

  const body = await res.json();
  const etag = res.headers.get('ETag');
  if (etag) cache.set(url, { etag, body });
  return body;
}

Store the ETag next to the payload, keyed by the full URL including the populate string — a different populate is a different representation and will have a different ETag.

Designing for the limit

A live scoreboard does not need 120 requests a minute.

PatternCost
Fetch on every page view1 request per visitor — do not do this
Server-side cache, 30–60 s TTL~1–2 requests per minute per round, regardless of traffic
Conditional poll every 30 s~2 requests per minute, almost all 304
Nightly full exportA few dozen requests, once

Put the cache on your server, not in the browser: the browser cannot hold your token anyway (see Authentication).

If you genuinely need more than 120 requests a minute — a large federation syncing dozens of tournaments — issue a second token for the batch job so the export cannot starve your live pages. An organization may hold up to 10 active tokens.

On this page