# Authentication
URL: https://docs.setto.io/docs/authentication
How SETTO External API tokens work — the bearer header, live vs test prefixes, organization scope, expiry, rotation and revocation, and the difference between 401 and 403.
Every request to the External API carries one credential: an organization API
token, sent in the `Authorization` header as a bearer token. Tokens are issued
from the SETTO organizer dashboard, belong to an **organization** rather than to
a person, are read-only, and are shown in full exactly once. This page covers the
header format, the two token prefixes, what a token can and cannot see, and how
to rotate or revoke one.
## The header [#the-header]
```http
GET /v1/external/tournaments HTTP/1.1
Host: api-production-ea80.up.railway.app
Authorization: Bearer setto_live_YOUR_TOKEN_HERE
```
There is no alternative. The API does not accept a token in a query string, a
cookie or an `X-Api-Key` header — only `Authorization: Bearer`. A missing or
malformed header is a `401`, never a silent anonymous read.
## Token format [#token-format]
A token is a prefix plus a secret. The first 16 characters are the **prefix**,
which SETTO stores in clear so the dashboard can show you which token is which;
the rest is the secret, which SETTO stores only as a hash.
| Prefix | Meaning |
| -------------- | ------------------------------------------------------- |
| `setto_live_…` | A production token. Works against the production API. |
| `setto_test_…` | A non-production token. Rejected by the production API. |
Use `setto_test_` tokens when you point an integration at a staging or local
SETTO API; use `setto_live_` for anything real. Both are read-only.
The `prefix` is what you see everywhere the token is *displayed* — in the
dashboard list and in the `token.prefix` field of `GET /v1/external/me`. Log the
prefix when you need to tell two tokens apart. Never log the whole token.
## Creating a token [#creating-a-token]
In [app.setto.io](https://app.setto.io) go to **Organización**, select the
organization, open the **API** tab, and choose **Create token**.
* Only the **organization creator** or a member with the **`ADMIN`** role may
create, list or revoke tokens. Other members get a `403` from the management
endpoints; the tab tells them so.
* A name is required. Name tokens after their consumer (`Club website`,
`Airtable sync`), not after a person.
* Expiry is optional: never, 30, 90 or 365 days. An expired token returns `401`
with `EXPIRED_API_TOKEN`.
* An organization may hold at most **10 active tokens** at a time. Revoke one
before creating the eleventh.
The full token appears once, in the dialog that follows creation. SETTO keeps
only a hash, so nobody — not you, not SETTO support — can recover it later. If
it is lost, revoke and re-create.
## What a token can see [#what-a-token-can-see]
A token is scoped to **one organization**. It reads:
* every tournament whose `organization` is that organization — including ones in
`DRAFT` and `ARCHIVED`, and including tournaments whose public page is turned
off (`isPublic: false`);
* the divisions, teams, rounds, pools, draws, games, scores and standings that
hang off those tournaments — including entries flagged `isVisible: false`.
It does not read:
* **personal tournaments** — tournaments created by a user outside any
organization are invisible to every token, even the owner's;
* **other organizations' data**, even if you are a member of both. One token, one
organization; use one token per organization;
* **personal contact data** — emails, phone numbers, social handles and the like
are stripped from every response;
* **payment or billing data** — registration fees paid, payment methods, Stripe
identifiers and organizer notes are not part of the contract.
Because hidden and draft material *is* returned, treat the payload as internal
until you filter it. If you are rendering a public page, filter on `status` and
on `isVisible` yourself.
## Keep tokens server-side [#keep-tokens-server-side]
A `setto_live_` token grants read access to your whole organization. It belongs
in a server environment variable, a secret manager or a serverless function —
never in a browser bundle, a mobile app, a public repository, or a URL.
If you need tournament data in a browser, put a small server route in front of
the API:
```js title="app/api/standings/route.ts"
export async function GET() {
const res = await fetch(
'https://api-production-ea80.up.railway.app/v1/external/rounds/9d3c6a84-7e15-4b02-8f6d-1c4a9e7b53f2',
{
// SETTO_API_TOKEN is server-only. Never prefix it with NEXT_PUBLIC_.
headers: { Authorization: `Bearer ${process.env.SETTO_API_TOKEN}` },
next: { revalidate: 60 },
},
);
// Check the STATUS before the body. An error payload has no `isVisible` and
// no `pools`, so projecting it would answer 200 with an empty result and hide
// an expired token behind "this round has no standings".
if (res.status === 404) {
return new Response('Not found', { status: 404 });
}
if (!res.ok) {
// Your credential's problem is not your visitor's. Log the detail, return a
// generic upstream failure — never forward the upstream body or status.
console.error(`SETTO API ${res.status} while loading standings`);
return Response.json(
{ error: 'upstream_unavailable' },
{ status: 502 },
);
}
const round = await res.json();
// Your token sees hidden and draft material; this route does not require one,
// so anything returned here is public. A hidden round answers 404 like a
// missing one — never confirm that it exists.
if (round.isVisible === false) {
return new Response('Not found', { status: 404 });
}
// ...then publish an explicit projection rather than the upstream payload, so
// a field added to the API later cannot leak through this route by default.
return Response.json({
name: round.name,
pools: (round.pools ?? []).map((pool) => ({
name: pool.name,
standings: (pool.standings ?? []).map((row) => ({
rank: row.rank,
team: row.team?.name,
points: row.awardedPoints,
})),
})),
});
}
```
Cache the projection, never the upstream response — a cached full payload is a
leak waiting for the next person who reaches for it.
The same rule applies to the **Try it** panel in the [reference](/docs/reference):
it fires the request from your own browser, so paste a throwaway or soon-to-be
rotated token rather than your production one.
## Rotation and revocation [#rotation-and-revocation]
Tokens do not rotate themselves. To rotate:
1. Create a second token with the same intent (`Website widget (new)`).
2. Deploy the new value to the consumer.
3. Confirm traffic moved — `token.lastUsedAt` on the new token starts advancing.
4. Revoke the old token from the **API** tab.
Revocation takes effect immediately: the next request with that token is a `401`
with `INVALID_API_TOKEN`. Revoke first and ask questions later if a token leaks —
nothing about the API is stateful, so a revoked-and-replaced token costs you
nothing but a redeploy.
Rotate on a schedule if you can, and always when someone with access leaves.
## 401 vs 403 [#401-vs-403]
They mean different things and want different responses.
| Status | Codes | What happened | What to do |
| ------ | -------------------- | ---------------------------------------------------------------------------------- | ------------------ |
| `401` | `MISSING_API_TOKEN` | No `Authorization` header, or it was not `Bearer setto_…` | Fix the request |
| `401` | `INVALID_API_TOKEN` | Unknown, revoked, or wrong-environment token (a `setto_test_` token in production) | Re-issue the token |
| `401` | `EXPIRED_API_TOKEN` | The token's `expiresAt` has passed | Create a new token |
| `403` | `INSUFFICIENT_SCOPE` | The token is valid but does not carry the scope the route needs | Do not retry |
In short: `401` means *we do not know who you are* — stop and get a working
credential. `403` means *we know exactly who you are and the answer is still no* —
retrying with the same token will never work.
A resource belonging to another organization is **not** a `403`. It is a `404`
(`TOURNAMENT_NOT_FOUND` / `ROUND_NOT_FOUND`), identical to the response for an id
that does not exist anywhere — the API never confirms the existence of data you
are not allowed to read. See [Errors](/docs/concepts/errors).
# Changelog
URL: https://docs.setto.io/docs/changelog
Version history of the SETTO External API — what shipped, what changed, and what counts as a breaking change.
Every change to the External API is recorded here, newest first. The API is
versioned in its path (`/v1/`): additive changes ship inside `v1`, and anything
that would break an existing integration would ship as a new path prefix.
## What counts as a change [#what-counts-as-a-change]
| Change | Breaking? |
| -------------------------------------------------------------------- | ------------------------ |
| A new endpoint, field or `populate` path | No — additive |
| A new member in an existing enum (a new `status`, `sport` or `type`) | No — treat enums as open |
| A new error `code` for a status you already handle | No |
| Removing or renaming a field, endpoint or `populate` path | Yes |
| Changing a field's type, or a list's default ordering | Yes |
| Tightening validation on a parameter that used to be accepted | Yes |
Build for the additive cases: ignore fields you do not recognise, and fall
through on enum values you have not seen. That way a release like "pickleball
individual leagues now expose a new tournament `type`" never reaches your
on-call.
## 1.0.0 — 2026-09 [#100--2026-09]
Initial public release.
* **Six read-only endpoints**, all under `/v1/external/`: `getMe`,
`listTournaments`, `getTournament`, `listDivisions`, `listTeams`, `getRound`.
* **Organization API tokens.** Created from `app.setto.io` → **Organización** →
the **API** tab by the organization creator or an `ADMIN` member. Shown once,
read-only, revocable, with an optional 30/90/365-day expiry and a maximum of 10
active tokens per organization.
* **Bearer authentication** with `setto_live_` (production) and `setto_test_`
(non-production) prefixes, and the error codes `MISSING_API_TOKEN`,
`INVALID_API_TOKEN`, `EXPIRED_API_TOKEN` and `INSUFFICIENT_SCOPE`.
* **Organization scoping.** A token reads only the tournaments its organization
owns; anything else — including another organization's data and personal
tournaments — answers `404`, never `403`, so ids cannot be probed.
* **`populate`**, an allow-listed comma-separated relation list per endpoint, with
implicit parent expansion and per-endpoint defaults. Unknown values are a `400`.
* **Offset pagination** on `listTournaments`: `limit` `1..100` (default `25`),
`offset`, and a `meta` envelope with `limit`, `offset` and `total`. Rows are
ordered by `startDate` descending.
* **Rate limiting** at 120 requests per 60 seconds per token, with
`X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` on every
response and `Retry-After` on a `429`.
* **Conditional requests.** Strong `ETag` plus `If-None-Match` returns `304 Not
Modified` so polling a round costs almost nothing.
* **A privacy-safe projection.** No emails, phone numbers or social handles; no
payment or billing data. Player objects carry `idx`, `firstName`, `lastName`
and `avatar` only.
* **Hidden and draft material is returned, flagged rather than filtered** —
`isVisible: false` on divisions and rounds, and `DRAFT`/`ARCHIVED` tournaments
in `listTournaments`.
* **This documentation site**, with a generated reference and a **Try it**
playground, a published [`/openapi.json`](/openapi.json), a Markdown rendition
of every page, `/llms.txt` and `/llms-full.txt`, a remote MCP server and an
Agent Skill.
# SETTO External API
URL: https://docs.setto.io/docs
A read-only HTTP API that lets an organizer pull their own SETTO tournaments, categories, teams, rounds, games and standings into a website, a spreadsheet or an AI agent.
The SETTO External API is a read-only HTTP API. With an organization API token you
can read every tournament your SETTO organization owns — its categories
(divisions), teams, rounds, groups, brackets, games and standings — and render
them wherever you want. There are six endpoints, all `GET`, all under
`/v1/external/`. This page explains what the API covers and where to go next;
the [quickstart](/docs/quickstart) has a working request in under five minutes.
## What you can read [#what-you-can-read]
| Resource | Endpoint |
| ------------------------------------------------ | ---------------------------------------------- |
| The organization and token behind the request | `GET /v1/external/me` |
| Tournaments owned by the organization | `GET /v1/external/tournaments` |
| One tournament, with optional relations | `GET /v1/external/tournaments/{idx}` |
| A tournament's categories (divisions) | `GET /v1/external/tournaments/{idx}/divisions` |
| A tournament's teams and their players | `GET /v1/external/tournaments/{idx}/teams` |
| One round: groups, standings, games and brackets | `GET /v1/external/rounds/{idx}` |
That is the whole surface. There is no write access: the API cannot create a
tournament, submit a score or register a player.
## Who it is for [#who-it-is-for]
* **Clubs and federations** embedding live draws, schedules and standings on
their own website.
* **Sponsors and media partners** pulling fixtures and results for a broadcast
graphic or a newsletter.
* **Analysts** exporting a season into a spreadsheet or a database.
* **AI agents** — every page here is also served as Markdown, and there is an
MCP server and an Agent Skill for the same six endpoints.
## Base URL [#base-url]
```
https://api-production-ea80.up.railway.app/v1
```
Every path in this documentation is written in full (`/v1/external/tournaments`),
so it appends directly to the host. A friendlier `api.setto.io` host will replace
the Railway one later; when it does, the paths stay identical.
## Authentication in one line [#authentication-in-one-line]
Send your organization token as a bearer token on every request:
```http
GET /v1/external/tournaments HTTP/1.1
Host: api-production-ea80.up.railway.app
Authorization: Bearer setto_live_YOUR_TOKEN_HERE
```
Tokens are created in the SETTO organizer dashboard and belong to an
**organization**, not to a person. See [Authentication](/docs/authentication).
## What the API never returns [#what-the-api-never-returns]
The External API is designed to be safe to put in front of a public website. It
never returns personal contact data (no emails, no phone numbers, no social
handles) and never returns payment or billing data. Player objects carry only
`idx`, `firstName`, `lastName` and `avatar`.
Two things it *does* return, which surprise people:
* **Hidden divisions and rounds.** They come back with `isVisible: false` instead
of being dropped, so you decide whether to render them.
* **Draft and archived tournaments.** Filter them out with the `status` query
parameter if you only want live ones.
## Where to go next [#where-to-go-next]
# Quickstart
URL: https://docs.setto.io/docs/quickstart
Create an organization API token in the SETTO dashboard and make your first three requests to the External API with curl, JavaScript or Python.
This page takes you from nothing to a tournament payload in five minutes: create
a token in the SETTO organizer dashboard, confirm it with `GET /v1/external/me`,
list your tournaments, then fetch one tournament with its categories embedded.
Every request is a plain `GET` with an `Authorization: Bearer` header — no SDK,
no OAuth dance, no client secret.
You need an organization in SETTO and either created it yourself or hold the
`ADMIN` role in it. Personal tournaments — ones that are not attached to an
organization — are not reachable through this API.
## Create an API token [#create-an-api-token]
In [app.setto.io](https://app.setto.io), open **Organización**, pick your
organization, and go to the **API** tab. Choose **Create token**, give it a name
you will recognise later (`Website widget`, `Season export`, …), optionally pick
an expiry of 30, 90 or 365 days, and confirm.
SETTO stores only a hash of the token. The full `setto_live_…` value appears
in the dialog immediately after creation and never again — copy it into your
secret manager before closing. If you lose it, revoke the token and create a
new one.
An organization can hold up to **10 active tokens**. Every token is read-only
(`scopes: ["read"]`) and can be revoked at any time from the same tab.
## Confirm the token [#confirm-the-token]
`GET /v1/external/me` is the cheapest way to prove a token works. It tells you
which organization the token belongs to, when it was created, when it expires,
and what your rate limit is.
```bash
export SETTO_API_TOKEN="setto_live_YOUR_TOKEN_HERE"
curl -s https://api-production-ea80.up.railway.app/v1/external/me \
-H "Authorization: Bearer $SETTO_API_TOKEN"
```
```js
const BASE = 'https://api-production-ea80.up.railway.app/v1';
const res = await fetch(`${BASE}/external/me`, {
headers: { Authorization: `Bearer ${process.env.SETTO_API_TOKEN}` },
});
if (!res.ok) throw new Error(`SETTO API ${res.status}: ${await res.text()}`);
console.log(await res.json());
```
```python
import os
import requests
BASE = "https://api-production-ea80.up.railway.app/v1"
res = requests.get(
f"{BASE}/external/me",
headers={"Authorization": f"Bearer {os.environ['SETTO_API_TOKEN']}"},
timeout=10,
)
res.raise_for_status()
print(res.json())
```
```json title="200 OK"
{
"organization": {
"idx": "6f1d9c2e-4a7b-4f3d-9d2c-8b5e1a0f7c43",
"name": "Club Padel Monterrey",
"slug": "club-padel-monterrey",
"avatar": "https://cdn.setto.io/organizations/club-padel-monterrey.png",
"website": "https://clubpadelmty.mx"
},
"token": {
"idx": "b8e3f107-5c92-4d6a-8e71-0a4c9d2b3f65",
"name": "Website widget",
"prefix": "setto_live_9f2ca",
"scopes": ["read"],
"createdAt": "2026-09-01T16:04:22.113Z",
"expiresAt": null,
"lastUsedAt": "2026-09-17T09:12:44.005Z"
},
"rateLimit": { "limit": 120, "windowSeconds": 60 }
}
```
A `401` here means the header is missing or the token is wrong — see
[Errors](/docs/concepts/errors).
## List your tournaments [#list-your-tournaments]
`GET /v1/external/tournaments` returns a page of tournaments ordered by
`startDate` descending, newest first.
```bash
curl -s -G https://api-production-ea80.up.railway.app/v1/external/tournaments \
-H "Authorization: Bearer $SETTO_API_TOKEN" \
-d status=IN_PROGRESS \
-d sport=PADEL \
-d limit=5
```
```js
const params = new URLSearchParams({
status: 'IN_PROGRESS',
sport: 'PADEL',
limit: '5',
});
const res = await fetch(`${BASE}/external/tournaments?${params}`, {
headers: { Authorization: `Bearer ${process.env.SETTO_API_TOKEN}` },
});
const { data, meta } = await res.json();
console.log(`${data.length} of ${meta.total} tournaments`);
```
```python
res = requests.get(
f"{BASE}/external/tournaments",
headers={"Authorization": f"Bearer {os.environ['SETTO_API_TOKEN']}"},
params={"status": "IN_PROGRESS", "sport": "PADEL", "limit": 5},
timeout=10,
)
res.raise_for_status()
body = res.json()
print(len(body["data"]), "of", body["meta"]["total"])
```
```json title="200 OK (trimmed)"
{
"data": [
{
"idx": "3c9b7f52-6d41-4a8e-b1f0-2e7d5c9a4b18",
"slug": "torneo-apertura-2026",
"name": "Torneo Apertura 2026",
"type": "TOURNAMENT",
"sport": "PADEL",
"status": "IN_PROGRESS",
"startDate": "2026-09-18T00:00:00.000Z",
"endDate": "2026-09-20T00:00:00.000Z",
"timezone": "America/Monterrey",
"publicUrl": "https://www.setto.io/t/torneo-apertura-2026",
"organization": {
"idx": "6f1d9c2e-4a7b-4f3d-9d2c-8b5e1a0f7c43",
"name": "Club Padel Monterrey",
"slug": "club-padel-monterrey"
}
}
],
"meta": { "limit": 5, "offset": 0, "total": 1 }
}
```
Keep the `idx` — it is the identifier every other endpoint takes.
## Fetch one tournament with its categories [#fetch-one-tournament-with-its-categories]
Relations are opt-in. Ask for them with `populate`, a comma-separated list.
`GET /v1/external/tournaments/{idx}` defaults to `club,divisions`; asking for
`divisions` alone keeps the payload small.
```bash
curl -s -G \
https://api-production-ea80.up.railway.app/v1/external/tournaments/3c9b7f52-6d41-4a8e-b1f0-2e7d5c9a4b18 \
-H "Authorization: Bearer $SETTO_API_TOKEN" \
-d populate=divisions
```
```js
const idx = '3c9b7f52-6d41-4a8e-b1f0-2e7d5c9a4b18';
const res = await fetch(
`${BASE}/external/tournaments/${idx}?populate=divisions`,
{ headers: { Authorization: `Bearer ${process.env.SETTO_API_TOKEN}` } },
);
const tournament = await res.json();
for (const division of tournament.divisions) {
console.log(division.number, division.name, division.isVisible);
}
```
```python
idx = "3c9b7f52-6d41-4a8e-b1f0-2e7d5c9a4b18"
res = requests.get(
f"{BASE}/external/tournaments/{idx}",
headers={"Authorization": f"Bearer {os.environ['SETTO_API_TOKEN']}"},
params={"populate": "divisions"},
timeout=10,
)
res.raise_for_status()
for division in res.json()["divisions"]:
print(division["number"], division["name"], division["isVisible"])
```
```json title="200 OK (trimmed)"
{
"idx": "3c9b7f52-6d41-4a8e-b1f0-2e7d5c9a4b18",
"slug": "torneo-apertura-2026",
"name": "Torneo Apertura 2026",
"type": "TOURNAMENT",
"sport": "PADEL",
"status": "IN_PROGRESS",
"publicUrl": "https://www.setto.io/t/torneo-apertura-2026",
"divisions": [
{
"idx": "7b2f4d16-9c83-4e50-a7d1-3f6c8b204e59",
"name": "Cuarta Fuerza Varonil",
"number": 1,
"color": "#2563eb",
"isDoubles": true,
"isVisible": true,
"price": 900,
"tournament": { "idx": "3c9b7f52-6d41-4a8e-b1f0-2e7d5c9a4b18" }
}
]
}
```
## Next steps [#next-steps]
* [Populate](/docs/concepts/populate) — the exact relation list per endpoint, and
why a nested path needs its parents.
* [Get a round](/docs/guides/get-round) — groups, standings, games and brackets
in a single request. This is the endpoint most integrations spend their time
in.
* [Rate limits](/docs/concepts/rate-limits) — 120 requests per minute per token,
and a second per-IP ceiling of 300 requests per minute.
* [API reference](/docs/reference) — every parameter, with a **Try it** panel you
can paste a token into.
# Docs for LLMs
URL: https://docs.setto.io/docs/ai/llms
Every page of these docs is available as raw Markdown, plus llms.txt, llms-full.txt and openapi.json — the machine-readable artifacts an AI agent or a RAG pipeline needs.
These docs are written to be read twice: once by you, once by a model. Every
page has a raw Markdown twin, the whole site is available as two plain-text
bundles, and the API contract is published as OpenAPI. No scraping, no
JavaScript rendering, no API key.
## The four artifacts [#the-four-artifacts]
| URL | What it is | Use it for |
| ------------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------ |
| [`/llms.txt`](https://docs.setto.io/llms.txt) | An index: one line per page, with its title and description | Letting an agent decide what to fetch |
| [`/llms-full.txt`](https://docs.setto.io/llms-full.txt) | Every page's Markdown concatenated into one document | Dropping the whole documentation into a context window |
| `.md` | Any single page as raw Markdown | Fetching exactly the page you need |
| [`/openapi.json`](https://docs.setto.io/openapi.json) | The OpenAPI 3 description of the six endpoints | Generating a client, or feeding a tool-calling model |
## `llms.txt` and `llms-full.txt` [#llmstxt-and-llms-fulltxt]
`/llms.txt` follows the [llms.txt convention](https://llmstxt.org): a short
Markdown index of the site, one bullet per page, cheap to fetch and cheap to
read. Start there when you want a model to pick its own reading.
```bash
curl -s https://docs.setto.io/llms.txt
```
`/llms-full.txt` is the same site with the bodies included — every guide, every
concept page and the whole generated reference in one response. It is the
single-request way to give a model everything:
```bash
curl -s https://docs.setto.io/llms-full.txt
```
Both describe the canonical **English** site. Spanish pages live under
`/es/docs/…` and have the same `.md` twins.
## Any page as Markdown [#any-page-as-markdown]
Append `.md` to a docs URL and you get the source Markdown instead of the
rendered page:
```bash
curl -s https://docs.setto.io/docs/concepts/populate.md
curl -s https://docs.setto.io/docs/guides/get-round.md
curl -s https://docs.setto.io/es/docs/quickstart.md
```
Generated reference pages work too. Their rendered form is an interactive
component, so the Markdown twin is assembled from the OpenAPI document instead —
method and path, auth, a parameter table, the response table and a curl example:
```bash
curl -s https://docs.setto.io/docs/reference/tournaments/list-tournaments.md
```
## Copy and open-in-AI actions [#copy-and-open-in-ai-actions]
Every page carries two controls under its title:
* **Copy Markdown** — puts that page's raw Markdown on your clipboard, ready to
paste into a chat.
* **View options** — the same page as Markdown in a new tab, or opened directly
in ChatGPT, Claude or another assistant with a prompt that references the URL.
They are the one-click version of the `curl` commands above; use whichever fits.
## `/openapi.json` [#openapijson]
```bash
curl -s https://docs.setto.io/openapi.json
```
The document the [API reference](/docs/reference) is generated from, with
`servers[0].url` already pointing at the live API host. Six operations, stable
`operationId`s — `getMe`, `listTournaments`, `getTournament`, `listDivisions`,
`listTeams`, `getRound` — so a generated client or a tool-calling model gets the
same names the guides use.
## For RAG builders [#for-rag-builders]
A few properties worth relying on:
* **URLs are stable.** A page's path is its identity; we do not renumber pages
or reshuffle sections underneath a URL. Store the URL as the citation for a
chunk and it keeps pointing at the same material.
* **`.md` is the canonical text.** Index the Markdown twin, not the rendered
HTML — no navigation chrome, no duplicated sidebar text, and headings map
cleanly onto chunks.
* **Headings are meaningful.** Every page starts with a one-paragraph summary
under the title, then `##` sections that stand alone. Chunk on `##` and each
chunk is still answerable.
* **Re-crawl cheaply.** Fetch `/llms.txt` and diff it to see which pages exist;
fetch `/llms-full.txt` when you want everything in one request.
* **The reference is generated.** `/docs/reference/**` is produced from
`/openapi.json`. If you are indexing for API questions, the OpenAPI document
is the denser, more reliable source.
* **Two locales, one content tree.** English is unprefixed, Spanish is under
`/es/`. Pages that have no Spanish translation yet fall back to the English
text at the Spanish URL, so treat `/es/…` duplicates as the same document.
## Related [#related]
* [MCP server](/docs/ai/mcp) — live tournament data as tool calls, not docs.
* [Agent Skills](/docs/ai/skills) — the `setto-api` skill and how to install it.
* [API reference](/docs/reference) — the rendered form of `/openapi.json`.
# MCP server
URL: https://docs.setto.io/docs/ai/mcp
Connect Claude Code, Cursor or any Streamable HTTP MCP client to the SETTO External API with six read-only tools, authenticated by the same organization API token.
SETTO publishes a remote [Model Context Protocol](https://modelcontextprotocol.io)
server at `https://docs.setto.io/mcp`. It exposes the same six read-only
endpoints as the HTTP API as MCP **tools**, so an assistant can answer "how many
teams are in the Cuarta Fuerza category?" by calling a tool instead of you
pasting JSON into a chat. It takes the same organization API token you already
use — there is nothing extra to provision.
## Endpoint [#endpoint]
| Property | Value |
| --------- | ---------------------------------------------------------------------------------- |
| URL | `https://docs.setto.io/mcp` |
| Transport | Streamable HTTP |
| Auth | `Authorization: Bearer setto_live_…` — the same organization token as the REST API |
| Access | Read-only |
The server is a thin proxy: it forwards each tool call to the External API with
your token and hands the JSON back. Anything the API refuses — another
organization's tournament, a write — the MCP server refuses too.
## Tools [#tools]
| Tool | Endpoint behind it | Guide |
| ------------------ | ---------------------------------------------- | ------------------------------------------------- |
| `me` | `GET /v1/external/me` | [Authentication](/docs/authentication) |
| `list_tournaments` | `GET /v1/external/tournaments` | [List tournaments](/docs/guides/list-tournaments) |
| `get_tournament` | `GET /v1/external/tournaments/{idx}` | [Get a tournament](/docs/guides/get-tournament) |
| `list_divisions` | `GET /v1/external/tournaments/{idx}/divisions` | [List divisions](/docs/guides/list-divisions) |
| `list_teams` | `GET /v1/external/tournaments/{idx}/teams` | [List teams](/docs/guides/list-teams) |
| `get_round` | `GET /v1/external/rounds/{idx}` | [Get a round](/docs/guides/get-round) |
Arguments mirror the query parameters documented for each endpoint, including
`populate`, `limit`/`offset` and the `division` filter. There is no tool that
writes.
## Connect a client [#connect-a-client]
```bash
claude mcp add --transport http setto https://docs.setto.io/mcp \
--header "Authorization: Bearer $SETTO_API_TOKEN"
```
Export `SETTO_API_TOKEN` in the shell you run that command from, so the secret
never appears in your shell history. Confirm with `/mcp` inside Claude Code —
`setto` should list the six tools.
Create `.cursor/mcp.json` in your project (or `~/.cursor/mcp.json` to make it
global):
```json title=".cursor/mcp.json"
{
"mcpServers": {
"setto": {
"url": "https://docs.setto.io/mcp",
"headers": {
"Authorization": "Bearer ${env:SETTO_API_TOKEN}"
}
}
}
}
```
The `${env:…}` form keeps the token in your environment rather than in a file
you might commit. Reload Cursor, then check **Settings → MCP**.
## Clients this does not work with yet [#clients-this-does-not-work-with-yet]
Claude Desktop and claude.ai custom connectors, and ChatGPT's connectors,
require the MCP server to implement OAuth. Version 1 of the SETTO MCP server
authenticates with a bearer token only, so those clients cannot connect to it.
Until OAuth lands, use one of these instead:
* **Claude Code or Cursor** — both accept a static `Authorization` header, as
above.
* **The Agent Skill** — [`setto-api`](/docs/ai/skills) teaches any agent the six
endpoints and it makes plain HTTP calls. That works in every client, including
the ones above.
* **The REST API directly** — see the [quickstart](/docs/quickstart).
## Security notes [#security-notes]
* **The token is a header, not a secret the model sees.** Configure it in the
client, never paste it into a conversation — a pasted token ends up in the
transcript and, depending on your setup, in logs.
* **Use the environment.** `$SETTO_API_TOKEN` in Claude Code, `${env:…}` in
Cursor. Do not commit `.cursor/mcp.json` with a literal token in it.
* **Scope what you can.** A token reads one organization and nothing else. If
you are connecting a shared workspace, create a dedicated token for it so you
can revoke it alone.
* **Read-only by construction.** There is no tool that mutates SETTO data, so a
confused or prompt-injected model cannot change a draw or a score through this
server.
* **It counts against your rate limit.** Tool calls are API requests: 120 per
60 seconds per token, shared with anything else using that token. See
[Rate limits](/docs/concepts/rate-limits).
## A sample conversation [#a-sample-conversation]
> **You:** Which categories in Torneo Apertura 2026 still have unfinished games?
>
> **Assistant:** *calls `list_tournaments` with `status: "IN_PROGRESS"`* — found
> `Torneo Apertura 2026` (`3c9b7f52-…`).
>
> *calls `list_divisions` with that tournament and `populate: "rounds"`* — three
> categories, five rounds between them.
>
> *calls `get_round` for each round with `populate: "games"`* —
>
> Cuarta Fuerza Varonil has 4 of 12 games still `UPCOMING`; Primera Fuerza
> Femenil is complete; Mixta B has 1 game left, scheduled for Saturday 10:00.
Three tool names, no JSON on your screen. The assistant is reading the same
payloads documented in the [guides](/docs/guides/list-tournaments) — if a number
looks wrong, ask it which tool call produced it and check that endpoint by hand.
## Related [#related]
* [Agent Skills](/docs/ai/skills) — the client-agnostic fallback, and what to
install when MCP is not an option.
* [Docs for LLMs](/docs/ai/llms) — `llms.txt`, per-page Markdown, `/openapi.json`.
* [Rate limits](/docs/concepts/rate-limits) — the budget your tool calls share.
# Agent Skills
URL: https://docs.setto.io/docs/ai/skills
Install the setto-api Agent Skill so Claude Code, Cursor or any AGENTS.md-aware agent knows the six SETTO External API endpoints, their populate rules and their error codes.
An [Agent Skill](https://agentskills.io) is a folder with a `SKILL.md` inside:
YAML frontmatter that says what the skill is for, and a Markdown body the agent
loads when a task matches. SETTO publishes one, `setto-api`, that teaches an
agent the whole External API — the six endpoints, the `populate` allow-lists,
the error codes and the "never print the token" rule — without you explaining it
each time.
It is the fallback for every client the [MCP server](/docs/ai/mcp) cannot reach,
and it is useful alongside MCP too: the skill is what tells an agent *when* the
SETTO tools are the right move.
## What is in it [#what-is-in-it]
The skill is served from this site under `/.well-known/skills/`, with
`/skills/…` as a shorter alias for the same files.
| File | What it is |
| ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| [`setto-api/SKILL.md`](https://docs.setto.io/skills/setto-api/SKILL.md) | The skill itself: when to use it, auth, the six endpoints with a curl each, populate, pagination, errors, safety |
| [`setto-api/references/endpoints.md`](https://docs.setto.io/skills/setto-api/references/endpoints.md) | Every parameter and every legal `populate` value, per endpoint, plus the data model |
| [`setto-api/references/errors.md`](https://docs.setto.io/skills/setto-api/references/errors.md) | Every error code, its cause, and whether a retry helps |
| [`setto-api/cursor/setto-api.mdc`](https://docs.setto.io/skills/setto-api/cursor/setto-api.mdc) | The same material as a Cursor project rule |
| [`setto-api/AGENTS.md`](https://docs.setto.io/skills/setto-api/AGENTS.md) | A paste-in section for an existing `AGENTS.md` |
| [`index.json`](https://docs.setto.io/skills/index.json) | Machine-readable catalogue of the skills this site publishes |
The frontmatter, if you want to see the shape before installing:
```yaml title="SKILL.md"
---
name: setto-api
description: "Read SETTO tournament data (tournaments, divisions/categories, teams, rounds, games, standings) for one organization through the SETTO External API with an org API token. …"
license: MIT
metadata:
version: "1.0.0"
docs: "https://docs.setto.io"
openapi: "https://docs.setto.io/openapi.json"
mcp: "https://docs.setto.io/mcp"
---
```
## Install it [#install-it]
The [`skills` CLI](https://github.com/vercel-labs/skills) accepts a direct
download URL to a single `SKILL.md`, which is what this site serves:
```bash
npx skills add https://docs.setto.io/.well-known/skills/setto-api/SKILL.md
```
By default it installs into the current project. Useful flags:
| Flag | Effect |
| --------------------------- | ----------------------------------------------------- |
| `-g`, `--global` | Install to your user directory instead of the project |
| `-a`, `--agent ` | Target specific agents, e.g. `claude-code`, `codex` |
The CLI's other supported sources are a GitHub shorthand (`owner/repo`), a full
GitHub, GitLab or Azure Repos URL, any git URL, a local path, and compressed
archives. The SETTO repository is private, so the direct `SKILL.md` URL above is
the one to use.
Installing from a single `SKILL.md` URL brings that file only. Add the two
reference files by hand if you want them — see the manual tab.
No CLI, no network beyond `curl`:
```bash
mkdir -p .claude/skills/setto-api/references
curl -fsSL https://docs.setto.io/skills/setto-api/SKILL.md \
-o .claude/skills/setto-api/SKILL.md
curl -fsSL https://docs.setto.io/skills/setto-api/references/endpoints.md \
-o .claude/skills/setto-api/references/endpoints.md
curl -fsSL https://docs.setto.io/skills/setto-api/references/errors.md \
-o .claude/skills/setto-api/references/errors.md
```
Swap `.claude/skills` for `~/.claude/skills` to install it for every project.
Restart Claude Code and the skill is offered whenever a task mentions SETTO.
Cursor reads project rules from `.cursor/rules/`:
```bash
mkdir -p .cursor/rules
curl -fsSL https://docs.setto.io/skills/setto-api/cursor/setto-api.mdc \
-o .cursor/rules/setto-api.mdc
```
The rule ships `alwaysApply: false` and an empty `globs` list, so it is applied
by description — Cursor pulls it in when the conversation is about SETTO rather
than on every file you open. Change `alwaysApply` to `true` if your whole
repository is a SETTO integration.
For agents that read a single `AGENTS.md` at the repo root, append the ready-made
section:
```bash
curl -fsSL https://docs.setto.io/skills/setto-api/AGENTS.md >> AGENTS.md
```
It is a self-contained `## SETTO External API` section of about forty lines —
base URL, auth, the endpoint table, populate, the error codes and the gotchas.
Review the result before committing; `>>` appends blindly.
## Keep the token out of it [#keep-the-token-out-of-it]
The skill never contains a token, and it tells the agent to read one from the
`SETTO_API_TOKEN` environment variable and never print, log or commit it. Keep
it that way: do not paste your token into `SKILL.md`, `setto-api.mdc` or
`AGENTS.md` — those files get committed.
## Updating [#updating]
There is no auto-update. Re-run the install command to pull the current version;
the file overwrites cleanly. `metadata.version` in the frontmatter tells you what
you have — compare it against
[`SKILL.md`](https://docs.setto.io/skills/setto-api/SKILL.md) on this site.
Breaking changes to the API are listed in the [changelog](/docs/changelog).
## Discovering it programmatically [#discovering-it-programmatically]
`https://docs.setto.io/.well-known/skills/index.json` lists what this site
publishes, so a tool can find the skill without scraping the docs:
```json title="/.well-known/skills/index.json"
{
"skills": [
{
"name": "setto-api",
"description": "Read SETTO tournament data …",
"files": [
"SKILL.md",
"references/endpoints.md",
"references/errors.md",
"cursor/setto-api.mdc",
"AGENTS.md"
]
}
]
}
```
Each `files` entry is relative to `/.well-known/skills//`.
## Related [#related]
* [MCP server](/docs/ai/mcp) — real tool calls instead of hand-written HTTP,
where the client supports it.
* [Docs for LLMs](/docs/ai/llms) — `llms.txt`, per-page Markdown, `/openapi.json`.
* [Authentication](/docs/authentication) — where the token in `SETTO_API_TOKEN`
comes from.
# Get a round
URL: https://docs.setto.io/docs/guides/get-round
Use GET /v1/external/rounds/{idx} to read one round's groups, standings, games, scores and knockout bracket — the richest endpoint in the SETTO External API.
`GET /v1/external/rounds/{idx}` is where the results live. One round is one phase
of a category — group play, playoffs, qualifiers — and this endpoint returns its
pools and standings, its flat list of games, and its bracket structure, with 22
populate paths to choose from. A live scoreboard, a standings table and a draw
sheet are all this one call with a different `populate`.
Round ids come from [`getTournament`](/docs/guides/get-tournament) with
`populate=divisions.rounds`, or from
[`listDivisions`](/docs/guides/list-divisions).
## Request [#request]
```http
GET /v1/external/rounds/9d3c6a84-7e15-4b02-8f6d-1c4a9e7b53f2 HTTP/1.1
Host: api-production-ea80.up.railway.app
Authorization: Bearer setto_live_…
```
### Parameters [#parameters]
| Parameter | In | Notes |
| ---------- | ----- | -------------------------------------------------------------------------------- |
| `idx` | path | The **round's** UUID v4 |
| `populate` | query | Up to 22 paths — see [Populate](/docs/concepts/populate#get-v1externalroundsidx) |
Sent with no `populate`, the endpoint applies a generous default: `division`,
`pools`, `pools.teams`, `games`, `games.homeTeam`, `games.awayTeam`,
`games.winner`, `draws.rounds`, `draws.rounds.games`,
`draws.rounds.games.homeTeam`, `draws.rounds.games.awayTeam`,
`draws.rounds.games.winner`, `draws.rounds.games.score` and
`draws.rounds.games.score.sets`.
```bash
# Standings table for a group-stage round
curl -s -G \
https://api-production-ea80.up.railway.app/v1/external/rounds/9d3c6a84-7e15-4b02-8f6d-1c4a9e7b53f2 \
-H "Authorization: Bearer $SETTO_API_TOKEN" \
-d populate=pools.standings.team,pools.standings.teamStats
```
```js
const BASE = 'https://api-production-ea80.up.railway.app/v1';
const roundIdx = '9d3c6a84-7e15-4b02-8f6d-1c4a9e7b53f2';
const query = new URLSearchParams({
populate: 'pools.standings.team,pools.standings.teamStats',
});
const res = await fetch(`${BASE}/external/rounds/${roundIdx}?${query}`, {
headers: { Authorization: `Bearer ${process.env.SETTO_API_TOKEN}` },
});
const round = await res.json();
for (const pool of round.pools) {
console.log(pool.name);
for (const s of pool.standings) {
console.log(s.rank, s.team.name, s.teamStats.wins, s.teamStats.losses);
}
}
```
## Response — group stage [#response--group-stage]
```json title="200 OK (ROUND_ROBIN_ROUND, trimmed)"
{
"idx": "9d3c6a84-7e15-4b02-8f6d-1c4a9e7b53f2",
"name": "Pool Play",
"type": "ROUND_ROBIN_ROUND",
"number": 1,
"isSetup": true,
"isVisible": true,
"numberOfSets": 3,
"proSets": false,
"playAllSets": false,
"gamesPerTeam": 3,
"hasNextRoundTransitioned": true,
"createdAt": "2026-06-02T18:25:10.338Z",
"division": {
"idx": "7b2f4d16-9c83-4e50-a7d1-3f6c8b204e59",
"name": "Cuarta Fuerza Varonil"
},
"pools": [
{
"idx": "5a8d2c71-3f96-4e18-b0d7-9c2e6a4f8b13",
"name": "Grupo A",
"number": 1,
"standings": [
{
"idx": "3f8b0d25-7a14-4e69-b2c8-0d5e9f1a6c73",
"rank": 1,
"awardedPoints": 6,
"team": {
"idx": "e0c4b839-2d17-4a56-9fb3-6e8d1c05a274",
"name": "Álvarez / Peña"
},
"teamStats": {
"idx": "7c1a4e08-9d35-42b7-8f60-3a2e5b9d1f47",
"points": 6,
"wins": 2,
"losses": 0,
"ties": 0,
"cancelled": 0,
"forfeits": 0,
"pointsFor": 4,
"pointsAgainst": 1,
"setsPointsFor": 48,
"setsPointsAgainst": 31,
"totalGamesPlayed": 2,
"pointsDifference": 3,
"setsPointsDifference": 17
}
}
]
}
],
"games": [
{
"idx": "4f6e9b27-5a03-4c81-bd52-7e1a8c3f9d06",
"number": 1,
"displayNumber": 1,
"status": "FINISHED",
"time": "2026-09-18T17:00:00.000Z",
"lengthInMinutes": 90,
"courtNumber": 3,
"matchDay": null,
"isRetirement": false,
"homeTeamDetails": null,
"awayTeamDetails": null,
"createdAt": "2026-06-02T18:26:44.019Z",
"homeTeam": {
"idx": "e0c4b839-2d17-4a56-9fb3-6e8d1c05a274",
"name": "Álvarez / Peña"
},
"awayTeam": {
"idx": "b71f3a05-6c28-49ed-8a94-2f5b0d7e1c63",
"name": "Ramos / Ortega"
},
"winner": {
"idx": "e0c4b839-2d17-4a56-9fb3-6e8d1c05a274",
"name": "Álvarez / Peña"
}
}
]
}
```
The flat `games` array above has no `score` key, because the default populate
covers `draws.rounds.games.score` but not `games.score`. For scores on the
flat list, ask for them — and remember `populate` replaces the default, so
re-list what you still need:
`populate=division,pools,pools.teams,games,games.homeTeam,games.awayTeam,games.winner,games.score,games.score.sets`.
## Response — knockout bracket [#response--knockout-bracket]
```json title="200 OK (SINGLE_ELIMINATION_ROUND, trimmed)"
{
"idx": "2e7f5b10-8c94-4d63-a1b8-7f0e3d6c2a95",
"name": "Playoffs",
"type": "SINGLE_ELIMINATION_ROUND",
"number": 2,
"isSetup": true,
"isVisible": true,
"drawSize": 8,
"numQualifiers": 8,
"placements": [1, 3],
"division": {
"idx": "7b2f4d16-9c83-4e50-a7d1-3f6c8b204e59",
"name": "Cuarta Fuerza Varonil"
},
"draws": [
{
"idx": "8e5c2a90-4b17-4d63-9f28-1c7a0e3b5d64",
"placement": 1,
"rounds": [
{
"idx": "d16b8f43-0c95-4e72-a835-6b2d9f1c4e07",
"label": "Semifinals",
"order": 2,
"games": [
{
"idx": "0a7d3c58-6e21-4b94-8f05-2d9c1e7a4b36",
"number": 5,
"displayNumber": 5,
"status": "FINISHED",
"time": "2026-09-20T16:00:00.000Z",
"courtNumber": 1,
"homeTeam": {
"idx": "e0c4b839-2d17-4a56-9fb3-6e8d1c05a274",
"name": "Álvarez / Peña"
},
"awayTeam": {
"idx": "b71f3a05-6c28-49ed-8a94-2f5b0d7e1c63",
"name": "Ramos / Ortega"
},
"winner": {
"idx": "e0c4b839-2d17-4a56-9fb3-6e8d1c05a274",
"name": "Álvarez / Peña"
},
"homeTeamDetails": null,
"awayTeamDetails": null,
"score": {
"idx": "1b9d7c36-4e58-40af-92c1-6d3f8a05b7e2",
"home": 2,
"away": 1,
"playerPointsBalanced": null,
"sets": [
{
"idx": "aa1c4e72-0b96-4d38-85f7-3e2a9c1d6b04",
"number": 1,
"home": 6,
"away": 4,
"isTieBreak": false
},
{
"idx": "bb2d5f83-1c07-4e49-96a8-4f3b0d2e7c15",
"number": 2,
"home": 3,
"away": 6,
"isTieBreak": false
},
{
"idx": "cc3e6094-2d18-4f5a-a7b9-5041ce3f8d26",
"number": 3,
"home": 7,
"away": 5,
"isTieBreak": false
}
]
}
}
]
},
{
"idx": "e27c9a56-1d08-4f83-b946-7c3e0a2f5b18",
"label": "Final",
"order": 3,
"games": [
{
"idx": "5c8f1b04-7a26-4d93-8e51-0b4d9c2a7f63",
"number": 7,
"displayNumber": 7,
"status": "UPCOMING",
"time": "2026-09-20T19:00:00.000Z",
"homeTeam": null,
"awayTeam": null,
"winner": null,
"homeTeamDetails": {
"label": "Winner of Game 5",
"teamNumber": null,
"poolNumber": null,
"poolRank": null,
"sourceGameNumber": 5,
"sourceResult": "WINNER"
},
"awayTeamDetails": {
"label": "Winner of Game 6",
"teamNumber": null,
"poolNumber": null,
"poolRank": null,
"sourceGameNumber": 6,
"sourceResult": "WINNER"
}
}
]
}
]
}
]
}
```
## Tips and pitfalls [#tips-and-pitfalls]
* **Games can appear twice.** A bracket game is in `draws[].rounds[].games[]` and
may also be in the flat `games[]`. De-duplicate on `game.idx` before counting
anything.
* **Sort draw rounds by `order`,** not by array position, and lay them out left to
right. `label` (`Quarterfinals`, `Semifinals`, `Final`) is an English string
produced by SETTO — translate it yourself if your site is not in English.
* **`placement` separates brackets.** `placement: 1` is the main draw;
higher values are consolation and third-place brackets. Filter rather than
assuming `draws[0]`.
* **Empty slots are normal.** Before a bracket fills, `homeTeam`/`awayTeam` are
`null` and `homeTeamDetails` tells you where the team will come from. Render
`sourceResult` + `sourceGameNumber` (`Winner of game 5`) rather than the stored
English `label`.
* **`score.home` / `score.away` are sets won**, not points. Per-set points are in
`sets[]`, ordered by `number`.
* **`status` is not just UPCOMING/FINISHED.** Handle `BYE` (an unopposed
advance), `FORFEIT` and `CANCELLED` — a `BYE` has one team and a winner but no
meaningful score.
* **`rank` is the stored rank.** The public site applies head-to-head tiebreaks on
top, so a table built from `rank` alone can differ from setto.io's. See
[Data model](/docs/concepts/data-model#pool-standing-and-team-stats).
* **Team-league rounds may have `division: null`.** The round belongs to the
tournament, not to a category; do not assume you can group every round under a
division.
* **Hidden rounds come back with `isVisible: false`.** Check it before publishing.
* **This is the payload worth caching.** It changes only when someone enters a
score. Combine a 30–60 s server-side cache with `If-None-Match` — see
[Rate limits](/docs/concepts/rate-limits).
## Related [#related]
* [Populate](/docs/concepts/populate) — all 22 paths and the default list.
* [Data model](/docs/concepts/data-model) — pools, draws, games, scores.
* Reference: [`getRound`](/docs/reference/rounds/get-round).
MCP tool: `get_round`
# Get a tournament
URL: https://docs.setto.io/docs/guides/get-tournament
Use GET /v1/external/tournaments/{idx} to read one tournament and embed its club, categories, rounds, sponsors, circuit and tiebreak rules with populate.
`GET /v1/external/tournaments/{idx}` returns one tournament by its UUID, with any
of seven relations embedded on request. It is the call behind a tournament
landing page: name, dates, venue, categories and sponsors in a single round trip.
Without a `populate` parameter it returns the tournament plus `club` and
`divisions`.
## Request [#request]
```http
GET /v1/external/tournaments/3c9b7f52-6d41-4a8e-b1f0-2e7d5c9a4b18?populate=club,club.courts,divisions,divisions.rounds HTTP/1.1
Host: api-production-ea80.up.railway.app
Authorization: Bearer setto_live_…
```
### Parameters [#parameters]
| Parameter | In | Notes |
| ---------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `idx` | path | The tournament's UUID v4, from [`listTournaments`](/docs/guides/list-tournaments) |
| `populate` | query | Any of `club`, `club.courts`, `divisions`, `divisions.rounds`, `sponsors`, `circuit`, `circuitCategory`, `rankedTiebreakRules`. Default `club,divisions` |
```bash
curl -s -G \
https://api-production-ea80.up.railway.app/v1/external/tournaments/3c9b7f52-6d41-4a8e-b1f0-2e7d5c9a4b18 \
-H "Authorization: Bearer $SETTO_API_TOKEN" \
-d populate=club,divisions,divisions.rounds,sponsors
```
```js
const BASE = 'https://api-production-ea80.up.railway.app/v1';
const idx = '3c9b7f52-6d41-4a8e-b1f0-2e7d5c9a4b18';
const query = new URLSearchParams({
populate: 'club,divisions,divisions.rounds,sponsors',
});
const res = await fetch(`${BASE}/external/tournaments/${idx}?${query}`, {
headers: { Authorization: `Bearer ${process.env.SETTO_API_TOKEN}` },
});
if (res.status === 404) throw new Error('No such tournament for this token');
const tournament = await res.json();
```
## Response [#response]
```json title="200 OK (trimmed)"
{
"idx": "3c9b7f52-6d41-4a8e-b1f0-2e7d5c9a4b18",
"slug": "torneo-apertura-2026",
"name": "Torneo Apertura 2026",
"type": "TOURNAMENT",
"sport": "PADEL",
"status": "IN_PROGRESS",
"startDate": "2026-09-18T00:00:00.000Z",
"endDate": "2026-09-20T00:00:00.000Z",
"timezone": "America/Monterrey",
"courtNames": { "1": "Cancha Central", "2": "Cancha 2" },
"scheduleType": "ASSIGNED",
"isPublic": true,
"isFormatComplete": true,
"displayPoints": true,
"standingColumns": [
{ "ordering": 1, "type": "GAMES_WON" },
{ "ordering": 2, "type": "POINTS_DIFFERENCE" }
],
"createdAt": "2026-06-02T18:21:07.442Z",
"publicUrl": "https://www.setto.io/t/torneo-apertura-2026",
"organization": {
"idx": "6f1d9c2e-4a7b-4f3d-9d2c-8b5e1a0f7c43",
"name": "Club Padel Monterrey",
"slug": "club-padel-monterrey"
},
"club": {
"idx": "5e2b8c04-1a76-4f93-b8d5-7c0e3a9f41b2",
"name": "Club Padel Monterrey",
"address": "Av. Lázaro Cárdenas 2400, Monterrey",
"website": "https://clubpadelmty.mx",
"image": "https://cdn.setto.io/clubs/club-padel-monterrey.jpg",
"location": {
"formattedAddress": "Av. Lázaro Cárdenas 2400, Monterrey, N.L., México",
"city": "Monterrey",
"country": "México",
"lat": 25.6514,
"lng": -100.3561,
"postalCode": "64920",
"province": "Nuevo León"
}
},
"divisions": [
{
"idx": "7b2f4d16-9c83-4e50-a7d1-3f6c8b204e59",
"name": "Cuarta Fuerza Varonil",
"number": 1,
"color": "#2563eb",
"isDoubles": true,
"isVisible": true,
"price": 900,
"minParticipants": 8,
"maxParticipants": 32,
"createdAt": "2026-06-02T18:24:55.901Z",
"tournament": { "idx": "3c9b7f52-6d41-4a8e-b1f0-2e7d5c9a4b18" },
"rounds": [
{
"idx": "9d3c6a84-7e15-4b02-8f6d-1c4a9e7b53f2",
"name": "Pool Play",
"type": "ROUND_ROBIN_ROUND",
"number": 1,
"isSetup": true,
"isVisible": true,
"numberOfSets": 3,
"division": {
"idx": "7b2f4d16-9c83-4e50-a7d1-3f6c8b204e59",
"name": "Cuarta Fuerza Varonil"
}
},
{
"idx": "2e7f5b10-8c94-4d63-a1b8-7f0e3d6c2a95",
"name": "Playoffs",
"type": "SINGLE_ELIMINATION_ROUND",
"number": 2,
"isSetup": true,
"isVisible": true,
"drawSize": 8,
"division": {
"idx": "7b2f4d16-9c83-4e50-a7d1-3f6c8b204e59",
"name": "Cuarta Fuerza Varonil"
}
}
]
}
],
"sponsors": [
{
"idx": "af03d7e5-6b18-4c92-8d40-1e7b5a2c9f36",
"name": "Head",
"logoUrl": "https://cdn.setto.io/sponsors/head.png",
"websiteUrl": "https://head.com",
"position": 1
}
]
}
```
## Tips and pitfalls [#tips-and-pitfalls]
* **`populate` replaces the default.** `?populate=divisions` drops `club` from
the response. Re-list everything you need.
* **`divisions.rounds` is the cheap way to get round ids.** One call gives you
every round `idx` in the tournament, which is exactly the input
[`getRound`](/docs/guides/get-round) wants. You do not need
[`listDivisions`](/docs/guides/list-divisions) for that.
* **Check for the key, not for an empty array.** An un-populated relation is
absent: `tournament.sponsors` is `undefined`, not `[]`.
`'sponsors' in tournament` distinguishes "not asked for" from "none exist".
* **Hidden categories come back.** Filter on `isVisible` before rendering a
public page.
* **`404` is also "wrong organization".** The API will not tell you which — see
[Errors](/docs/concepts/errors).
* **`rankedTiebreakRules` explains the standings order.** If you are rebuilding a
standings table and want it to match setto.io, populate the rules and apply them
in `rank` order rather than trusting the stored `rank` alone.
* **`courtNames` is an object keyed by court number**, not an array.
## Related [#related]
* [Data model](/docs/concepts/data-model) — what every field means.
* [Populate](/docs/concepts/populate) — the full allow-list.
* Reference: [`getTournament`](/docs/reference/tournaments/get-tournament).
MCP tool: `get_tournament`
# List divisions
URL: https://docs.setto.io/docs/guides/list-divisions
Use GET /v1/external/tournaments/{idx}/divisions to read a tournament's categories, their rounds, and optionally every team registered in each one.
`GET /v1/external/tournaments/{idx}/divisions` lists a tournament's categories —
what organizers and players call *categorías*. Each division carries its display
order, colour, price, capacity and visibility, and by default its rounds. Use it
to build a category selector, to find the round ids you will feed to
[`getRound`](/docs/guides/get-round), or — with `populate=teams` — to dump an
entire draw sheet in one request.
The response is a **bare JSON array**, not a `data`/`meta` envelope, and it is not
paginated.
## Request [#request]
```http
GET /v1/external/tournaments/3c9b7f52-6d41-4a8e-b1f0-2e7d5c9a4b18/divisions?populate=rounds HTTP/1.1
Host: api-production-ea80.up.railway.app
Authorization: Bearer setto_live_…
```
### Parameters [#parameters]
| Parameter | In | Notes |
| ---------- | ----- | --------------------------------------------------------------------------------------------------------- |
| `idx` | path | The **tournament's** UUID v4 |
| `populate` | query | Any of `rounds`, `teams`, `teams.playerProfiles`, `teams.leagueTeam`, `circuitCategory`. Default `rounds` |
```bash
curl -s -G \
https://api-production-ea80.up.railway.app/v1/external/tournaments/3c9b7f52-6d41-4a8e-b1f0-2e7d5c9a4b18/divisions \
-H "Authorization: Bearer $SETTO_API_TOKEN" \
-d populate=rounds,teams,teams.playerProfiles
```
```js
const BASE = 'https://api-production-ea80.up.railway.app/v1';
const tournamentIdx = '3c9b7f52-6d41-4a8e-b1f0-2e7d5c9a4b18';
const res = await fetch(
`${BASE}/external/tournaments/${tournamentIdx}/divisions?populate=rounds`,
{ headers: { Authorization: `Bearer ${process.env.SETTO_API_TOKEN}` } },
);
const divisions = await res.json(); // an array
const visible = divisions.filter((d) => d.isVisible);
```
## Response [#response]
```json title="200 OK (trimmed)"
[
{
"idx": "7b2f4d16-9c83-4e50-a7d1-3f6c8b204e59",
"name": "Cuarta Fuerza Varonil",
"number": 1,
"color": "#2563eb",
"isDoubles": true,
"isVisible": true,
"price": 900,
"minParticipants": 8,
"maxParticipants": 32,
"maxRegistrations": 32,
"createdAt": "2026-06-02T18:24:55.901Z",
"tournament": { "idx": "3c9b7f52-6d41-4a8e-b1f0-2e7d5c9a4b18" },
"rounds": [
{
"idx": "9d3c6a84-7e15-4b02-8f6d-1c4a9e7b53f2",
"name": "Pool Play",
"type": "ROUND_ROBIN_ROUND",
"number": 1,
"isSetup": true,
"isVisible": true,
"numberOfSets": 3,
"proSets": false,
"playAllSets": false,
"hasNextRoundTransitioned": true,
"createdAt": "2026-06-02T18:25:10.338Z",
"division": {
"idx": "7b2f4d16-9c83-4e50-a7d1-3f6c8b204e59",
"name": "Cuarta Fuerza Varonil"
}
},
{
"idx": "2e7f5b10-8c94-4d63-a1b8-7f0e3d6c2a95",
"name": "Playoffs",
"type": "SINGLE_ELIMINATION_ROUND",
"number": 2,
"isSetup": true,
"isVisible": true,
"drawSize": 8,
"numQualifiers": 8,
"placements": [1, 3],
"createdAt": "2026-06-02T18:25:10.512Z",
"division": {
"idx": "7b2f4d16-9c83-4e50-a7d1-3f6c8b204e59",
"name": "Cuarta Fuerza Varonil"
}
}
]
},
{
"idx": "c58a1e93-0b74-42df-96e8-5a3d7f1b28c0",
"name": "Segunda Fuerza Femenil",
"number": 2,
"color": "#db2777",
"isDoubles": true,
"isVisible": false,
"price": 900,
"tournament": { "idx": "3c9b7f52-6d41-4a8e-b1f0-2e7d5c9a4b18" },
"rounds": []
}
]
```
## Tips and pitfalls [#tips-and-pitfalls]
* **It is an array.** There is no `data` key and no `meta`. `res.json()` gives you
the list directly.
* **Ordered by `number` ascending** — the order the organizer arranged the
categories in. Render in that order; do not sort alphabetically.
* **`isVisible: false` categories are included.** The second row above is hidden
on setto.io but present here. Filter it out for a public page.
* **An empty `rounds` array is real information**: the organizer has created the
category but has not built its format yet. Combine with the tournament's
`isFormatComplete`.
* **`teams` can be large.** A 64-pair category with
`populate=teams,teams.playerProfiles` is a big payload, multiplied by every
category in the tournament. If you only need one category's entries, use
[`listTeams`](/docs/guides/list-teams) with its `division` filter instead.
* **`price` is per the category**, in the tournament's currency, and may be
`null` when the organizer did not set one.
* **Rounds here carry `division`**, so you can key them without a second lookup.
In a `TEAM_LEAGUE` a round's `division` may be `null` — those rounds belong to
the tournament, not to one category, and will not appear under any division.
## Related [#related]
* [Get a round](/docs/guides/get-round) — feed it a round `idx` from here.
* [List teams](/docs/guides/list-teams) — per-category entries, filterable.
* Reference: [`listDivisions`](/docs/reference/divisions/list-divisions).
MCP tool: `list_divisions`
# List teams
URL: https://docs.setto.io/docs/guides/list-teams
Use GET /v1/external/tournaments/{idx}/teams to read every registered pair or player, filter by category, and embed players, captain and league team.
`GET /v1/external/tournaments/{idx}/teams` lists every entry registered in a
tournament — a pair in doubles, a single player in singles, a squad in a team
league. Each team carries its seed, its category, and (populated by default) its
players. Use it for an entry list, a seeding sheet, or to resolve the `{ idx,
name }` team references that appear inside round payloads.
Like divisions, the response is a **bare JSON array** and is not paginated.
## Request [#request]
```http
GET /v1/external/tournaments/3c9b7f52-6d41-4a8e-b1f0-2e7d5c9a4b18/teams?division=7b2f4d16-9c83-4e50-a7d1-3f6c8b204e59 HTTP/1.1
Host: api-production-ea80.up.railway.app
Authorization: Bearer setto_live_…
```
### Parameters [#parameters]
| Parameter | In | Notes |
| ---------- | ----- | ------------------------------------------------------------------------------------------------------ |
| `idx` | path | The **tournament's** UUID v4 |
| `division` | query | Restrict to one category. Must be a UUID v4 — a bad value is a `400` |
| `populate` | query | Any of `playerProfiles`, `captainProfile`, `leagueTeam`, `division`. Default `playerProfiles,division` |
```bash
curl -s -G \
https://api-production-ea80.up.railway.app/v1/external/tournaments/3c9b7f52-6d41-4a8e-b1f0-2e7d5c9a4b18/teams \
-H "Authorization: Bearer $SETTO_API_TOKEN" \
-d division=7b2f4d16-9c83-4e50-a7d1-3f6c8b204e59 \
-d populate=playerProfiles,division
```
```js
const BASE = 'https://api-production-ea80.up.railway.app/v1';
const tournamentIdx = '3c9b7f52-6d41-4a8e-b1f0-2e7d5c9a4b18';
const query = new URLSearchParams({
division: '7b2f4d16-9c83-4e50-a7d1-3f6c8b204e59',
populate: 'playerProfiles,division',
});
const res = await fetch(
`${BASE}/external/tournaments/${tournamentIdx}/teams?${query}`,
{ headers: { Authorization: `Bearer ${process.env.SETTO_API_TOKEN}` } },
);
const teams = await res.json();
const byIdx = new Map(teams.map((t) => [t.idx, t]));
```
## Response [#response]
```json title="200 OK (trimmed)"
[
{
"idx": "e0c4b839-2d17-4a56-9fb3-6e8d1c05a274",
"name": "Álvarez / Peña",
"seed": 1,
"seedNumber": 1,
"club": "Club Padel Monterrey",
"picture": null,
"ranking": 412,
"isWildCard": false,
"qualifierStatus": null,
"drawAssignment": null,
"createdAt": "2026-08-14T15:02:31.770Z",
"division": {
"idx": "7b2f4d16-9c83-4e50-a7d1-3f6c8b204e59",
"name": "Cuarta Fuerza Varonil"
},
"players": [
{
"idx": "f3a7d02c-8b19-4e65-97c4-5d1b0a6e3f82",
"firstName": "Mariana",
"lastName": "Álvarez",
"avatar": "https://cdn.setto.io/avatars/f3a7d02c.jpg"
},
{
"idx": "0d6c81b4-72ef-4a30-8b19-c5e73f2a9d64",
"firstName": "Sofía",
"lastName": "Peña",
"avatar": null
}
]
},
{
"idx": "b71f3a05-6c28-49ed-8a94-2f5b0d7e1c63",
"name": "Ramos / Ortega",
"seed": null,
"seedNumber": null,
"isWildCard": true,
"createdAt": "2026-08-19T11:47:03.214Z",
"division": {
"idx": "7b2f4d16-9c83-4e50-a7d1-3f6c8b204e59",
"name": "Cuarta Fuerza Varonil"
},
"players": [
{
"idx": "6a2e9f47-0c53-4b81-9d26-8f1c4a7b0e35",
"firstName": "Daniela",
"lastName": "Ramos",
"avatar": null
},
{
"idx": "c94b7d18-3e60-4a27-85f9-2b0d6e1a7c84",
"firstName": "Regina",
"lastName": "Ortega",
"avatar": null
}
]
}
]
```
## Tips and pitfalls [#tips-and-pitfalls]
* **`playerProfiles` embeds as `players`.** The populate key and the response key
differ. Likewise `captainProfile` embeds as `captainProfileIdx` — an id, not an
object.
* **Players carry no contact data, by design.** `idx`, `firstName`, `lastName`,
`avatar` — that is the whole player object. There is no email, phone or social
handle in the External API.
* **Ordering is `seed` ascending, unseeded last, then `name`.** Do not assume
`teams[0]` is the top seed if the organizer has not seeded the draw yet.
* **Singles categories still return "teams"** with one entry in `players`. Check
`division.isDoubles` — from
[`listDivisions`](/docs/guides/list-divisions) — if you need to label them
differently.
* **`division` can be `null`** on a team that has not been assigned to a category.
* **Use the `division` filter, not client-side filtering,** on large tournaments:
it is applied in the query.
* **Team leagues:** populate `leagueTeam` to get the squad
(`{ idx, name, color, logo, position, pointsAdjustment }`) each entry plays for.
* **`name` is what you render.** It is the organizer's chosen label for the pair
and is what round payloads echo in their `{ idx, name }` team references, so you
can display bracket slots without joining back to this list.
## Related [#related]
* [List divisions](/docs/guides/list-divisions) — get the `division` idx to filter by.
* [Data model](/docs/concepts/data-model#team) — the full field list.
* Reference: [`listTeams`](/docs/reference/teams/list-teams).
MCP tool: `list_teams`
# List tournaments
URL: https://docs.setto.io/docs/guides/list-tournaments
Use GET /v1/external/tournaments to find your organization's tournaments, filter them by status and sport, and page through a season archive.
`GET /v1/external/tournaments` is the entry point of every integration: it is how
you turn "my organization" into a list of tournament `idx` values that the other
endpoints take. It filters by `status` and `sport`, pages with `limit`/`offset`,
and returns rows ordered by `startDate` descending. Typical uses are a "current
tournaments" strip on a club website, a season archive page, and the discovery
step of a nightly export.
## Request [#request]
```http
GET /v1/external/tournaments?status=IN_PROGRESS&sport=PADEL&limit=10 HTTP/1.1
Host: api-production-ea80.up.railway.app
Authorization: Bearer setto_live_…
```
### Query parameters [#query-parameters]
| Parameter | Type | Default | Notes |
| ---------- | ------- | -------- | ----------------------------------------------------------- |
| `status` | enum | — | `DRAFT`, `UPCOMING`, `IN_PROGRESS`, `COMPLETED`, `ARCHIVED` |
| `sport` | enum | — | `PADEL`, `TENNIS`, `PICKLEBALL` |
| `populate` | list | *(none)* | Only `club` is allowed here |
| `limit` | integer | `25` | `1`–`100` |
| `offset` | integer | `0` | `0` or greater |
```bash
curl -s -G https://api-production-ea80.up.railway.app/v1/external/tournaments \
-H "Authorization: Bearer $SETTO_API_TOKEN" \
-d status=IN_PROGRESS \
-d sport=PADEL \
-d limit=10
```
```js
const BASE = 'https://api-production-ea80.up.railway.app/v1';
const query = new URLSearchParams({
status: 'IN_PROGRESS',
sport: 'PADEL',
limit: '10',
});
const res = await fetch(`${BASE}/external/tournaments?${query}`, {
headers: { Authorization: `Bearer ${process.env.SETTO_API_TOKEN}` },
});
const { data, meta } = await res.json();
console.log(`${data.length} shown, ${meta.total} matching`);
```
## Response [#response]
```json title="200 OK (one row, trimmed)"
{
"data": [
{
"idx": "3c9b7f52-6d41-4a8e-b1f0-2e7d5c9a4b18",
"slug": "torneo-apertura-2026",
"name": "Torneo Apertura 2026",
"type": "TOURNAMENT",
"sport": "PADEL",
"status": "IN_PROGRESS",
"startDate": "2026-09-18T00:00:00.000Z",
"endDate": "2026-09-20T00:00:00.000Z",
"timezone": "America/Monterrey",
"matchDays": null,
"isPublic": true,
"isFormatComplete": true,
"banner": "https://cdn.setto.io/banners/torneo-apertura-2026.jpg",
"displayPoints": true,
"inscriptionCost": 900,
"totalPrize": 40000,
"createdAt": "2026-06-02T18:21:07.442Z",
"publicUrl": "https://www.setto.io/t/torneo-apertura-2026",
"organization": {
"idx": "6f1d9c2e-4a7b-4f3d-9d2c-8b5e1a0f7c43",
"name": "Club Padel Monterrey",
"slug": "club-padel-monterrey"
}
}
],
"meta": { "limit": 10, "offset": 0, "total": 1 }
}
```
The `data` rows are full tournament objects — the same shape
[`getTournament`](/docs/guides/get-tournament) returns — minus any relations, since
this endpoint populates nothing by default.
## Tips and pitfalls [#tips-and-pitfalls]
* **Drafts and archives are included.** With no `status` filter you get `DRAFT`
and `ARCHIVED` tournaments too. A public "what's on" strip almost always wants
`status=IN_PROGRESS` plus `status=UPCOMING` — two calls, since `status` takes a
single value.
* **`isPublic: false` is still returned.** It tells you the organizer has turned
the public page off; the API does not filter on it. If you are mirroring the
public site, skip those rows yourself.
* **Personal tournaments are invisible.** Only tournaments attached to your
organization appear. If an expected tournament is missing, it is probably owned
by a user rather than by the organization.
* **Do not populate here.** `club` is the only allowed relation, and on a page of
100 rows it is 100 joins. Fetch the club once with
[`getTournament`](/docs/guides/get-tournament) instead.
* **Page with `limit=100`** for exports, and de-duplicate on `idx` — see
[Pagination](/docs/concepts/pagination).
* **Sort is `startDate` descending**, so "the current tournament" is usually
`data[0]` when you filter by `status=IN_PROGRESS`.
## Related [#related]
* [Get a tournament](/docs/guides/get-tournament) — the detail call.
* [Pagination](/docs/concepts/pagination) — walking a full archive.
* Reference: [`listTournaments`](/docs/reference/tournaments/list-tournaments).
MCP tool: `list_tournaments`
# Data model
URL: https://docs.setto.io/docs/concepts/data-model
The SETTO domain tree — organization, tournament, division, round, pool, draw and game — plus the id conventions and the enum values you will switch on.
Everything the External API returns hangs off one tree: an **organization** owns
**tournaments**; a tournament has **divisions** (categories) and **teams**; a
division has **rounds**; a round holds **pools** (groups, with standings) for
round-robin play and **draws** (brackets) for knockout play; both ultimately
contain **games**, and a finished game has a **score** with **sets**. Learn this
shape once and the six endpoints stop looking like six unrelated things.
## The tree [#the-tree]
```mermaid
graph TD
ORG[Organization] --> T[Tournament]
T --> D[Division · categoría]
T --> TM[Team]
D --> R[Round]
R --> P[Pool · group]
R --> DR[Draw · bracket]
R --> G1[Game]
P --> S[Standing]
P --> TM2[Team ref]
DR --> DRR[DrawRound]
DRR --> G2[Game]
S --> TS[TeamStats]
G1 --> SC[Score]
G2 --> SC
SC --> SET[Set]
```
In prose:
* An **Organization** owns tournaments. Your token is bound to exactly one.
* A **Tournament** is one competition. It carries the format (`type`), the sport,
the lifecycle `status`, dates, and a `publicUrl`.
* A **Division** is what organizers and players call a *categoría*: a bracket of
skill level and gender inside the tournament (`Cuarta Fuerza Varonil`). It owns
the rounds and the price.
* A **Round** is one phase — group play or a knockout stage.
* A **Pool** is a group inside a round-robin round; it holds the teams in that
group and their **standings**.
* A **Draw** is a bracket. It contains **draw rounds** (`Quarterfinals`,
`Semifinals`, …) in `order`, and each draw round contains its **games**.
* A **Game** is one match. Finished games carry a **score**, which carries its
**sets**.
* A **Team** is a registered entry: a pair in doubles, a single player in singles,
and in team leagues a squad linked to a **league team**.
## Identifiers [#identifiers]
Every resource exposes a UUID v4 as **`idx`**. That is the id you pass in a path
(`/v1/external/tournaments/{idx}`) and the id you store on your side. The numeric
primary keys SETTO uses internally are never exposed.
Tournaments carry two extra identifiers:
| Field | Example | Use |
| ----------- | --------------------------------------------- | ------------------------------------ |
| `slug` | `torneo-apertura-2026` | Human-readable, unique, stable |
| `publicUrl` | `https://www.setto.io/t/torneo-apertura-2026` | Canonical public page, ready to link |
Endpoints take `idx`, not `slug`. Keep the `idx` from the list call; use the
`slug` for display and the `publicUrl` for links.
## Tournament [#tournament]
| Field | Notes |
| ---------------------- | -------------------------------------------------------------------------------- |
| `type` | Competition format — see below |
| `sport` | `PADEL`, `TENNIS` or `PICKLEBALL` |
| `status` | Lifecycle — see below |
| `startDate`, `endDate` | ISO 8601 UTC timestamps |
| `timezone` | IANA zone the schedule was authored in (`America/Monterrey`), nullable |
| `isPublic` | Whether the public page is reachable — `false` does **not** hide it from the API |
| `isFormatComplete` | Whether the organizer finished configuring the format |
| `matchDays` | Number of jornadas, for league-shaped tournaments |
| `standingColumns` | Which statistics the organizer shows in the standings table |
| `publicUrl` | `https://www.setto.io/t/{slug}` |
| `organization` | `{ idx, name, slug }` — always present |
### `type` [#type]
The four formats you will meet most often:
| Value | Shape |
| ------------------- | ---------------------------------------------------------- |
| `TOURNAMENT` | A one-off event: group stage and/or a bracket |
| `LEAGUE` | A season of jornadas between pairs |
| `TEAM_LEAGUE` | A season between squads; a round may have `division: null` |
| `INDIVIDUAL_LEAGUE` | A season between individual players |
Specialised formats (Americano, express/lucky-loser and pickleball individual
leagues) carry their own `type` values. Treat `type` as an open string: switch on
the values you care about and fall through for the rest, rather than asserting
the set is closed.
### `status` [#status]
The lifecycle runs in one direction:
```
DRAFT → UPCOMING → IN_PROGRESS → COMPLETED → ARCHIVED
```
All five are returned. `DRAFT` and `ARCHIVED` tournaments are visible to your
token even though the public site hides them — filter with the `status` query
parameter, or drop them client-side, before you render anything public.
## Division (categoría) [#division-categoría]
| Field | Notes |
| -------------------------------------------------------- | ---------------------------------------------------------------- |
| `name` | `Cuarta Fuerza Varonil` |
| `number` | Display order; lists are sorted by it ascending |
| `color` | Hex colour the organizer picked, used across SETTO's UI |
| `isDoubles` | `true` for pairs, `false` for singles |
| `isVisible` | `false` means the organizer hid this category on the public site |
| `price` | Entry price for this category, nullable |
| `minParticipants`, `maxParticipants`, `maxRegistrations` | Capacity, all nullable |
| `tournament` | `{ idx }` back-reference |
A hidden division comes back with `isVisible: false` rather than being
dropped. The API's job is to tell you the truth; deciding what to show is
yours. The same applies to rounds.
## Round [#round]
| Field | Notes |
| -------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `name` | `Pool Play`, `Playoffs` or `Qualifiers` |
| `type` | `ROUND_ROBIN_ROUND`, `SINGLE_ELIMINATION_ROUND` or `QUALIFIERS_ROUND` |
| `number` | Order within the division |
| `isVisible` | Same semantics as a division's |
| `isSetup` | Whether the organizer has generated the round's games |
| `numberOfSets`, `proSets`, `playAllSets`, `pointsPerSet` | Scoring rules |
| `drawSize`, `numQualifiers`, `placements` | Bracket sizing |
| `division` | `{ idx, name }` — **nullable**: a `TEAM_LEAGUE` round can span the whole tournament |
A round-robin round fills `pools`; a single-elimination round fills `draws`. Many
rounds fill both `games` (the flat list) and the nested bracket structure — the
same game can therefore appear twice in one payload, once under `games` and once
under `draws[].rounds[].games[]`. De-duplicate on `game.idx`.
## Pool, standing and team stats [#pool-standing-and-team-stats]
A **pool** is a group: `{ idx, name, number }`, plus `teams` and `standings` when
you populate them. A **standing** carries `rank`, `awardedPoints`, its `team`
reference, and a `teamStats` object with `points`, `wins`, `losses`, `ties`,
`forfeits`, `pointsFor` / `pointsAgainst`, `setsPointsFor` / `setsPointsAgainst`
and the two computed differences.
`rank` is the **stored** rank as SETTO last computed it. The public site
applies head-to-head and other configured tiebreaks on top when it renders a
standings table, so a table you build from `rank` alone can differ from the
one on setto.io. If exact parity matters, order by the tournament's own
tiebreak rules (`populate=rankedTiebreakRules` on the tournament) rather than
by `rank`.
## Draw and draw round [#draw-and-draw-round]
A **draw** is one bracket: `{ idx, placement }`. `placement` distinguishes the
main draw from consolation/third-place brackets. Its **draw rounds** carry a
`label` (`Quarterfinals`, `Semifinals`, `Final`) and an `order`; sort by `order`
to lay the bracket out left to right.
## Game [#game]
| Field | Notes |
| ------------------------------------ | -------------------------------------------------------- |
| `number`, `displayNumber` | Match numbering; `displayNumber` is what organizers show |
| `status` | `UPCOMING`, `FINISHED`, `CANCELLED`, `FORFEIT` or `BYE` |
| `time` | Scheduled kickoff, ISO 8601 UTC, nullable |
| `courtNumber`, `court` | Court number and, when populated, the court object |
| `matchDay` | Jornada number, for leagues |
| `homeTeam`, `awayTeam`, `winner` | `{ idx, name }` references, each nullable |
| `homeTeamDetails`, `awayTeamDetails` | Placeholder slot description — see below |
| `score` | `{ home, away, sets[] }` when populated |
| `isRetirement` | Whether the match ended in a retirement |
### Placeholder slots [#placeholder-slots]
In a bracket, a game often exists before its participants are known. Then
`homeTeam` is `null` and `homeTeamDetails` describes where the team will come
from:
```json
{
"label": "Winner of Game 3",
"teamNumber": null,
"poolNumber": null,
"poolRank": null,
"sourceGameNumber": 3,
"sourceResult": "WINNER"
}
```
`label` is a stored English string. If your site is not in English, render
`sourceGameNumber` and `sourceResult` yourself rather than printing `label`.
### Score and sets [#score-and-sets]
```json
{
"idx": "1b9d7c36-4e58-40af-92c1-6d3f8a05b7e2",
"home": 2,
"away": 1,
"sets": [
{ "idx": "…", "number": 1, "home": 6, "away": 4, "isTieBreak": false },
{ "idx": "…", "number": 2, "home": 3, "away": 6, "isTieBreak": false },
{ "idx": "…", "number": 3, "home": 7, "away": 5, "isTieBreak": false }
]
}
```
`score.home` / `score.away` are **sets won**, not points. Per-set points live in
`sets[]`, ordered by `number`.
## Team [#team]
| Field | Notes |
| ------------------------------------------------- | ------------------------------------------------------------------------------- |
| `name` | `Álvarez / Peña` for a pair, the player's name in singles |
| `seed`, `seedNumber` | Seeding; nullable, and sorted nulls-last in list responses |
| `division` | `{ idx, name }`, nullable |
| `players` | `[{ idx, firstName, lastName, avatar }]` when populated — no contact data, ever |
| `captainProfileIdx` | The captain's player-profile `idx`, when populated |
| `leagueTeam` | `{ idx, name, color, logo, position, pointsAdjustment }` for team leagues |
| `isWildCard`, `qualifierStatus`, `drawAssignment` | Qualifier/wildcard bookkeeping |
| `picture`, `club`, `ranking`, `description` | Optional presentation fields |
In a `TEAM_LEAGUE`, a team belongs to a **league team** — the squad
(`Alemania`, `Brasil`) that accumulates points across the season.
## Dates and time zones [#dates-and-time-zones]
Every timestamp is ISO 8601 in UTC with a `Z` suffix
(`2026-09-18T17:00:00.000Z`). The tournament's `timezone` field tells you which
IANA zone the organizer authored the schedule in; convert into it before showing
a kickoff time, or a Monterrey 19:00 match will read as 01:00 the next day.
# Errors
URL: https://docs.setto.io/docs/concepts/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 [#the-error-body]
```json title="404 Not Found"
{
"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 [#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 |
| `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 scope this route needs | No |
| `404` | `TOURNAMENT_NOT_FOUND` | No such tournament **for this organization** | No |
| `404` | `ROUND_NOT_FOUND` | No such round **for this organization** | 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 [#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.
```json title="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:
```js
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](/docs/concepts/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` [#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:
```json title="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 [#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 [#what-to-do-on-each-status]
```js
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.
## Related [#related]
* [Authentication](/docs/authentication) — the full `401` vs `403` breakdown.
* [Rate limits](/docs/concepts/rate-limits) — the headers that let you avoid
`429` altogether.
# Pagination
URL: https://docs.setto.io/docs/concepts/pagination
How limit and offset work on GET /v1/external/tournaments, what the meta envelope contains, and why the other list endpoints return everything at once.
Exactly one endpoint paginates: `GET /v1/external/tournaments`. It takes `limit`
and `offset`, returns its rows under `data`, and reports the window under `meta`.
The other two list endpoints — divisions and teams — return a bare JSON array with
every row for that tournament, because both are bounded by how many categories
and entries a single tournament can hold.
## The envelope [#the-envelope]
```json title="GET /v1/external/tournaments?limit=2&offset=0"
{
"data": [
{
"idx": "3c9b7f52-6d41-4a8e-b1f0-2e7d5c9a4b18",
"name": "Torneo Apertura 2026"
},
{ "idx": "a41e08d7-33b2-4c96-8f5a-6b0d2e1c7934", "name": "Liga Otoño 2026" }
],
"meta": { "limit": 2, "offset": 0, "total": 37 }
}
```
| Field | Meaning |
| ------------- | -------------------------------------------------------------- |
| `data` | The rows in this window |
| `meta.limit` | The page size actually applied |
| `meta.offset` | The offset actually applied |
| `meta.total` | Total rows matching the filters, ignoring `limit` and `offset` |
## Parameters [#parameters]
| Parameter | Type | Default | Range |
| --------- | ------- | ------- | -------------- |
| `limit` | integer | `25` | `1`–`100` |
| `offset` | integer | `0` | `0` or greater |
Values outside the range are a `400`, not a clamp: `limit=0`, `limit=101` and
`offset=-1` all fail validation. Non-integers fail too.
## Ordering [#ordering]
Tournaments come back ordered by **`startDate` descending** — the most recent
season first. The order is stable across pages for a fixed data set.
## Walking every page [#walking-every-page]
Stop when you have collected `meta.total` rows, or when a page comes back short.
```js
async function listAllTournaments(token, params = {}) {
const BASE = 'https://api-production-ea80.up.railway.app/v1';
const limit = 100;
const all = [];
let offset = 0;
let total = Infinity;
while (offset < total) {
const query = new URLSearchParams({
...params,
limit: String(limit),
offset: String(offset),
});
const res = await fetch(`${BASE}/external/tournaments?${query}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`SETTO API ${res.status}`);
const { data, meta } = await res.json();
all.push(...data);
total = meta.total;
offset += limit;
if (data.length === 0) break; // defensive: never loop forever
}
return all;
}
```
```python
def list_all_tournaments(token, **params):
BASE = "https://api-production-ea80.up.railway.app/v1"
headers = {"Authorization": f"Bearer {token}"}
limit, offset, total, out = 100, 0, None, []
while total is None or offset < total:
res = requests.get(
f"{BASE}/external/tournaments",
headers=headers,
params={**params, "limit": limit, "offset": offset},
timeout=10,
)
res.raise_for_status()
body = res.json()
if not body["data"]:
break
out.extend(body["data"])
total = body["meta"]["total"]
offset += limit
return out
```
`status` and `sport` are applied before the window, so `meta.total` reflects
the filtered set. Narrowing with `status=IN_PROGRESS` is almost always cheaper
than paging through an archive.
## Offset drift [#offset-drift]
Offset pagination reads a moving list. If a tournament is created, or its
`startDate` is edited, while you are between pages, a row can shift across the
page boundary and be returned twice or skipped.
For a long export:
* page with `limit=100` so there are fewer boundaries to cross;
* de-duplicate on `idx` as you accumulate;
* or pin the window with a filter that will not change under you, such as
`status=COMPLETED`.
Do not treat `offset` as a cursor you can persist between runs — re-run the walk
from `offset=0` each time.
## The other lists [#the-other-lists]
`GET /v1/external/tournaments/{idx}/divisions` and
`GET /v1/external/tournaments/{idx}/teams` take no `limit` or `offset` and return
a JSON array directly — no `data`/`meta` envelope:
```json title="GET /v1/external/tournaments/{idx}/divisions"
[
{
"idx": "7b2f4d16-9c83-4e50-a7d1-3f6c8b204e59",
"name": "Cuarta Fuerza Varonil",
"number": 1
},
{
"idx": "c58a1e93-0b74-42df-96e8-5a3d7f1b28c0",
"name": "Segunda Fuerza Femenil",
"number": 2
}
]
```
Divisions are ordered by `number` ascending. Teams are ordered by `seed`
ascending with unseeded entries last, then by `name`.
If a very large tournament makes those payloads uncomfortable, narrow the teams
call with the `division` filter — `?division={divisionIdx}` — rather than asking
for a page size that does not exist.
# Populate
URL: https://docs.setto.io/docs/concepts/populate
How the populate query parameter embeds relations, the exact allow-list per endpoint, the defaults each endpoint applies, and the rules for nested paths.
Relations are opt-in. By default each endpoint returns its own columns plus a
small set of relations; anything else you ask for with `populate`, a
comma-separated list of allow-listed paths. A relation key is **absent from the
JSON entirely** unless it was populated — so `'divisions' in tournament` is a
reliable test for "did I ask for this?", and an unknown path is a `400` rather
than a silently ignored parameter.
## Syntax [#syntax]
```http
GET /v1/external/tournaments/{idx}?populate=club,divisions,divisions.rounds
```
* Comma-separated, no spaces.
* Order does not matter.
* Repeating a value is harmless.
* A value outside the endpoint's allow-list is a `400` — see
[Errors](/docs/concepts/errors).
The moment you send a `populate` parameter, the endpoint's default list is
gone. `GET /v1/external/tournaments/{idx}` returns `club` and `divisions` when
you send nothing; `?populate=divisions` returns divisions and **no** club. If
you want both, say both.
## Nested paths need their parents [#nested-paths-need-their-parents]
Paths are dotted: `draws.rounds.games.score.sets`. You do not have to list the
ancestors — the API expands them for you, so
`populate=draws.rounds.games.score.sets` implies `draws`, `draws.rounds`,
`draws.rounds.games` and `draws.rounds.games.score`. Listing them explicitly
changes nothing.
The reverse is not true: populating a parent does **not** bring its children.
`populate=games` gives you games whose `homeTeam` and `score` keys are missing;
you need `games.homeTeam` and `games.score` for those.
## Per-endpoint allow-lists [#per-endpoint-allow-lists]
### `GET /v1/external/tournaments` [#get-v1externaltournaments]
List rows stay deliberately shallow — this is the one endpoint that paginates, so
a fat row multiplies by 100.
| Allowed | Default |
| ------- | -------- |
| `club` | *(none)* |
### `GET /v1/external/tournaments/{idx}` [#get-v1externaltournamentsidx]
| Allowed | Default |
| ----------------------------------------------------------------------------------------------------------------------- | ---------------- |
| `club`, `club.courts`, `divisions`, `divisions.rounds`, `sponsors`, `circuit`, `circuitCategory`, `rankedTiebreakRules` | `club,divisions` |
### `GET /v1/external/tournaments/{idx}/divisions` [#get-v1externaltournamentsidxdivisions]
| Allowed | Default |
| -------------------------------------------------------------------------------- | -------- |
| `rounds`, `teams`, `teams.playerProfiles`, `teams.leagueTeam`, `circuitCategory` | `rounds` |
### `GET /v1/external/tournaments/{idx}/teams` [#get-v1externaltournamentsidxteams]
| Allowed | Default |
| ------------------------------------------------------------ | ------------------------- |
| `playerProfiles`, `captainProfile`, `leagueTeam`, `division` | `playerProfiles,division` |
### `GET /v1/external/rounds/{idx}` [#get-v1externalroundsidx]
The richest endpoint, and the one whose default is worth knowing by heart.
Allowed:
```
division
pools
pools.teams
pools.standings
pools.standings.team
pools.standings.teamStats
games
games.homeTeam
games.awayTeam
games.winner
games.court
games.score
games.score.sets
draws
draws.rounds
draws.rounds.games
draws.rounds.games.homeTeam
draws.rounds.games.awayTeam
draws.rounds.games.winner
draws.rounds.games.court
draws.rounds.games.score
draws.rounds.games.score.sets
```
Default when you send no `populate`:
```
division
pools
pools.teams
games
games.homeTeam
games.awayTeam
games.winner
draws.rounds
draws.rounds.games
draws.rounds.games.homeTeam
draws.rounds.games.awayTeam
draws.rounds.games.winner
draws.rounds.games.score
draws.rounds.games.score.sets
```
Note the asymmetry: the default populates `draws.rounds.games.score` (+
`sets`) but **not** `games.score`. If you are reading results off the flat
`games` array, ask for `games.score,games.score.sets` explicitly — and
remember that doing so replaces the whole default list, so re-list everything
else you need.
## Populate keys are not always response keys [#populate-keys-are-not-always-response-keys]
Three paths embed under a different name than the one you request:
| You populate | It appears as |
| -------------------------------------------- | ------------------------------------------------- |
| `playerProfiles` (or `teams.playerProfiles`) | `players: [{ idx, firstName, lastName, avatar }]` |
| `captainProfile` | `captainProfileIdx: string \| null` |
| `pools.standings.teamStats` | `standings[].teamStats` |
## Worked examples [#worked-examples]
A public draw sheet for one knockout round — brackets, winners and scores, no
group tables:
```bash
curl -s -G https://api-production-ea80.up.railway.app/v1/external/rounds/2e7f5b10-8c94-4d63-a1b8-7f0e3d6c2a95 \
-H "Authorization: Bearer $SETTO_API_TOKEN" \
-d populate=draws.rounds.games.homeTeam,draws.rounds.games.awayTeam,draws.rounds.games.winner,draws.rounds.games.score.sets
```
A group-stage standings table:
```bash
curl -s -G https://api-production-ea80.up.railway.app/v1/external/rounds/9d3c6a84-7e15-4b02-8f6d-1c4a9e7b53f2 \
-H "Authorization: Bearer $SETTO_API_TOKEN" \
-d populate=pools.standings.team,pools.standings.teamStats
```
A full category listing with every registered pair and its players:
```bash
curl -s -G https://api-production-ea80.up.railway.app/v1/external/tournaments/3c9b7f52-6d41-4a8e-b1f0-2e7d5c9a4b18/divisions \
-H "Authorization: Bearer $SETTO_API_TOKEN" \
-d populate=teams,teams.playerProfiles
```
## Cost [#cost]
Each populated path is more joins and more rows. Three habits keep responses
fast:
1. **Ask for the leaves you render, nothing else.** `pools.standings.teamStats`
without `pools.standings.team` is a legal, cheaper request if you already know
the teams.
2. **Do not populate on the list endpoint** unless you need the club. Fetch the
ids from the list, then fetch the one tournament you are rendering.
3. **Cache.** Round payloads change only when a score is entered. Combine a short
TTL with [conditional requests](/docs/concepts/rate-limits) and one round page
costs you a handful of requests an hour instead of one per visitor.
# Rate limits
URL: https://docs.setto.io/docs/concepts/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 [#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:
```json
{ "rateLimit": { "limit": 120, "windowSeconds": 60 } }
```
### A second ceiling, per IP [#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 [#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
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 [#handling-429]
```json title="429 Too Many Requests"
{
"statusCode": 429,
"code": "RATE_LIMITED",
"message": "RATE_LIMITED"
}
```
Respect `Retry-After`. Do not poll the endpoint until it relents.
```js
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 [#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.
```bash
# 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
```
```js
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 [#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](/docs/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.
# API reference
URL: https://docs.setto.io/docs/reference
How the generated SETTO External API reference works — one page per operation, a Try it panel you can paste a token into, and the raw OpenAPI document.
{/* The per-operation pages under this folder are generated from
openapi/external-api.json by `bun run generate-docs`. Do not hand-edit them —
edit the spec and regenerate. This index page and the folder's meta.json are
hand-written and are kept across regenerations. */}
The pages in this section are generated from the SETTO External API's OpenAPI
document, so they cannot drift from the API: one page per operation, with its
path, method, every parameter, the security scheme and the response schema.
Each page also carries a **Try it** panel that fires a real request from your
browser, so you can see a live payload without leaving the docs. If you are
looking for worked examples and advice rather than an exhaustive parameter list,
read the [guides](/docs/guides/list-tournaments) instead.
## The operations [#the-operations]
| Operation | Method and path | Guide |
| ----------------------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------- |
| [`getMe`](/docs/reference/organization/get-me) | `GET /v1/external/me` | [Quickstart](/docs/quickstart) |
| [`listTournaments`](/docs/reference/tournaments/list-tournaments) | `GET /v1/external/tournaments` | [List tournaments](/docs/guides/list-tournaments) |
| [`getTournament`](/docs/reference/tournaments/get-tournament) | `GET /v1/external/tournaments/{idx}` | [Get a tournament](/docs/guides/get-tournament) |
| [`listDivisions`](/docs/reference/divisions/list-divisions) | `GET /v1/external/tournaments/{idx}/divisions` | [List divisions](/docs/guides/list-divisions) |
| [`listTeams`](/docs/reference/teams/list-teams) | `GET /v1/external/tournaments/{idx}/teams` | [List teams](/docs/guides/list-teams) |
| [`getRound`](/docs/reference/rounds/get-round) | `GET /v1/external/rounds/{idx}` | [Get a round](/docs/guides/get-round) |
Pages are grouped by OpenAPI tag — Organization, Tournaments, Divisions, Teams,
Rounds — which is also how the sidebar is ordered.
## Using "Try it" [#using-try-it]
Every operation page has a panel where you can fill in the path and query
parameters and send the request.
1. Open the **Authorization** field and paste an organization API token
(`setto_live_…`). The panel uses the `apiToken` bearer scheme declared in the
spec.
2. Fill in `idx` — a real tournament or round UUID from your own organization.
3. Send. The response comes back with its status, headers and body.
The playground calls the SETTO API directly from your browser; there is no
proxy in front of it, and nothing is stored on this site's servers. Even so, a
token pasted into a web form is a token that has been in a web form — use a
short-lived one, or revoke it afterwards from the organizer dashboard. See
[Authentication](/docs/authentication).
If a request fails with a network or CORS error rather than an HTTP status, the
token never left your browser — check the token and the URL and try again.
## The raw OpenAPI document [#the-raw-openapi-document]
The same document that generates these pages is published at
[`/openapi.json`](/openapi.json). Point a client generator, a Postman import or
an AI agent at it:
```bash
curl -s https://docs.setto.io/openapi.json -o setto-external-api.json
```
Its `servers[0].url` is filled in at request time from the API base URL this site
is configured against, so the URLs in a generated client are correct without
editing.
## Related [#related]
* [Authentication](/docs/authentication) — before you paste a token anywhere.
* [Errors](/docs/concepts/errors) — every status and code the reference lists.
* [Rate limits](/docs/concepts/rate-limits) — the headers on every response.
# A tournament's divisions (categories)
URL: https://docs.setto.io/docs/reference/divisions/list-divisions
`GET /v1/external/tournaments/{idx}/divisions`
A tournament's divisions (categories)
Ordered by division number. An unknown or out-of-organization tournament is a 404. `populate` is a comma-separated list of relation paths. Allowed: rounds, teams, teams.playerProfiles, teams.leagueTeam, circuitCategory. Default when omitted: rounds. A path outside that list is a 400. Relations that were not requested are absent from the response body (not null). All identifiers (`idx`) are v4 UUIDs.
### Authorization
- `apiToken` — Bearer token. Organization API token
Send it as `Authorization: Bearer `.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `idx` | path | string (uuid) | yes | Tournament identifier |
| `populate` | query | string | no | Comma-separated. Allowed: rounds, teams, teams.playerProfiles, teams.leagueTeam, circuitCategory |
### Responses
| Status | Description |
| --- | --- |
| `200` | |
| `400` | Malformed request, in one of two shapes. A path identifier that is not a v4 UUID is rejected by `ExternalUuidPipe` and answers in this API’s own error body — `{ statusCode, code: "INVALID_ID", message }`. A bad query parameter (an unknown `populate` path, `limit`/`offset` out of range, a non-UUID `division`) is rejected by the api-wide ValidationPipe, which answers in Nest’s shape — `{ statusCode, message: string[], error: "Bad Request" }` — with one entry per failed constraint. |
| `401` | Missing, malformed, unknown or expired token — `MISSING_API_TOKEN`, `INVALID_API_TOKEN`, `EXPIRED_API_TOKEN`. |
| `403` | The token lacks the `read` scope — `INSUFFICIENT_SCOPE`. |
| `404` | No such tournament in the calling organization — `TOURNAMENT_NOT_FOUND`. A tournament owned by another organization is also a 404, never a 403: this endpoint is not an existence oracle. |
| `429` | More than 120 requests in 60s — `RATE_LIMITED`. The response carries `Retry-After` alongside the `X-RateLimit-*` headers. |
### Example
```bash
curl -X GET 'https://api-production-ea80.up.railway.app/v1/external/tournaments/{idx}/divisions' \
-H 'Authorization: Bearer '
```
# The organization and token behind the current Bearer credential
URL: https://docs.setto.io/docs/reference/organization/get-me
`GET /v1/external/me`
The organization and token behind the current Bearer credential
Echoes the calling organization, the token being used (never its secret — only the displayable `prefix`) and the rate limit the token is subject to. Use it to verify a credential is live and correctly scoped. All identifiers (`idx`) are v4 UUIDs.
### Authorization
- `apiToken` — Bearer token. Organization API token
Send it as `Authorization: Bearer `.
### Parameters
None.
### Responses
| Status | Description |
| --- | --- |
| `200` | `ExternalMeResponseDto` |
| `400` | Malformed request, in one of two shapes. A path identifier that is not a v4 UUID is rejected by `ExternalUuidPipe` and answers in this API’s own error body — `{ statusCode, code: "INVALID_ID", message }`. A bad query parameter (an unknown `populate` path, `limit`/`offset` out of range, a non-UUID `division`) is rejected by the api-wide ValidationPipe, which answers in Nest’s shape — `{ statusCode, message: string[], error: "Bad Request" }` — with one entry per failed constraint. |
| `401` | Missing, malformed, unknown or expired token — `MISSING_API_TOKEN`, `INVALID_API_TOKEN`, `EXPIRED_API_TOKEN`. |
| `403` | The token lacks the `read` scope — `INSUFFICIENT_SCOPE`. |
| `429` | More than 120 requests in 60s — `RATE_LIMITED`. The response carries `Retry-After` alongside the `X-RateLimit-*` headers. |
### Example
```bash
curl -X GET 'https://api-production-ea80.up.railway.app/v1/external/me' \
-H 'Authorization: Bearer '
```
# One round by identifier, with its pools, games and draws
URL: https://docs.setto.io/docs/reference/rounds/get-round
`GET /v1/external/rounds/{idx}`
One round by identifier, with its pools, games and draws
A round whose tournament belongs to another organization is a 404. `populate` is a comma-separated list of relation paths. Allowed: division, pools, pools.teams, pools.standings, pools.standings.team, pools.standings.teamStats, games, games.homeTeam, games.awayTeam, games.winner, games.court, games.score, games.score.sets, draws, draws.rounds, draws.rounds.games, draws.rounds.games.homeTeam, draws.rounds.games.awayTeam, draws.rounds.games.winner, draws.rounds.games.court, draws.rounds.games.score, draws.rounds.games.score.sets. Default when omitted: pools, pools.teams, games, games.homeTeam, games.awayTeam, games.winner, division, draws.rounds, draws.rounds.games, draws.rounds.games.homeTeam, draws.rounds.games.awayTeam, draws.rounds.games.winner, draws.rounds.games.score, draws.rounds.games.score.sets. A path outside that list is a 400. Relations that were not requested are absent from the response body (not null). All identifiers (`idx`) are v4 UUIDs.
### Authorization
- `apiToken` — Bearer token. Organization API token
Send it as `Authorization: Bearer `.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `idx` | path | string (uuid) | yes | Round identifier |
| `populate` | query | string | no | Comma-separated. Allowed: division, pools, pools.teams, pools.standings, pools.standings.team, pools.standings.teamStats, games, games.homeTeam, games.awayTeam, games.winner, games.court, games.score, games.score.sets, draws, draws.rounds, draws.rounds.games, draws.rounds.games.homeTeam, draws.rounds.games.awayTeam, draws.rounds.games.winner, draws.rounds.games.court, draws.rounds.games.score, draws.rounds.games.score.sets |
### Responses
| Status | Description |
| --- | --- |
| `200` | `ExternalRoundDto` |
| `400` | Malformed request, in one of two shapes. A path identifier that is not a v4 UUID is rejected by `ExternalUuidPipe` and answers in this API’s own error body — `{ statusCode, code: "INVALID_ID", message }`. A bad query parameter (an unknown `populate` path, `limit`/`offset` out of range, a non-UUID `division`) is rejected by the api-wide ValidationPipe, which answers in Nest’s shape — `{ statusCode, message: string[], error: "Bad Request" }` — with one entry per failed constraint. |
| `401` | Missing, malformed, unknown or expired token — `MISSING_API_TOKEN`, `INVALID_API_TOKEN`, `EXPIRED_API_TOKEN`. |
| `403` | The token lacks the `read` scope — `INSUFFICIENT_SCOPE`. |
| `404` | No such round in the calling organization — `ROUND_NOT_FOUND`. A round owned by another organization is also a 404, never a 403: this endpoint is not an existence oracle. |
| `429` | More than 120 requests in 60s — `RATE_LIMITED`. The response carries `Retry-After` alongside the `X-RateLimit-*` headers. |
### Example
```bash
curl -X GET 'https://api-production-ea80.up.railway.app/v1/external/rounds/{idx}' \
-H 'Authorization: Bearer '
```
# A tournament's teams (pairs, players or league teams)
URL: https://docs.setto.io/docs/reference/teams/list-teams
`GET /v1/external/tournaments/{idx}/teams`
A tournament's teams (pairs, players or league teams)
Ordered by seed, then name. Narrow to one category with `division=`. An unknown or out-of-organization tournament is a 404. `populate` is a comma-separated list of relation paths. Allowed: playerProfiles, captainProfile, leagueTeam, division. Default when omitted: playerProfiles, division. A path outside that list is a 400. Relations that were not requested are absent from the response body (not null). All identifiers (`idx`) are v4 UUIDs.
### Authorization
- `apiToken` — Bearer token. Organization API token
Send it as `Authorization: Bearer `.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `idx` | path | string (uuid) | yes | Tournament identifier |
| `populate` | query | string | no | Comma-separated. Allowed: playerProfiles, captainProfile, leagueTeam, division |
| `division` | query | string (uuid) | no | Only teams in this division (division idx) |
### Responses
| Status | Description |
| --- | --- |
| `200` | |
| `400` | Malformed request, in one of two shapes. A path identifier that is not a v4 UUID is rejected by `ExternalUuidPipe` and answers in this API’s own error body — `{ statusCode, code: "INVALID_ID", message }`. A bad query parameter (an unknown `populate` path, `limit`/`offset` out of range, a non-UUID `division`) is rejected by the api-wide ValidationPipe, which answers in Nest’s shape — `{ statusCode, message: string[], error: "Bad Request" }` — with one entry per failed constraint. |
| `401` | Missing, malformed, unknown or expired token — `MISSING_API_TOKEN`, `INVALID_API_TOKEN`, `EXPIRED_API_TOKEN`. |
| `403` | The token lacks the `read` scope — `INSUFFICIENT_SCOPE`. |
| `404` | No such tournament in the calling organization — `TOURNAMENT_NOT_FOUND`. A tournament owned by another organization is also a 404, never a 403: this endpoint is not an existence oracle. |
| `429` | More than 120 requests in 60s — `RATE_LIMITED`. The response carries `Retry-After` alongside the `X-RateLimit-*` headers. |
### Example
```bash
curl -X GET 'https://api-production-ea80.up.railway.app/v1/external/tournaments/{idx}/teams' \
-H 'Authorization: Bearer '
```
# One tournament by identifier
URL: https://docs.setto.io/docs/reference/tournaments/get-tournament
`GET /v1/external/tournaments/{idx}`
One tournament by identifier
A tournament belonging to another organization is a 404, not a 403 — the endpoint is not an existence oracle. `populate` is a comma-separated list of relation paths. Allowed: club, club.courts, divisions, divisions.rounds, sponsors, circuit, circuitCategory, rankedTiebreakRules. Default when omitted: club, divisions. A path outside that list is a 400. Relations that were not requested are absent from the response body (not null). All identifiers (`idx`) are v4 UUIDs.
### Authorization
- `apiToken` — Bearer token. Organization API token
Send it as `Authorization: Bearer `.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `idx` | path | string (uuid) | yes | Tournament identifier |
| `populate` | query | string | no | Comma-separated. Allowed: club, club.courts, divisions, divisions.rounds, sponsors, circuit, circuitCategory, rankedTiebreakRules |
### Responses
| Status | Description |
| --- | --- |
| `200` | `ExternalTournamentDto` |
| `400` | Malformed request, in one of two shapes. A path identifier that is not a v4 UUID is rejected by `ExternalUuidPipe` and answers in this API’s own error body — `{ statusCode, code: "INVALID_ID", message }`. A bad query parameter (an unknown `populate` path, `limit`/`offset` out of range, a non-UUID `division`) is rejected by the api-wide ValidationPipe, which answers in Nest’s shape — `{ statusCode, message: string[], error: "Bad Request" }` — with one entry per failed constraint. |
| `401` | Missing, malformed, unknown or expired token — `MISSING_API_TOKEN`, `INVALID_API_TOKEN`, `EXPIRED_API_TOKEN`. |
| `403` | The token lacks the `read` scope — `INSUFFICIENT_SCOPE`. |
| `404` | No such tournament in the calling organization — `TOURNAMENT_NOT_FOUND`. A tournament owned by another organization is also a 404, never a 403: this endpoint is not an existence oracle. |
| `429` | More than 120 requests in 60s — `RATE_LIMITED`. The response carries `Retry-After` alongside the `X-RateLimit-*` headers. |
### Example
```bash
curl -X GET 'https://api-production-ea80.up.railway.app/v1/external/tournaments/{idx}' \
-H 'Authorization: Bearer '
```
# List the calling organization's tournaments
URL: https://docs.setto.io/docs/reference/tournaments/list-tournaments
`GET /v1/external/tournaments`
List the calling organization's tournaments
Newest first (`startDate` descending). Optionally filtered by `status` and `sport`, paged with `limit` (1-100, default 25) and `offset`. `populate` is a comma-separated list of relation paths. Allowed: club. Default when omitted: nothing is populated. A path outside that list is a 400. Relations that were not requested are absent from the response body (not null). All identifiers (`idx`) are v4 UUIDs.
### Authorization
- `apiToken` — Bearer token. Organization API token
Send it as `Authorization: Bearer `.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `status` | query | string | no | Only tournaments in this status. One of: `DRAFT`, `UPCOMING`, `IN_PROGRESS`, `COMPLETED`, `ARCHIVED`. |
| `sport` | query | string | no | Only tournaments for this sport. One of: `PADEL`, `TENNIS`, `PICKLEBALL`. |
| `populate` | query | string | no | Comma-separated. Allowed: club |
| `limit` | query | number | no | Page size, 1-100 |
| `offset` | query | number | no | Rows to skip |
### Responses
| Status | Description |
| --- | --- |
| `200` | `ExternalTournamentListResponseDto` |
| `400` | Malformed request, in one of two shapes. A path identifier that is not a v4 UUID is rejected by `ExternalUuidPipe` and answers in this API’s own error body — `{ statusCode, code: "INVALID_ID", message }`. A bad query parameter (an unknown `populate` path, `limit`/`offset` out of range, a non-UUID `division`) is rejected by the api-wide ValidationPipe, which answers in Nest’s shape — `{ statusCode, message: string[], error: "Bad Request" }` — with one entry per failed constraint. |
| `401` | Missing, malformed, unknown or expired token — `MISSING_API_TOKEN`, `INVALID_API_TOKEN`, `EXPIRED_API_TOKEN`. |
| `403` | The token lacks the `read` scope — `INSUFFICIENT_SCOPE`. |
| `429` | More than 120 requests in 60s — `RATE_LIMITED`. The response carries `Retry-After` alongside the `X-RateLimit-*` headers. |
### Example
```bash
curl -X GET 'https://api-production-ea80.up.railway.app/v1/external/tournaments' \
-H 'Authorization: Bearer '
```