Whistle.day

Whistle.day API

The HTTP API the Whistle.day screens are built on.

Everything the web app does goes through this API, so anything it can do, a script can do too. It is PocketBase underneath: tournaments, teams, matches and tables are collections read and written through the standard records API, and the operations a collection rule cannot express (drawing a stage, taking a team out, handing a tournament over) are the custom routes listed here.

Requests and responses are JSON. A published tournament is readable by anybody; a draft, and every write, needs a token from somebody who owns the tournament or is on its crew.

This page is served by the API itself at /api/docs, and the same reference is available as an OpenAPI 3.1 document at /api/openapi.json. An agent is better served by the MCP server at /api/mcp, which wraps the same engine in 21 tools.

Getting started

The base address is https://whistle.day. Two requests that need nothing at all, to see the shape of things:

curl -X POST https://whistle.day/api/preview \
  -H 'Content-Type: application/json' \
  -d '{"format": "round_robin", "teams": 4}'

curl 'https://whistle.day/api/collections/tournaments/records?perPage=5'

Everything that writes needs a token. Sign in for one, then send it on every request:

curl -X POST https://whistle.day/api/collections/users/auth-with-password \
  -H 'Content-Type: application/json' \
  -d '{"identity": "you@example.com", "password": "…"}'

curl -X POST https://whistle.day/api/stages/STAGE_ID/generate?dry_run=1 \
  -H 'Authorization: Bearer TOKEN'

A refusal is always the same object, with a message written to be shown to a person:

{
  "status": 404,
  "message": "Stage not found.",
  "data": {}
}

There is no general rate limit, because a sports hall is two hundred people behind one IP address. The endpoints that do have one say so.

Screens and previews

Public, read-only endpoints. No account and no token header: a screen is opened by the token in its address, and a preview reads no stored data at all.

GET /api/display/{token} no token

Everything one projector screen shows

One assembled payload: the tournament, its stages with their pools, tables and rounds, what is playing now, what is next, recent results, courts and sponsors. Teams are sent once in entries and referenced by id everywhere else. A side of a match that nobody has qualified for yet has an empty entry and a label to draw instead, such as Winner SF1.

The response carries an ETag. Send it back in If-None-Match and an unchanged tournament answers 304. A screen whose tournament is still a draft answers 404, the same as a token that does not exist. This endpoint has no rate limit.

Parameters

token path, required The screen's token, from the address of its /display/{token} page.
If-None-Match header The ETag of the last response you received.

Responses

200 The screen's payload.
Example
{
  "generated_at": "2026-10-03T09:41:12Z",
  "tournament": {
    "id": "t8w2k1m0q9x4c7z",
    "name": "Autumn Cup",
    "sport": "volleyball",
    "location": "Sports hall B",
    "logo": "",
    "status": "live"
  },
  "display": {
    "id": "d3n5p7r9t1v2x4z",
    "name": "Main wall",
    "mode": "auto",
    "rotate_seconds": 15,
    "scope": {
      "stage": "",
      "pool": "",
      "court": ""
    },
    "theme": {
      "mode": "dark"
    },
    "panels": []
  },
  "entries": {
    "e1a2b3c4d5f6g7h": {
      "id": "e1a2b3c4d5f6g7h",
      "name": "Kestrels",
      "short_name": "KES",
      "club": "",
      "logo": "",
      "seed": 1
    },
    "e2b3c4d5f6g7h8j": {
      "id": "e2b3c4d5f6g7h8j",
      "name": "Otters",
      "short_name": "OTT",
      "club": "",
      "logo": "",
      "seed": 2
    }
  },
  "courts": [
    {
      "id": "c9y8x7w6v5u4t3s",
      "name": "Court 1",
      "position": 0
    }
  ],
  "stages": [
    {
      "id": "s4h6j8k0m2n4p6q",
      "position": 0,
      "name": "Groups",
      "name_kind": "groups",
      "format": "round_robin",
      "status": "live",
      "pools": [
        {
          "id": "p1q2r3s4t5u6v7w",
          "name": "A",
          "kind": "group",
          "advance_count": 2,
          "standings": [
            {
              "position": 1,
              "entry": "e1a2b3c4d5f6g7h",
              "played": 1,
              "won": 1,
              "drawn": 0,
              "lost": 0,
              "score_for": 2,
              "score_against": 0,
              "points": 2,
              "qualified": false,
              "tiebreak": [
                "points"
              ]
            }
          ],
          "rounds": [
            {
              "round": 0,
              "fixtures": [
                {
                  "id": "f7g8h9j0k1m2n3p",
                  "code": "A1",
                  "stage": "s4h6j8k0m2n4p6q",
                  "pool": "p1q2r3s4t5u6v7w",
                  "round": 0,
                  "position": 0,
                  "status": "finished",
                  "home": {
                    "entry": "e1a2b3c4d5f6g7h",
                    "label": "Kestrels",
                    "score": 2
                  },
                  "away": {
                    "entry": "e2b3c4d5f6g7h8j",
                    "label": "Otters",
                    "score": 0
                  },
                  "winner": "e1a2b3c4d5f6g7h",
                  "court": "c9y8x7w6v5u4t3s",
                  "scheduled_at": "2026-10-03 09:00:00.000Z",
                  "games": [
                    {
                      "number": 1,
                      "home": 25,
                      "away": 19
                    },
                    {
                      "number": 2,
                      "home": 25,
                      "away": 22
                    }
                  ]
                }
              ]
            }
          ]
        }
      ]
    }
  ],
  "now_playing": [],
  "next_up": [],
  "recent": [],
  "sponsors": []
}
304 Nothing has changed since the ETag you sent.
404 No active screen has this token, or its tournament is still a draft.
GET /api/clubs/{slug} no token

A club and the tournaments it has run

The club's name, colour and logo, and every tournament its owner has listed on it, newest first. A draft is left out. date is when the tournament is played if that was ever set, and when it was created otherwise. screen is the token of one of the tournament's active screens, for /m/{screen} or GET /api/display/{token}, and is empty when it has none.

There is no endpoint that lists clubs: this one answers for a slug you already have.

Parameters

slug path, required The club's slug, from the address of its /c/{slug} page.

Responses

200 The club's page.
Example
{
  "club": {
    "id": "c4h7k2m9p1r3t5v",
    "name": "TJ Sokol Brno",
    "slug": "tj-sokol-brno",
    "accent": "#0b7a43",
    "logo": "/api/files/pbc_1219621782/c4h7k2m9p1r3t5v/crest_a1b2c3d4e5.png"
  },
  "tournaments": [
    {
      "id": "t8w2k1m0q9x4c7z",
      "name": "Autumn Cup",
      "sport": "volleyball",
      "location": "Sports hall B",
      "status": "live",
      "date": "2026-10-03T08:00:00Z",
      "screen": "hala-1"
    }
  ]
}
404 No club has this slug.
POST /api/preview no token

Draw a stage that does not exist

A format, a field and a config in, a plan out. It runs the same generators a real stage does and touches no stored data, so it needs no account. Send either teams (a count, numbered Team 1, Team 2, …) or names; names wins when both are present.

format is one of round_robin, single_elim, double_elim or swiss. config takes the same keys a stage's config does: poolCount, advancePerPool, legs, thirdPlace, consolation and rounds. seedFrom is refused, because there is no earlier stage to read. The field is capped at 128 teams and the endpoint at 60 requests a minute per IP address. Responses may be cached for an hour.

Request body

{
  "format": "single_elim",
  "names": [
    "Kestrels",
    "Otters"
  ],
  "config": {
    "thirdPlace": false
  }
}

Responses

200 The plan: pools, the slots in them, and the fixtures between those slots. Keys are positional and exist only to link the three lists together.
Example
{
  "format": "single_elim",
  "plan": {
    "pools": [
      {
        "key": "pool:main",
        "name": "Main",
        "name_kind": "main",
        "position": 0,
        "kind": "winners",
        "advance_count": 1
      }
    ],
    "slots": [
      {
        "key": "slot:0:0",
        "pool_key": "pool:main",
        "round": 0,
        "position": 0,
        "label": "Kestrels",
        "entry": "preview1",
        "source_type": "direct",
        "source_fixture_key": "",
        "source_pool": "",
        "source_stage": "",
        "source_position": 1
      },
      {
        "key": "slot:0:1",
        "pool_key": "pool:main",
        "round": 0,
        "position": 1,
        "label": "Otters",
        "entry": "preview2",
        "source_type": "direct",
        "source_fixture_key": "",
        "source_pool": "",
        "source_stage": "",
        "source_position": 2
      },
      {
        "key": "slot:1:0",
        "pool_key": "pool:main",
        "round": 1,
        "position": 0,
        "label": "Winner F",
        "label_kind": "winner_of",
        "label_args": {
          "code": "F"
        },
        "entry": "",
        "source_type": "fixture_winner",
        "source_fixture_key": "fixture:0:0",
        "source_pool": "",
        "source_stage": "",
        "source_position": 0
      }
    ],
    "fixtures": [
      {
        "key": "fixture:0:0",
        "pool_key": "pool:main",
        "round": 0,
        "position": 0,
        "code": "F",
        "home_slot_key": "slot:0:0",
        "away_slot_key": "slot:0:1",
        "status": "pending"
      }
    ]
  }
}
400 Fewer than 2 teams, more than 128, an unknown format, ladder, or a seedFrom.
429 More than 60 requests in a minute from this IP address.

Authentication

A token is a PocketBase auth token for the users collection. Send it in the Authorization header, with or without the Bearer prefix. Signing in is rate limited to 5 requests per 10 seconds per IP address.

POST /api/collections/users/auth-with-password no token

Sign in

PocketBase's own sign-in. identity is the email address of the account. The token in the answer is what every authenticated request sends in its Authorization header.

Request body

{
  "identity": "jana@example.com",
  "password": "correct horse battery"
}

Responses

200 A token and the account it belongs to.
Example
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
  "record": {
    "id": "u5k7m9p1r3t5v7x",
    "collectionName": "users",
    "email": "jana@example.com",
    "verified": true
  }
}
400 The address or the password is wrong.
429 More than 5 sign-in requests in 10 seconds from this IP address.
POST /api/collections/users/auth-refresh token required

Trade a token for a fresh one

PocketBase's own refresh. Send the token you hold; the answer has the same shape as signing in.

Responses

200 A new token and the account record.
401 The token is missing, expired, or was invalidated when the account was claimed or handed over.

Records

The PocketBase records API over the collections listed under Collections below. Reading a published tournament needs no token. Listing supports PocketBase's filter, sort, expand, fields, page and perPage parameters.

GET /api/collections/{collection}/records no token

List records

A page of one collection. Records of a published tournament are returned to anybody; records of a draft only to its owner and crew, and without a token they are simply absent from the list rather than refused.

displays is the exception: anonymously it returns a screen only when the request carries that screen's ?token=.

Parameters

collection path, required One of the names under Collections below.
filter query A PocketBase filter expression, for example tournament = "t8w2k1m0q9x4c7z" && status = "live".
sort query Comma-separated fields, - for descending: round,position.
expand query Relations to inline, for example home_entry,away_entry.
fields query Comma-separated fields to return instead of the whole record.
page query 1-based page number. Defaults to 1.
perPage query Records per page. Defaults to 30.

Responses

200 One page.
Example
{
  "page": 1,
  "perPage": 30,
  "totalItems": 1,
  "totalPages": 1,
  "items": [
    {
      "id": "c9y8x7w6v5u4t3s",
      "collectionName": "courts",
      "tournament": "t8w2k1m0q9x4c7z",
      "name": "Court 1",
      "position": 0
    }
  ]
}
400 The filter or the sort does not parse.
404 No such collection.
POST /api/collections/{collection}/records token required

Create a record

Anybody signed in can create a tournament, and becomes its owner. Courts, entries, stages, displays and sponsors are created by the tournament's owner or crew, invites and crew rows by the owner alone. standings and fixture_events are written by the server and accept nothing.

A stage created here has no matches until POST /api/stages/{stage}/generate draws it. An entry created after the draw is not in any stage until POST /api/entries/{entry}/admit fits it in.

Parameters

collection path, required One of the names under Collections below.

Request body

{
  "tournament": "t8w2k1m0q9x4c7z",
  "name": "Kestrels",
  "short_name": "KES",
  "seed": 1,
  "status": "registered"
}

Responses

200 The record as stored.
400 A field failed validation, or the collection's create rule refused the request.
403 The collection takes no writes through the API.
GET /api/collections/{collection}/records/{id} no token

Read one record

The same access as listing. A record you may not see answers 404, not 403.

Parameters

collection path, required One of the names under Collections below.
id path, required The record's id.
expand query Relations to inline.

Responses

200 The record.
404 No such record, or not one this request may see.
PATCH /api/collections/{collection}/records/{id} token required

Update a record

Send only the fields that change. Entering a result is a patch to a fixture: settling it is what fills the slots that were waiting on it, rebuilds the tables, draws the next Swiss round and seeds a playoff from a finished group, all before the response is sent.

A level score is refused with 400 when the tournament's scoring does not allow draws, and always in a knockout stage. A best-of series accepts only the scores a series can end on.

Parameters

collection path, required One of the names under Collections below.
id path, required The record's id.

Request body

{
  "home_score": 2,
  "away_score": 1,
  "status": "finished"
}

Responses

200 The record as stored.
400 A field failed validation, or the result is one the format cannot end on.
404 No such record, or not one this token may change.
DELETE /api/collections/{collection}/records/{id} token required

Delete a record

Deleting a tournament deletes everything under it. Deleting a crew row removes that person's access immediately.

Parameters

collection path, required One of the names under Collections below.
id path, required The record's id.

Responses

204 Deleted.
404 No such record, or not one this token may delete.
GET /api/realtime no token

Be told when a tournament changes

PocketBase's server-sent event stream. The first event is PB_CONNECT and carries a clientId; post that id back with the topics you want. Whistle.day broadcasts on tournament/{id} whenever anything a screen renders is written. The message says that something changed and never what, so the thing to do on receiving one is to read GET /api/display/{token} or the records again.

Keep a slow poll underneath: a connection that looks open and has stopped delivering is the failure worth planning for.

Responses

200 An open text/event-stream.
POST /api/realtime no token

Choose what the stream delivers

Sets the subscriptions of an open stream, replacing whatever it had.

Request body

{
  "clientId": "kX2bQ9…",
  "subscriptions": [
    "tournament/t8w2k1m0q9x4c7z"
  ]
}

Responses

204 Subscribed.
404 No open stream has this clientId.

Stages

Drawing a stage and repairing one. All three need a token from the tournament's owner or crew.

POST /api/stages/{stage}/generate token required

Draw a stage

Writes the stage's pools, slots and fixtures from its format, its config and the tournament's entries, then fills in whatever can already be resolved. With ?dry_run=1 nothing is written and the answer is the plan itself, in the shape POST /api/preview returns, so a preview and the real draw come out of the same generator.

A playoff seeded from an earlier stage sets config.seedFrom to { "stage": "<id>", "advancePerPool": 2 } and is filled from that stage's tables as its groups finish.

Parameters

stage path, required The stage's id.
dry_run query 1 or true to return the plan without writing it.

Responses

200 How much was written. With dry_run, { "stage", "dry_run": true, "plan" } instead.
Example
{
  "stage": "s4h6j8k0m2n4p6q",
  "pools": 2,
  "slots": 8,
  "fixtures": 12
}
400 The stage cannot be drawn as configured, or it already holds played fixtures.
401 No token.
403 The token's account is not on this tournament's crew.
404 No such stage.
POST /api/stages/{stage}/resolve token required

Re-resolve a stage

Recomputes who goes where from the results already entered. Results resolve themselves as they are written, so this is the repair path: after a score is corrected, or when a slot was written after the fixture it reads from. Safe to call twice.

Parameters

stage path, required The stage's id.

Responses

200 How many slots were filled.
Example
{
  "stage": "s4h6j8k0m2n4p6q",
  "resolved": 3
}
401 No token.
403 The token's account is not on this tournament's crew.
404 No such stage.
POST /api/stages/{stage}/pair token required

Draw the next Swiss round

A Swiss stage draws each round itself when the last result of the round before it is entered, so this too is a repair path. It depends only on rounds that are complete, which makes it safe to call twice.

Parameters

stage path, required The stage's id.

Responses

200 round is the round drawn, or -1 when there was nothing to draw: the round in progress is not finished, or the stage has played out. rematch is true when the field ran out of fresh opponents and the round repeats a meeting.
Example
{
  "stage": "s4h6j8k0m2n4p6q",
  "round": 2,
  "rematch": false
}
400 The stage is not a Swiss stage.
401 No token.
403 The token's account is not on this tournament's crew.
404 No such stage.

Entries

Taking a team out of a running tournament, putting one back, and fitting in a team that arrived after the draw. Each is one transaction that also retallies every table it touches.

POST /api/entries/{entry}/withdraw token required

Take a team out

Closes every match the team had left and retallies every stage, in one transaction. policy has no default, because the two answers rank everybody else differently: forfeit lets what they played stand and gives the rest to their opponents as walkovers; void strikes every match they were in, on both sides. status is withdrawn (the default) or disqualified.

Parameters

entry path, required The entry's id.

Request body

{
  "policy": "forfeit",
  "status": "withdrawn"
}

Responses

200 The entry's new state.
Example
{
  "entry": "e1a2b3c4d5f6g7h",
  "status": "withdrawn",
  "policy": "forfeit"
}
400 policy is missing or is neither forfeit nor void.
401 No token.
403 The token's account is not on this tournament's crew.
404 No such entry.
POST /api/entries/{entry}/reinstate token required

Put a team back

Undoes exactly what taking them out did, and nothing else: the matches that withdrawal closed are reopened and the tables are rebuilt.

Parameters

entry path, required The entry's id.

Responses

200 The entry's new state.
Example
{
  "entry": "e1a2b3c4d5f6g7h",
  "status": "active"
}
400 The team could not be put back.
401 No token.
403 The token's account is not on this tournament's crew.
404 No such entry.
POST /api/entries/{entry}/admit token required

Fit in a team that arrived after the draw

Create the entry through the records API first, then call this. What it does depends on the format: in a round robin the team joins the smallest group and gets a match against everybody in it; in a Swiss it takes a seat in the table and is paired from the next round; a knockout drawn from the entries is drawn again, and only while nobody has played in it.

Every stage is checked before anything is written, so a refusal leaves the tournament untouched. A team already in a stage is left where it is.

Parameters

entry path, required The entry's id.

Responses

200 The stages the team was admitted to.
Example
{
  "entry": "e3c4d5f6g7h8j9k",
  "stages": [
    "s4h6j8k0m2n4p6q"
  ]
}
400 A stage cannot take them, most often because a knockout has already started. The message says which.
401 No token.
403 The token's account is not on this tournament's crew.
404 No such entry.

Crew

How a second person gets access to a tournament: the owner shows a join code, and whoever scans it becomes a referee, a scorer or an organiser.

GET /api/staff/invite/{token} no token

What a join code is for

The name of the tournament and the role on offer, and nothing else about the event. Public, so that a join screen can say what is being accepted before anybody accepts it. A withdrawn code answers the same 404 as one that never existed.

Parameters

token path, required The join code's token.

Responses

200 The invitation. role is organizer, referee or scorer.
Example
{
  "name": "Autumn Cup",
  "role": "referee"
}
404 This invitation is not valid.
POST /api/staff/join token required

Accept a join code

Adds the signed-in account to the tournament's crew in the role the code carries. name is what the rest of the crew sees beside the results this person enters; left empty it falls back to the role. Scanning a different role's code changes the role instead of adding a second row, and the owner scanning their own code changes nothing.

Request body

{
  "token": "Zk4mP9qR2sT7vX1b",
  "name": "Jana"
}

Responses

200 The tournament joined and the role held in it.
Example
{
  "tournament": "t8w2k1m0q9x4c7z",
  "name": "Autumn Cup",
  "role": "referee"
}
401 No token.
404 This invitation is not valid.

Account

The wizard and the MCP server both start a tournament on a temporary account that nobody signed up for. These endpoints turn that account into a person's, hand it to somebody else, or delete it.

POST /api/organiser/claim token required

Put an email and a password on a temporary account

Turns the account the wizard made into one somebody can sign in with. It works only on an account that has never had an address. Every token issued before the claim stops working, so sign in again with the new address and password. A confirmation email is sent, and the account works before it is confirmed.

A refusal does not say whether the address is already in use.

Request body

{
  "email": "jana@example.com",
  "password": "correct horse battery"
}

Responses

200 Claimed.
Example
{
  "email": "jana@example.com"
}
400 The account already has an email, the password is shorter than 8 characters, or the address cannot be used.
401 No token.
POST /api/organiser/handoff token required

Make a link that hands a tournament to a person

For an account that cannot claim itself, which in practice is one an agent is holding. The owner mints a link, sends it to whoever is running the day, and that person sets their own email and password on it. The link is single-use and expires after 24 hours; minting another burns the one before it.

Request body

{
  "tournament": "t8w2k1m0q9x4c7z"
}

Responses

200 The link.
Example
{
  "token": "4f9c…",
  "url": "https://whistle.day/claim/4f9c…",
  "expires": "2026-10-04 09:41:12.000Z"
}
400 The account already belongs to somebody, so there is nothing to hand over.
401 No token.
403 Only the owner can hand a tournament over.
404 No such tournament.
GET /api/organiser/handoff/{token} no token

What a handoff link is for

The name of the tournament on offer. Public, because the person opening the link has no account yet.

Parameters

token path, required The token from the link.

Responses

200 The tournament's name.
Example
{
  "tournament": "Autumn Cup"
}
404 The link has been used, has expired, or never existed.
POST /api/organiser/handoff/{token}/claim no token

Take a tournament over with an email and a password

Public: the link is the credential. The account behind it gets the address and password sent here, the link is spent, and every token the previous holder had stops working. Sign in afterwards with what was just set.

Parameters

token path, required The token from the link.

Request body

{
  "email": "jana@example.com",
  "password": "correct horse battery"
}

Responses

200 Claimed.
Example
{
  "email": "jana@example.com",
  "tournament": "t8w2k1m0q9x4c7z"
}
400 The password is shorter than 8 characters, the address cannot be used, or the tournament already belongs to somebody.
404 The link has been used, has expired, or never existed.
POST /api/organiser/handoff/{token}/oauth-session no token

Start taking a tournament over with Google or Facebook

The first of two requests either side of the provider's redirect. It returns a token for the account behind the link, so that completing PocketBase's own OAuth2 sign-in with it links the provider to that account instead of creating a new one. Nothing about the account changes here and the link is not spent.

Parameters

token path, required The token from the link.

Responses

200 A token for the account being handed over.
Example
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"
}
400 The tournament already belongs to somebody.
404 The link has been used, has expired, or never existed.
POST /api/organiser/handoff/{token}/finish token required

Finish taking a tournament over with Google or Facebook

The second request, sent with the token from oauth-session once the provider has been linked. The link is spent, the account's old password and tokens stop working, and the answer carries the one token that still does. email is optional: send the address the provider returned to put it on the account.

Parameters

token path, required The token from the link.

Request body

{
  "email": "jana@example.com"
}

Responses

200 Claimed, with a fresh token.
Example
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
  "email": "jana@example.com",
  "tournament": "t8w2k1m0q9x4c7z"
}
400 The address cannot be used.
401 No token.
403 The token did not come from this link.
404 The link has been used, has expired, or never existed.
DELETE /api/organiser/account token required

Delete the account and everything it owns

Permanent. Every tournament the account owns is deleted with its courts, teams, stages, matches, tables, screens and crew; the account is removed from the crews it was on, and from any match it was down to referee.

Responses

200 Deleted.
Example
{
  "deleted": true
}
401 No token.
POST /api/organiser/delete token required

Delete the account, for clients that cannot send DELETE

The same operation as DELETE /api/organiser/account.

Responses

200 Deleted.
Example
{
  "deleted": true
}
401 No token.

Agents

The MCP server and the OAuth 2.1 authorization server in front of it. Connection instructions for Claude, ChatGPT, Cursor and other clients are on the MCP page at https://whistle.day/mcp.

GET /api/mcp no token

Open the stream an MCP client listens on

Server-to-client messages for the session in Mcp-Session-Id.

Responses

200 An open text/event-stream.
POST /api/mcp no token

Send an MCP request

The MCP server, over Streamable HTTP: JSON-RPC in the body, the session in the Mcp-Session-Id header the server hands out on initialize. No credential is needed to start: create_tournament makes a temporary account and the session remembers it. A client that has been through the OAuth flow below sends its access token as Authorization: Bearer and works on that account instead. Limited to 120 requests a minute per IP address.

Responses

200 A JSON-RPC response, as JSON or as an event stream.
429 More than 120 requests in a minute from this IP address.
DELETE /api/mcp no token

End an MCP session

Ends the session in Mcp-Session-Id. A temporary account lasts as long as the session that made it, so call the create_handoff tool first to keep the tournament.

Responses

200 The session is closed.
GET /.well-known/oauth-protected-resource no token

Where the MCP server's tokens come from

Protected resource metadata (RFC 9728): names /api/mcp as the resource and this host as its authorization server.

Responses

200 The metadata document.
GET /.well-known/oauth-authorization-server no token

The OAuth endpoints and what they support

Authorization server metadata (RFC 8414), and the authoritative list of the endpoints below. Authorization code with PKCE (S256) and refresh tokens, public clients only, one scope: tournaments.

Responses

200 The metadata document.
POST /oauth/register no token

Register an OAuth client

Dynamic client registration (RFC 7591). Send redirect_uris and a client_name; the answer carries the client_id to use from then on. There is no client secret.

Responses

201 The registered client.
400 The registration is not acceptable, most often because of a redirect URI.
GET /oauth/authorize no token

Ask a person to connect their account

The authorization endpoint. Send the browser here with a standard authorization code request carrying a PKCE code_challenge; the person signs in, approves, and is sent back to the client's redirect_uri with a code.

Responses

302 A redirect, to the consent screen or back to the client.
400 The client or its redirect URI is not recognised.
POST /oauth/token no token

Exchange a code or a refresh token

The token endpoint, taking grant_type=authorization_code with the PKCE code_verifier, or grant_type=refresh_token. Refresh tokens rotate: each one is good once, and using a spent one revokes the whole family.

Responses

200 An access token for /api/mcp and a refresh token.
400 An OAuth error object, such as invalid_grant.

Collections

What the records API reads and writes. A tournament is an ordered list of stages; a stage holds pools; a pool holds slots, and a fixture is played between two of them. The field lists below are read from the running database, so they are what the API returns today.

CollectionWhat it isReadWrite
tournaments The event: its name, sport, status, and how matches are scored and ties are broken. Anybody once published; owner and crew while a draft Any signed-in account creates one. Owner and crew update, the owner deletes. Only the owner sets club
clubs Who runs a series of tournaments, and the address they are listed together at. A tournament's club puts it on that page. The owner. Everybody else reads one through GET /api/clubs/{slug} The owner
courts Playing areas, named as the venue names them. Anybody once published Owner and crew
entries A participant, team or individual. short_name is what a projector draws. Anybody once published Owner and crew
stages A phase of the tournament. A tournament is an ordered list of them, each with its own format and config. Anybody once published Owner and crew
pools A self-contained part of a stage: one round-robin group, or one side of a bracket. Anybody once published Owner and crew; normally written by drawing the stage
slots A position that gets filled later, such as the winner of a semi-final or first place in group A. entry is empty until its source resolves. Anybody once published Owner and crew; normally written by drawing the stage
fixtures A match. Entering a result is a patch to one of these. Anybody once published Owner and crew
games The sets, legs or maps inside a match. Anybody once published Owner and crew
standings A table, rebuilt from scratch after every result. tiebreak records which criteria placed each row. Anybody once published The server only
fixture_events An append-only record of who changed a result, from what, to what. Anybody once published The server only
sponsors The businesses credited on a tournament's closing screen. Anybody once published Owner and crew
displays A projector screen and how it is configured: which panels, how fast it rotates, what it is narrowed to. Whoever sends the screen's ?token=, and the owner and crew Owner and crew
staff The crew: who may operate a tournament, in which role, and the name they gave when they joined. Owner and crew The owner. People join through POST /api/staff/join
invites A join code: one token per role. The owner The owner
tournaments fields
idtext
nametext
slugtext
sporttext
locationtext
starts_ondate
ends_ondate
statusdraft | published | live | finished | archived
ownerrelation to users
logofile
scoringjson
settingsjson
createdautodate
updatedautodate
clubrelation to clubs
clubs fields
idtext
nametext
slugtext
ownerrelation to users
logofile
accenttext
createdautodate
updatedautodate
courts fields
idtext
tournamentrelation to tournaments
nametext
positionnumber
activebool
createdautodate
updatedautodate
entries fields
idtext
tournamentrelation to tournaments
nametext
short_nametext
clubtext
contacttext
seednumber
statusregistered | checked_in | active | withdrawn | disqualified
logofile
membersjson
createdautodate
updatedautodate
withdrawalforfeit | void
stages fields
idtext
tournamentrelation to tournaments
positionnumber
nametext
formatround_robin | single_elim | double_elim | swiss | ladder
statusdraft | seeded | live | finished
configjson
createdautodate
updatedautodate
name_kindtext
pools fields
idtext
stagerelation to stages
nametext
positionnumber
kindgroup | winners | losers | final | consolation
advance_countnumber
createdautodate
updatedautodate
name_kindtext
slots fields
idtext
stagerelation to stages
poolrelation to pools
roundnumber
positionnumber
labeltext
entryrelation to entries
source_typedirect | fixture_winner | fixture_loser | pool_rank | stage_rank | bye
source_poolrelation to pools
source_stagerelation to stages
source_positionnumber
createdautodate
updatedautodate
source_fixturerelation to fixtures
label_kindtext
label_argsjson
fixtures fields
idtext
tournamentrelation to tournaments
stagerelation to stages
poolrelation to pools
roundnumber
positionnumber
codetext
home_slotrelation to slots
away_slotrelation to slots
home_entryrelation to entries
away_entryrelation to entries
statuspending | ready | live | finished | walkover | bye | cancelled
home_scorenumber
away_scorenumber
winnerrelation to entries
loserrelation to entries
courtrelation to courts
refereerelation to users
scheduled_atdate
started_atdate
finished_atdate
lockedbool
notestext
createdautodate
updatedautodate
forfeited_byrelation to entries
games fields
idtext
fixturerelation to fixtures
numbernumber
home_scorenumber
away_scorenumber
finishedbool
createdautodate
updatedautodate
standings fields
idtext
stagerelation to stages
poolrelation to pools
entryrelation to entries
positionnumber
playednumber
wonnumber
drawnnumber
lostnumber
score_fornumber
score_againstnumber
sub_fornumber
sub_againstnumber
pointsnumber
tiebreakjson
qualifiedbool
createdautodate
updatedautodate
fixture_events fields
idtext
fixturerelation to fixtures
actorrelation to users
kindrecorded | corrected | cleared
statustext
home_scorenumber
away_scorenumber
winnerrelation to entries
previousjson
createdautodate
sponsors fields
idtext
tournamentrelation to tournaments
nametext
logofile
tiertext
positionnumber
createdautodate
updatedautodate
displays fields
idtext
tournamentrelation to tournaments
nametext
tokentext
modeauto | now_playing | standings | bracket | schedule | results | court_schedule | stage_progress
rotate_secondsnumber
scopejson
themejson
activebool
createdautodate
updatedautodate
panelsjson
staff fields
idtext
tournamentrelation to tournaments
userrelation to users
roleorganizer | referee | scorer
createdautodate
updatedautodate
nametext
invites fields
idtext
tournamentrelation to tournaments
tokentext
roleorganizer | referee | scorer
activebool
createdautodate
updatedautodate