# Foos App — Club Results Integration API **Version 1.** Publish a finished foosball tournament to Foos App as a first-class tournament: full bracket, every match, standings, and podium, on a public web page and inside the iOS/Android apps. Canonical URL for this document: --- ## If you are an AI implementing this integration, read this section first Your task is to convert one finished tournament from the club's own system into a single JSON POST. The work is almost entirely **data mapping**, not API choreography — there is one call. Four things determine whether you succeed: 1. **You describe what happened, not how the bracket looked.** You never send rounds, positions, or bracket slots for elimination play. You send a flat list of matches, each naming the two teams that played and who won. Foos App regenerates the bracket with the same code that runs native tournaments and replays your results through it. 2. **You invent your own IDs.** Players and teams are joined together by short string `ref` values that you make up (`"p1"`, `"t3"`, anything stable within the payload). They exist only inside the request. 3. **Seeding must reproduce the real pairings.** Because the bracket is regenerated, the order of `teams[]` has to produce the matchups that actually happened. This is the single most common cause of a rejected import. See [Seeding](#seeding-the-one-thing-that-actually-bites). 4. **You verify before you commit.** Every import can be dry-run: it builds the entire tournament inside a database transaction that always rolls back, and returns exactly what would have been created. Loop on the dry run until it succeeds, then send the real one. See [The verification loop](#the-verification-loop). Everything you need is in this document. There is no SDK, and you do not need one — it is one HTTP POST with a JSON body. --- ## Base URL and authentication ``` https://foos-api.com/api/v1/integrations ``` Every request needs the club's API key in a header: ``` X-Club-Api-Key: ``` `Authorization: Bearer ` is accepted as an alternative. A key is scoped to exactly one club and can only ever write to that club. Keys are issued by Foos App (see [How a human gets set up](#how-a-human-gets-set-up)) and can be revoked at any time. Missing or bad key: ```json { "error": "unauthorized", "message": "Missing or invalid X-Club-Api-Key." } ``` with HTTP `401`. --- ## Endpoints | Method | Path | What it does | |---|---|---| | `POST` | `/tournaments` | Import a finished tournament. Add `?dry_run=1` to validate only. | | `GET` | `/tournaments/:external_ref` | Read back what you imported. | | `DELETE` | `/tournaments/:external_ref` | Remove an import completely. | | `POST` | `/matches` | Publish a single one-off result (see [Single match](#single-match-shortcut)). | | `GET` | `/schema` | Live machine-readable schema: enums plus an example per format. | `/schema` is generated from the running code, so if this document and `/schema` ever disagree, `/schema` wins. --- ## The mental model ``` your JSON ──▶ players[] ──▶ guests or linked Foos App accounts teams[] ──▶ seeded bracket, regenerated by Foos App matches[] ──▶ results replayed in order, advancing winners │ ▼ a real, completed Foos App tournament (bracket page, standings page, podium) ``` Foos App does not store your bracket layout. It stores the *facts* — who played, who won, what the score was — and derives the bracket, the advancement, the standings, and the champion itself. That is why the output is indistinguishable from a tournament run natively in the app. --- ## The envelope ```jsonc { "external_ref": "spring-open-2026", // REQUIRED. Your stable ID. Idempotency key. "name": "Spring Open 2026", // REQUIRED. Display name. "format": "double_elim", // REQUIRED. See enums below. "tournament_type": "dyp", // REQUIRED. See enums below. "played_at": "2026-04-11T18:00:00Z", // Optional. ISO 8601. Backdates the record. "settings": { "monster": { } }, // Monster format only. "players": [ ], // REQUIRED. "teams": [ ], // REQUIRED except Monster points-only. "matches": [ ] // REQUIRED. } ``` ### Enums | Field | Allowed values | |---|---| | `format` | `single_elim`, `double_elim`, `round_robin`, `monster` | | `tournament_type` | `dyp`, `bring_partner`, `singles` | | `skill_level` | `a_player`, `b_player`, `c_player` | | `settings.monster.advancement` | `points_only`, `top_4`, `top_8`, `top_16` | `tournament_type` describes how teams were formed, not the bracket: `dyp` is draw your partner, `bring_partner` is fixed pairs, `singles` is one player per team. Swiss is **not** importable yet — see [Not supported yet](#not-supported-yet). `played_at` sets the tournament's completed and started timestamps, so an event from last year appears in history at the right date rather than today. --- ## `players[]` Every human in the event, listed once. ```jsonc { "ref": "p1", // REQUIRED. Unique within this payload. You invent it. "name": "Alex Río", // REQUIRED unless user_id is given. "user_id": 4821, // Optional. A known Foos App user id. "ifp_player_id": 20626, // Optional. An IFP player id. "skill_level": "a_player" // Optional. a_player | b_player | c_player. } ``` How a player is resolved, in order: - **`user_id`** — links the real Foos App account. If the id does not exist the whole import is rejected. - **`ifp_player_id`** — links the Foos App account that has claimed that IFP player. Most IFP players have not claimed an account, so this usually falls through to the next case; that is expected and fine. - **Otherwise** — the player is created as a club guest under `name`. This is the normal path. Guests still appear on the bracket, standings, and public pages. `skill_level` also accepts the shorthands `"a"`, `"b"`, `"c"`. Any other value is ignored rather than rejected. --- ## `teams[]` The competing units. Required for `single_elim`, `double_elim`, and `round_robin`. For Monster it describes only the **knockout** teams, and is omitted entirely for points-only Monster. ```jsonc { "ref": "t1", // REQUIRED. Unique within this payload. "player_refs": ["p1", "p2"], // REQUIRED. One ref for singles, two for doubles. "seed": 1, // Optional. Defaults to the array index + 1. "name": "Río / Okafor" // Optional. Auto-generated from player names. } ``` At least two teams are required. --- ## `matches[]` The shape depends on the format. Elimination and round robin use one shape; Monster qualifying uses another. ### Elimination and round robin ```jsonc { "team_refs": ["t1", "t4"], // REQUIRED. Exactly the two teams that played. "winner_ref": "t1", // REQUIRED. Must be one of team_refs. "team1_score": 7, // Optional. Score for team_refs[0]. "team2_score": 4, // Optional. Score for team_refs[1]. "games": [ // Optional. Per-game detail, in team_refs order. { "game_number": 1, "team1_score": 7, "team2_score": 5 }, { "game_number": 2, "team1_score": 4, "team2_score": 7 }, { "game_number": 3, "team1_score": 7, "team2_score": 2 } ] } ``` Rules that matter: - **Do not send `round`, `position`, or `bracket`.** They are ignored. The bracket is regenerated. - **Do not send bye matches.** Anything without exactly two `team_refs` is skipped. Byes are generated automatically when the team count is not a power of two. - **Scores are optional but recommended.** They are stated in `team_refs` order and re-oriented automatically, so you never have to worry about which side the app considers "team 1". - **`winner_ref` is mandatory even when scores are supplied.** It is the source of truth for advancement. - **Order matters whenever the same two teams meet more than once.** Results are matched to generated matches by unordered team pair and consumed in the order you list them. In double elimination one pair can meet up to three times — the winners final, the grand final, and the reset — so list those results in the order they were played. - **Double elimination supports the grand-finals reset.** The losers-bracket team arrives with one loss and has to beat the winners-bracket team twice. If they win the grand final, submit the reset as a second result for the same pair, immediately after it. If the winners-bracket team wins the grand final, the title is settled in one game and there is no reset to submit. > **Extra results are ignored silently.** A result that does not correspond to any > match in the regenerated bracket is dropped without an error — you will still get > `"ok": true`. Submitting a third grand-final game, or a reset when the > winners-bracket team already closed it out, is the usual way to hit this. Read the > `podium` in the dry-run response and confirm the champion is who you expect before > committing. ### Monster qualifying Monster rotates partners every round, so its qualifying matches name *players* directly and there is no `teams[]` entry for them. ```jsonc { "bracket": "monster_qualifying", // REQUIRED. This exact literal. "round": 1, // REQUIRED. Integer >= 1. "position": 1, // REQUIRED. Integer >= 1, unique in the round. "team1_player_refs": ["p1", "p2"], // REQUIRED. The partners on side 1. "team2_player_refs": ["p3", "p4"], // REQUIRED. The partners on side 2. "team1_score": 7, // REQUIRED. Monster scores are mandatory. "team2_score": 5 // REQUIRED. } ``` Monster scores are validated against the points race and a wrong score is rejected. For `race_to: N`: - The winner's score must be exactly `N`; the loser's must be `0` to `N-1`. - A tie is only legal as `N-1` to `N-1`, and only when `allow_ties` is `true`. ### Monster knockout Identical to the elimination shape, with `team_refs` pointing at entries in `teams[]`. Include these only when `settings.monster.advancement` is `top_4`, `top_8`, or `top_16`. Omit them entirely for `points_only`. --- ## Monster settings ```jsonc "settings": { "monster": { "race_to": 7, // Points target per qualifying match. "allow_ties": false, "win_by_2": false, "advancement": "points_only", // points_only | top_4 | top_8 | top_16 "knockout_format": "single_elim" } } ``` `qualifying_rounds` is inferred from the highest `round` in your qualifying matches, so you do not need to send it. With `points_only`, the event ends on the qualifying leaderboard and the champion is the points leader. With any `top_N`, you must also supply `teams[]` for the knockout and the knockout matches. --- ## Seeding: the one thing that actually bites The order of `teams[]` — or the explicit `seed` on each — is the competitive seeding. Foos App places them with standard tournament seeding: the top seed and the second seed at opposite ends of the bracket, the next pair on the quarter anchors, and so on, so that the strongest teams cannot meet until late. Then your results are matched to the generated matches **by team pair**. The consequence: **your seeds must produce the pairings that actually happened.** If your event genuinely used standard seeding, sending the real seeds reproduces it exactly. If your bracket was drawn some other way and, say, seeds 1 and 2 met in round one, the regenerated bracket will contain a matchup you have no result for, and the import fails with: ``` no result provided for WB-R1-M2 (Río / Okafor vs Bell / Nakamura) ``` That error names the pairing the bracket expected. Adjust the order of `teams[]` until a dry run passes. Because the dry run always rolls back, you can iterate as many times as you need with no consequences. Round robin has no seeding sensitivity — every team plays every other team, so any order works. Submit all matches. --- ## Idempotency `external_ref` is your key, unique per club. Re-POSTing the same `external_ref` **destroys the previous import and rebuilds it from scratch**. That makes corrections trivial: fix the payload, send it again, done. There is no PATCH and you never need one. Use a stable identifier from your own system — an event id, a slug, anything you can reproduce. Do not use a timestamp or a random value, or every submission will create a duplicate tournament. --- ## The verification loop Always dry run first. ```bash curl -X POST 'https://foos-api.com/api/v1/integrations/tournaments?dry_run=1' \ -H 'X-Club-Api-Key: YOUR_KEY' \ -H 'Content-Type: application/json' \ -d @tournament.json ``` The dry run does the entire import — creates the tournament, the players, the teams, generates the bracket, replays every result, computes the podium — inside a transaction that always rolls back. Nothing is persisted, and the response is the same shape you would get for real, including the podium, so you can check the champion is who you expect before committing. `"dry_run": true` in the request body works identically to the query parameter. When it comes back `"ok": true`, send the same request without `?dry_run=1`. --- ## Responses Success: ```json { "ok": true, "dry_run": false, "created": true, "replaced": false, "tournament": { "id": 812, "external_ref": "spring-open-2026", "source": "integration", "name": "Spring Open 2026", "format": "double_elim", "tournament_type": "dyp", "status": "completed", "completed_at": "2026-04-11T18:00:00Z", "participant_count": 16, "team_count": 8, "match_count": 15, "podium": { }, "web_url": "https://www.foos-app.com/tournaments/812", "bracket_url": "https://www.foos-app.com/tournaments/812/bracket", "standings_url": "https://www.foos-app.com/tournaments/812/standings" } } ``` `201` when the tournament is newly created; `200` when it replaced a previous import or when it was a dry run. `created` and `replaced` tell you which happened. The three URLs are public and shareable immediately. ## Errors Every failure is JSON. Nothing is ever partially imported — the entire import is one transaction, so a failure leaves no trace. | HTTP | `error` | Meaning and what to do | |---|---|---| | `401` | `unauthorized` | Missing or invalid key. Check the `X-Club-Api-Key` header. | | `422` | `invalid_request` | Your payload. `message` says exactly what is wrong. Fix and retry. | | `422` | `import_failed` | Unexpected server-side failure. Report it with your payload. | | `404` | `not_found` | No import with that `external_ref` for your club (GET/DELETE). | Messages you are most likely to hit, and their causes: - `no result provided for ( vs )` — a seeding mismatch. See [Seeding](#seeding-the-one-thing-that-actually-bites). - `import did not reach a completed state — every played match must include a result` — you are missing at least one match. Every non-bye match in the regenerated bracket needs a result. - `unknown player ref 'pX'` / `team 'tY' references unknown player 'pX'` — a ref in `teams[]` or `matches[]` has no matching entry in `players[]`. - `MQ-R2-M1: ` — an illegal Monster score for the points race. - `every player needs a unique 'ref'` / `duplicate player ref 'pX'` — refs must be present and unique. --- ## Worked examples Each of these is a complete, valid request body. ### Single elimination, draw your partner, 4 teams ```json { "external_ref": "thursday-dyp-2026-04-09", "name": "Thursday Night DYP", "format": "single_elim", "tournament_type": "dyp", "played_at": "2026-04-09T23:00:00Z", "players": [ { "ref": "p1", "name": "Alex Río" }, { "ref": "p2", "name": "Dana Okafor" }, { "ref": "p3", "name": "Sam Bell" }, { "ref": "p4", "name": "Kit Nakamura" }, { "ref": "p5", "name": "Jordan Vega" }, { "ref": "p6", "name": "Robin Osei" }, { "ref": "p7", "name": "Casey Lund" }, { "ref": "p8", "name": "Morgan Diaz" } ], "teams": [ { "ref": "t1", "seed": 1, "player_refs": ["p1", "p2"] }, { "ref": "t2", "seed": 2, "player_refs": ["p3", "p4"] }, { "ref": "t3", "seed": 3, "player_refs": ["p5", "p6"] }, { "ref": "t4", "seed": 4, "player_refs": ["p7", "p8"] } ], "matches": [ { "team_refs": ["t1", "t4"], "winner_ref": "t1", "team1_score": 7, "team2_score": 4 }, { "team_refs": ["t2", "t3"], "winner_ref": "t3", "team1_score": 5, "team2_score": 7 }, { "team_refs": ["t1", "t3"], "winner_ref": "t1", "team1_score": 7, "team2_score": 6 } ] } ``` Seeds 1–4 with standard seeding pair 1v4 and 2v3, which is what the match list submits. The final is whoever emerged. ### Double elimination, fixed partners, with a grand-finals reset ```json { "external_ref": "spring-open-2026", "name": "Spring Open 2026", "format": "double_elim", "tournament_type": "bring_partner", "played_at": "2026-04-11T18:00:00Z", "players": [ { "ref": "p1", "name": "Alex Río", "skill_level": "a_player" }, { "ref": "p2", "name": "Dana Okafor", "skill_level": "a_player" }, { "ref": "p3", "name": "Sam Bell", "skill_level": "b_player" }, { "ref": "p4", "name": "Kit Nakamura", "skill_level": "b_player" }, { "ref": "p5", "name": "Jordan Vega" }, { "ref": "p6", "name": "Robin Osei" }, { "ref": "p7", "name": "Casey Lund" }, { "ref": "p8", "name": "Morgan Diaz" } ], "teams": [ { "ref": "t1", "seed": 1, "player_refs": ["p1", "p2"], "name": "Río / Okafor" }, { "ref": "t2", "seed": 2, "player_refs": ["p3", "p4"], "name": "Bell / Nakamura" }, { "ref": "t3", "seed": 3, "player_refs": ["p5", "p6"], "name": "Vega / Osei" }, { "ref": "t4", "seed": 4, "player_refs": ["p7", "p8"], "name": "Lund / Diaz" } ], "matches": [ { "team_refs": ["t1", "t4"], "winner_ref": "t1", "team1_score": 7, "team2_score": 3 }, { "team_refs": ["t2", "t3"], "winner_ref": "t2", "team1_score": 7, "team2_score": 5 }, { "team_refs": ["t1", "t2"], "winner_ref": "t1", "team1_score": 7, "team2_score": 6 }, { "team_refs": ["t4", "t3"], "winner_ref": "t3", "team1_score": 4, "team2_score": 7 }, { "team_refs": ["t2", "t3"], "winner_ref": "t2", "team1_score": 7, "team2_score": 2 }, { "team_refs": ["t1", "t2"], "winner_ref": "t2", "team1_score": 5, "team2_score": 7 }, { "team_refs": ["t1", "t2"], "winner_ref": "t1", "team1_score": 7, "team2_score": 4 } ] } ``` `t1` versus `t2` appears three times, which is the maximum possible and the reason result order is load-bearing. Bell / Nakamura lose the winners final to Río / Okafor, run the losers bracket, and win the grand final — but that only levels the tie, because they carried a loss into it. The reset decides the title, and Río / Okafor take it. Listed in play order, those three results are consumed as winners final, grand final, reset. Reverse any two and you publish the wrong champion. The dry run reports `winner: Río / Okafor`, so you can check it before committing. Had Río / Okafor simply won the grand final, there would be no reset to submit and the payload would stop at six matches. ### Round robin, singles ```json { "external_ref": "monday-singles-league-w14", "name": "Monday Singles — Week 14", "format": "round_robin", "tournament_type": "singles", "played_at": "2026-04-06T23:30:00Z", "players": [ { "ref": "p1", "name": "Alex Río" }, { "ref": "p2", "name": "Dana Okafor" }, { "ref": "p3", "name": "Sam Bell" }, { "ref": "p4", "name": "Kit Nakamura" } ], "teams": [ { "ref": "t1", "player_refs": ["p1"] }, { "ref": "t2", "player_refs": ["p2"] }, { "ref": "t3", "player_refs": ["p3"] }, { "ref": "t4", "player_refs": ["p4"] } ], "matches": [ { "team_refs": ["t1", "t2"], "winner_ref": "t1", "team1_score": 7, "team2_score": 5 }, { "team_refs": ["t1", "t3"], "winner_ref": "t1", "team1_score": 7, "team2_score": 2 }, { "team_refs": ["t1", "t4"], "winner_ref": "t1", "team1_score": 7, "team2_score": 6 }, { "team_refs": ["t2", "t3"], "winner_ref": "t2", "team1_score": 7, "team2_score": 4 }, { "team_refs": ["t2", "t4"], "winner_ref": "t2", "team1_score": 7, "team2_score": 6 }, { "team_refs": ["t3", "t4"], "winner_ref": "t4", "team1_score": 3, "team2_score": 7 } ] } ``` For singles, each team holds exactly one player ref. These results give a clean 3-0 / 2-1 / 1-2 / 0-3 ladder. Where teams finish level, the standings tie-break decides, so avoid reading too much into the order of a tied group. ### Monster, points only, 8 players over 3 rounds ```json { "external_ref": "monster-2026-04-14", "name": "Tuesday Monster", "format": "monster", "tournament_type": "dyp", "played_at": "2026-04-14T23:00:00Z", "settings": { "monster": { "race_to": 7, "allow_ties": false, "win_by_2": false, "advancement": "points_only" } }, "players": [ { "ref": "p1", "name": "Alex Río" }, { "ref": "p2", "name": "Dana Okafor" }, { "ref": "p3", "name": "Sam Bell" }, { "ref": "p4", "name": "Kit Nakamura" }, { "ref": "p5", "name": "Jordan Vega" }, { "ref": "p6", "name": "Robin Osei" }, { "ref": "p7", "name": "Casey Lund" }, { "ref": "p8", "name": "Morgan Diaz" } ], "matches": [ { "bracket": "monster_qualifying", "round": 1, "position": 1, "team1_player_refs": ["p1", "p2"], "team2_player_refs": ["p3", "p4"], "team1_score": 7, "team2_score": 5 }, { "bracket": "monster_qualifying", "round": 1, "position": 2, "team1_player_refs": ["p5", "p6"], "team2_player_refs": ["p7", "p8"], "team1_score": 7, "team2_score": 3 }, { "bracket": "monster_qualifying", "round": 2, "position": 1, "team1_player_refs": ["p1", "p3"], "team2_player_refs": ["p5", "p7"], "team1_score": 7, "team2_score": 4 }, { "bracket": "monster_qualifying", "round": 2, "position": 2, "team1_player_refs": ["p2", "p4"], "team2_player_refs": ["p6", "p8"], "team1_score": 6, "team2_score": 7 }, { "bracket": "monster_qualifying", "round": 3, "position": 1, "team1_player_refs": ["p1", "p5"], "team2_player_refs": ["p2", "p6"], "team1_score": 7, "team2_score": 2 }, { "bracket": "monster_qualifying", "round": 3, "position": 2, "team1_player_refs": ["p3", "p7"], "team2_player_refs": ["p4", "p8"], "team1_score": 1, "team2_score": 7 } ] } ``` No `teams[]` at all, because points-only Monster never forms standing teams. To add a knockout instead, set `advancement` to `top_4`, add a `teams[]` array with the four qualifying teams, and append knockout matches in the elimination shape. --- ## Single match shortcut For a club that only records finals, `POST /matches` builds a minimal completed tournament from one result. `format` defaults to `single_elim` and `tournament_type` to `dyp`, and a single `match` object is accepted in place of a `matches` array. ```json { "external_ref": "house-final-2026-04-18", "name": "House Final", "players": [ { "ref": "p1", "name": "Alex Río" }, { "ref": "p2", "name": "Dana Okafor" }, { "ref": "p3", "name": "Sam Bell" }, { "ref": "p4", "name": "Kit Nakamura" } ], "teams": [ { "ref": "t1", "player_refs": ["p1", "p2"] }, { "ref": "t2", "player_refs": ["p3", "p4"] } ], "match": { "team_refs": ["t1", "t2"], "winner_ref": "t1", "team1_score": 7, "team2_score": 5 } } ``` It runs through the same importer, so it is idempotent by `external_ref` in the same way. --- ## Checklist before you POST - [ ] `external_ref` is stable and reproducible from the club's own system. - [ ] Every `ref` in `teams[]` and `matches[]` exists in `players[]`. - [ ] Every player ref is unique; every team ref is unique. - [ ] Every match has a `winner_ref` that is one of its own `team_refs`. - [ ] No bye matches, no `round`/`position`/`bracket` on elimination matches. - [ ] `teams[]` order reflects real seeding. - [ ] Any pairing that occurs more than once is listed in play order. - [ ] Double elim only: if the losers-bracket team won the grand final, the reset is the next result for that pair. - [ ] You checked the `podium` in the dry-run response and the champion is correct. - [ ] Monster only: every qualifying match has legal scores for the race. - [ ] You ran it with `?dry_run=1` and got `"ok": true`. --- ## Not supported yet - **Swiss format.** Foos App runs Swiss natively, but the importer does not accept it yet. Ask us if you need it. - **In-progress tournaments.** An import must reach a completed state, so every played match needs a result. There is no live/partial feed. - **Editing a single match.** Resubmit the whole tournament with the same `external_ref` instead; it is a full, clean replacement. --- ## How a human gets set up 1. **Ask for a key.** Text or email Foos App and say which club is publishing results. We issue a key for that club and send it to you once — it is stored only as a hash, so it cannot be recovered later, only replaced. 2. **Hand this document to your AI.** Point it at along with a sample export from your system, and ask it to write the mapping. It has everything it needs here. 3. **Dry run it.** Nothing is saved until a dry run passes. 4. **Go live.** Your tournaments appear on the public web and in the apps, with shareable bracket and standings links, exactly like events run inside Foos App. Questions: