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.
| Collection | What it is | Read | Write |
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
id | text |
name | text |
slug | text |
sport | text |
location | text |
starts_on | date |
ends_on | date |
status | draft | published | live | finished | archived |
owner | relation to users |
logo | file |
scoring | json |
settings | json |
created | autodate |
updated | autodate |
club | relation to clubs |
clubs fields
id | text |
name | text |
slug | text |
owner | relation to users |
logo | file |
accent | text |
created | autodate |
updated | autodate |
courts fields
id | text |
tournament | relation to tournaments |
name | text |
position | number |
active | bool |
created | autodate |
updated | autodate |
entries fields
id | text |
tournament | relation to tournaments |
name | text |
short_name | text |
club | text |
contact | text |
seed | number |
status | registered | checked_in | active | withdrawn | disqualified |
logo | file |
members | json |
created | autodate |
updated | autodate |
withdrawal | forfeit | void |
stages fields
id | text |
tournament | relation to tournaments |
position | number |
name | text |
format | round_robin | single_elim | double_elim | swiss | ladder |
status | draft | seeded | live | finished |
config | json |
created | autodate |
updated | autodate |
name_kind | text |
pools fields
id | text |
stage | relation to stages |
name | text |
position | number |
kind | group | winners | losers | final | consolation |
advance_count | number |
created | autodate |
updated | autodate |
name_kind | text |
slots fields
id | text |
stage | relation to stages |
pool | relation to pools |
round | number |
position | number |
label | text |
entry | relation to entries |
source_type | direct | fixture_winner | fixture_loser | pool_rank | stage_rank | bye |
source_pool | relation to pools |
source_stage | relation to stages |
source_position | number |
created | autodate |
updated | autodate |
source_fixture | relation to fixtures |
label_kind | text |
label_args | json |
fixtures fields
id | text |
tournament | relation to tournaments |
stage | relation to stages |
pool | relation to pools |
round | number |
position | number |
code | text |
home_slot | relation to slots |
away_slot | relation to slots |
home_entry | relation to entries |
away_entry | relation to entries |
status | pending | ready | live | finished | walkover | bye | cancelled |
home_score | number |
away_score | number |
winner | relation to entries |
loser | relation to entries |
court | relation to courts |
referee | relation to users |
scheduled_at | date |
started_at | date |
finished_at | date |
locked | bool |
notes | text |
created | autodate |
updated | autodate |
forfeited_by | relation to entries |
games fields
id | text |
fixture | relation to fixtures |
number | number |
home_score | number |
away_score | number |
finished | bool |
created | autodate |
updated | autodate |
standings fields
id | text |
stage | relation to stages |
pool | relation to pools |
entry | relation to entries |
position | number |
played | number |
won | number |
drawn | number |
lost | number |
score_for | number |
score_against | number |
sub_for | number |
sub_against | number |
points | number |
tiebreak | json |
qualified | bool |
created | autodate |
updated | autodate |
fixture_events fields
id | text |
fixture | relation to fixtures |
actor | relation to users |
kind | recorded | corrected | cleared |
status | text |
home_score | number |
away_score | number |
winner | relation to entries |
previous | json |
created | autodate |
displays fields
id | text |
tournament | relation to tournaments |
name | text |
token | text |
mode | auto | now_playing | standings | bracket | schedule | results | court_schedule | stage_progress |
rotate_seconds | number |
scope | json |
theme | json |
active | bool |
created | autodate |
updated | autodate |
panels | json |
staff fields
id | text |
tournament | relation to tournaments |
user | relation to users |
role | organizer | referee | scorer |
created | autodate |
updated | autodate |
name | text |
invites fields
id | text |
tournament | relation to tournaments |
token | text |
role | organizer | referee | scorer |
active | bool |
created | autodate |
updated | autodate |