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
| Limit | 120 requests |
| Window | 60 seconds |
| Scope | One 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
| Header | On | Meaning |
|---|---|---|
X-RateLimit-Limit | authenticated | Requests allowed per window — 120 |
X-RateLimit-Remaining | authenticated | Requests left in the current window |
X-RateLimit-Reset | authenticated | Unix timestamp, in seconds, when the window resets |
Retry-After | 429 only | Seconds to wait before retrying |
HTTP/1.1 200 OK
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 1789238460All four are exposed to browsers via CORS, so a front-end proxy can read them too.
Handling 429
{
"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
429will 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-Remainingdrops below ~10, pause untilX-RateLimit-Reset. Preventing the429is 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"'
# 304const 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.
| Pattern | Cost |
|---|---|
| Fetch on every page view | 1 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 export | A 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.