# 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 ' ```