{
  "openapi": "3.1.0",
  "info": {
    "title": "Chessfolio API",
    "version": "1.0.0",
    "description": "Read-only personal chess data for the authenticated Chessfolio user: stats, rating progress, games (list and single-game detail with moves and review), problem opening lines, the latest weekly report and puzzle activity.\n\n**Auth:** personal access token (PAT), created at chessfolio.io → Settings → API access, sent as `Authorization: Bearer cfp_…`. Tokens are shown once at creation and revocable at any time.\n\n**Scope:** every endpoint returns only the token owner's own data. There are no write endpoints.\n\n**Rate limit:** 120 requests/minute per user, shared with the MCP server at /api/mcp.\n\n**MCP:** these eight personal capabilities are also exposed as MCP tools at `https://chessfolio.io/api/mcp` (streamable HTTP, same bearer token), alongside three token-less tournament tools — 11 in total. See /.well-known/mcp.json.",
    "contact": {
      "url": "https://chessfolio.io/developers"
    }
  },
  "servers": [
    {
      "url": "https://chessfolio.io"
    }
  ],
  "security": [
    {
      "pat": []
    }
  ],
  "components": {
    "securitySchemes": {
      "pat": {
        "type": "http",
        "scheme": "bearer",
        "description": "Personal access token from chessfolio.io → Settings → API access. Format `cfp_` + 43 url-safe chars."
      }
    },
    "responses": {
      "Unauthorised": {
        "description": "Missing, invalid or revoked token.",
        "content": {
          "application/json": {
            "example": {
              "error": "Invalid or revoked token."
            }
          }
        }
      },
      "RateLimited": {
        "description": "More than 120 requests in a minute.",
        "content": {
          "application/json": {
            "example": {
              "error": "Rate limit exceeded — try again in a minute."
            }
          }
        }
      },
      "TournamentRateLimited": {
        "description": "More than 60 requests in a minute from one IP (the public tournament limit).",
        "content": {
          "application/json": {
            "example": {
              "error": "Rate limit exceeded — try again in a minute."
            }
          }
        }
      },
      "TournamentUpstream": {
        "description": "The tournament source could not be read — Chess-Results changed its page layout, or the upstream fetch failed. The parser fails loudly upstream rather than returning half-parsed data; the web layer surfaces a fixed, caller-safe message.",
        "content": {
          "application/json": {
            "example": {
              "error": "Tournament data source is unavailable."
            }
          }
        }
      },
      "TournamentBusy": {
        "description": "The global outbound courtesy cap to Chess-Results was reached. Retryable.",
        "content": {
          "application/json": {
            "example": {
              "error": "Rate limited upstream, try again shortly."
            }
          }
        }
      },
      "TournamentNoPairing": {
        "description": "The Dutch engine found no legal pairing for the requested round — e.g. the round is already fully played, or too few players remain available.",
        "content": {
          "application/json": {
            "example": {
              "error": "No legal pairing could be estimated for this round."
            }
          }
        }
      }
    }
  },
  "paths": {
    "/api/v1/me": {
      "get": {
        "operationId": "getProfile",
        "summary": "Profile & sync status",
        "description": "The token owner's profile: display name, linked platform usernames, each connected source (chess.com / Lichess / ECF) with its status and last-synced time, and total game count. Use this first to learn which sources exist and how fresh the data is — every other endpoint reads the same synced store, so `lastSyncedAt` bounds the freshness of everything.",
        "responses": {
          "200": {
            "description": "The profile.",
            "content": {
              "application/json": {
                "example": {
                  "displayName": "Tim Bland",
                  "email": "tim@example.com",
                  "memberSince": "2026-01-01T00:00:00Z",
                  "usernames": {
                    "chesscom": "timb",
                    "lichess": "timb"
                  },
                  "connections": [
                    {
                      "provider": "chesscom",
                      "status": "active",
                      "lastSyncedAt": "2026-07-20T02:30:00Z",
                      "connectedAt": "2026-06-01T00:00:00Z"
                    }
                  ],
                  "totals": {
                    "games": 23009,
                    "connections": 3
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/me/stats": {
      "get": {
        "operationId": "getChessStats",
        "summary": "Aggregate chess statistics",
        "description": "Aggregate statistics over the user's games for the chosen window — the same compute the chessfolio.io dashboard runs: win/draw/loss split by colour, online vs over-the-board comparison (with per-group accuracy and opponent strength), performance rating, best win / worst defeat and streaks, last-10 form, weekday performance, opponent-strength breakdown, game-length breakdown, top-20 openings, monthly form, and average effective accuracy. `capped: true` means the window exceeded 10,000 games and aggregates cover the most recent 10,000.",
        "parameters": [
          {
            "name": "range",
            "in": "query",
            "required": false,
            "description": "Time window: `30d`, `90d`, `1y` (default) or `all`.",
            "schema": {
              "type": "string",
              "enum": [
                "30d",
                "90d",
                "1y",
                "all"
              ]
            }
          },
          {
            "name": "provider",
            "in": "query",
            "required": false,
            "description": "Comma-separated sources to include: `chesscom`, `lichess`, `ecf` (OTB). Omit for all.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "timeClass",
            "in": "query",
            "required": false,
            "description": "Comma-separated time-control buckets: `bullet`, `blitz`, `rapid`, `standard`. Omit for all.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "opening",
            "in": "query",
            "required": false,
            "description": "Opening-family prefix filter, e.g. `Sicilian`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Aggregate blocks keyed by concern.",
            "content": {
              "application/json": {
                "example": {
                  "filters": {
                    "range": "1y",
                    "providers": null,
                    "timeClasses": null,
                    "opening": null
                  },
                  "window": {
                    "from": "2025-07-21",
                    "to": null
                  },
                  "games": 4664,
                  "capped": false,
                  "totals": {
                    "white": {
                      "win": 1200,
                      "draw": 200,
                      "loss": 900,
                      "total": 2300,
                      "winRate": 52.2
                    },
                    "black": {
                      "win": 1050,
                      "draw": 250,
                      "loss": 1064,
                      "total": 2364,
                      "winRate": 44.4
                    },
                    "overall": {
                      "win": 2250,
                      "draw": 450,
                      "loss": 1964,
                      "total": 4664,
                      "winRate": 48.2
                    }
                  },
                  "accuracy": {
                    "games": 4069,
                    "average": 78.4
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/me/ratings": {
      "get": {
        "operationId": "getRatingProgress",
        "summary": "Rating progress",
        "description": "Rating series per provider and time control (including ECF over-the-board), each with `start`, `end` and `delta` over the window — the same lines the chessfolio.io dashboard chart draws. Honesty rules: series longer than 60 points are evenly downsampled (first and last points always kept) and flagged with `pointsDownsampled: true`; and the window's opening value is seeded from the latest pre-window rating, so a player who last played before the window still enters it at their standing rating and `delta` matches the dashboard instead of measuring from the first in-window snapshot.",
        "parameters": [
          {
            "name": "range",
            "in": "query",
            "required": false,
            "description": "Time window: `30d`, `90d`, `1y` (default) or `all`.",
            "schema": {
              "type": "string",
              "enum": [
                "30d",
                "90d",
                "1y",
                "all"
              ]
            }
          },
          {
            "name": "provider",
            "in": "query",
            "required": false,
            "description": "Comma-separated sources to include: `chesscom`, `lichess`, `ecf` (OTB). Omit for all.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "timeClass",
            "in": "query",
            "required": false,
            "description": "Comma-separated time-control buckets: `bullet`, `blitz`, `rapid`, `standard`. Omit for all.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "One line per provider × time-control combination present in the window.",
            "content": {
              "application/json": {
                "example": {
                  "filters": {
                    "range": "1y",
                    "providers": null,
                    "timeClasses": null,
                    "opening": null
                  },
                  "window": {
                    "from": "2025-07-21",
                    "to": null
                  },
                  "lines": [
                    {
                      "provider": "lichess",
                      "timeClass": "blitz",
                      "points": [
                        {
                          "date": "2025-07-21",
                          "rating": 1493
                        },
                        {
                          "date": "2026-07-18",
                          "rating": 1541
                        }
                      ],
                      "pointsDownsampled": true,
                      "start": 1493,
                      "end": 1541,
                      "delta": 48
                    },
                    {
                      "provider": "ecf",
                      "timeClass": "standard",
                      "points": [
                        {
                          "date": "2025-08-01",
                          "rating": 1612
                        },
                        {
                          "date": "2026-07-01",
                          "rating": 1630
                        }
                      ],
                      "pointsDownsampled": false,
                      "start": 1612,
                      "end": 1630,
                      "delta": 18
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/me/games": {
      "get": {
        "operationId": "listGames",
        "summary": "List games",
        "description": "Paged list of the user's games (50 per page, newest first by default) across chess.com, Lichess, ECF (OTB), manual and PGN imports. Filters match the chessfolio.io games library exactly: provider, result, colour, time class, date range, move-count range, opening prefix, opponent contains, SAN opening-line prefix (`lineMoves` — use a value returned by /api/v1/me/problem-lines to drill into a problem line) and free-text search (`q`). Each game's `accuracy` is the effective accuracy — the platform's own value preferred, chessfolio's engine-review value as fallback — and `accuracySource` says which you are looking at (`platform`, `review` or null when neither exists). A `page` beyond the last page falls back to page 1 rather than erroring.",
        "parameters": [
          {
            "name": "page",
            "in": "query",
            "required": false,
            "description": "1-based page number (50 games per page). Out-of-range values fall back to page 1.",
            "schema": {
              "type": "integer",
              "minimum": 1
            }
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Sort order: `date-desc` (default), `date-asc`, `accuracy-desc`, `accuracy-asc`. Accuracy sorts use effective accuracy.",
            "schema": {
              "type": "string",
              "enum": [
                "date-desc",
                "date-asc",
                "accuracy-desc",
                "accuracy-asc"
              ]
            }
          },
          {
            "name": "provider",
            "in": "query",
            "required": false,
            "description": "Comma-separated sources to include: `chesscom`, `lichess`, `ecf`, `manual`, `pgn`. Omit for all.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "result",
            "in": "query",
            "required": false,
            "description": "Comma-separated results to include: `win`, `draw`, `loss`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "colour",
            "in": "query",
            "required": false,
            "description": "Comma-separated colours the user played: `white`, `black`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "timeClass",
            "in": "query",
            "required": false,
            "description": "Comma-separated RAW time classes (unlike the dashboard's four buckets): `ultraBullet`, `bullet`, `blitz`, `rapid`, `classical`, `daily`, `correspondence`, `standard`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "Inclusive lower date bound, `YYYY-MM-DD` (UTC). An inverted from/to pair drops both bounds.",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "Inclusive upper date bound, `YYYY-MM-DD` (UTC).",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "movesMin",
            "in": "query",
            "required": false,
            "description": "Minimum full-move count (inclusive).",
            "schema": {
              "type": "integer",
              "minimum": 0
            }
          },
          {
            "name": "movesMax",
            "in": "query",
            "required": false,
            "description": "Maximum full-move count (inclusive).",
            "schema": {
              "type": "integer",
              "minimum": 0
            }
          },
          {
            "name": "opening",
            "in": "query",
            "required": false,
            "description": "Opening-name prefix filter, e.g. `Sicilian` matches the family and all its variations.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "opponent",
            "in": "query",
            "required": false,
            "description": "Opponent-name contains filter (case-insensitive).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "lineMoves",
            "in": "query",
            "required": false,
            "description": "Space-separated SAN opening-line prefix, exactly as returned in a problem line's `lineMoves` (case-sensitive — SAN casing is meaningful).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "q",
            "in": "query",
            "required": false,
            "description": "Free-text search over opponent, opening, event and ECO code.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "One page of games plus paging info and the parsed-filter echo.",
            "content": {
              "application/json": {
                "example": {
                  "filters": {
                    "q": null,
                    "providers": null,
                    "results": null,
                    "colours": null,
                    "timeClasses": null,
                    "from": null,
                    "to": null,
                    "movesMin": null,
                    "movesMax": null,
                    "opening": null,
                    "opponent": null,
                    "lineMoves": null,
                    "sort": "date-desc"
                  },
                  "page": 1,
                  "pageCount": 94,
                  "total": 4664,
                  "pageSize": 50,
                  "games": [
                    {
                      "id": "6a3b0c9e-…",
                      "provider": "lichess",
                      "playedAt": "2026-07-18T19:42:00Z",
                      "colour": "white",
                      "result": "win",
                      "opponent": "opponent42",
                      "opponentRating": 1480,
                      "userRating": 1502,
                      "ratingDelta": 8,
                      "timeClass": "blitz",
                      "rated": true,
                      "eco": "B01",
                      "opening": "Scandinavian Defense",
                      "event": null,
                      "movesCount": 41,
                      "accuracy": 84.2,
                      "accuracySource": "platform",
                      "sourceUrl": "https://lichess.org/abcd1234"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/me/games/{id}": {
      "get": {
        "operationId": "getGame",
        "summary": "Game detail",
        "description": "One of the token owner's games in full: the same summary fields as a /api/v1/me/games row, plus the game's `moves` (SAN mainline), the raw `pgn` where one is stored, and a `review` summary where a Chessfolio analysis exists. `movesSource` is honest about what the moves are: `pgn` when the full game is stored (reviewed or PGN-attached games), `opening-only` when only the recorded opening line is known, or `null` for an OTB/online game that has no attached PGN — that row carries no moves at all and none are fabricated. `review` (null unless analysed) trims the heavy per-ply data: it carries `accuracyWhite`/`accuracyBlack`/`accuracyForUser`, the `criticalMoments` list and a `classificationCounts` histogram, but not `moveEvals`. `id` is a game id exactly as returned by /api/v1/me/games. Owner-scoped: a game id that is not yours, or does not exist, both return 404 identically.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Game id, exactly as returned in a /api/v1/me/games row's `id`.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The game's summary, moves and (when analysed) review summary.",
            "content": {
              "application/json": {
                "example": {
                  "id": "6a3b0c9e-…",
                  "provider": "lichess",
                  "playedAt": "2026-07-18T19:42:00Z",
                  "colour": "white",
                  "result": "win",
                  "opponent": "opponent42",
                  "opponentRating": 1480,
                  "userRating": 1502,
                  "ratingDelta": 8,
                  "timeClass": "blitz",
                  "rated": true,
                  "eco": "B01",
                  "opening": "Scandinavian Defense",
                  "event": null,
                  "movesCount": 41,
                  "accuracy": 84.2,
                  "accuracySource": "platform",
                  "sourceUrl": "https://lichess.org/abcd1234",
                  "moves": [
                    "e4",
                    "d5",
                    "exd5",
                    "Qxd5",
                    "Nc3",
                    "Qa5"
                  ],
                  "movesSource": "pgn",
                  "pgn": "[Event \"Rated Blitz game\"]\n\n1. e4 d5 2. exd5 Qxd5 3. Nc3 Qa5 …",
                  "review": {
                    "accuracyWhite": 84.2,
                    "accuracyBlack": 79.1,
                    "accuracyForUser": 84.2,
                    "depth": 18,
                    "engineVersion": "sf16",
                    "analysedAt": "2026-07-19T09:00:00Z",
                    "classificationCounts": {
                      "book": 6,
                      "brilliant": 0,
                      "best": 18,
                      "great": 1,
                      "good": 9,
                      "inaccuracy": 3,
                      "mistake": 1,
                      "miss": 0,
                      "blunder": 1
                    },
                    "criticalMoments": [
                      {
                        "ply": 42,
                        "move": "Qxf2",
                        "type": "blunder",
                        "evalSwing": 320,
                        "description": "Drops the queen to a fork."
                      }
                    ]
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "404": {
            "description": "No game with that id belongs to the token owner (a non-owned id and an unknown id are indistinguishable).",
            "content": {
              "application/json": {
                "example": {
                  "error": "Game not found."
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/me/problem-lines": {
      "get": {
        "operationId": "getProblemLines",
        "summary": "Problem opening lines",
        "description": "The 'lines that keep hurting': per-colour opening lines (6–24 plies) where the user's score sits at least 8 percentage points (`thresholds.minDropPct`) below their own colour baseline over at least 5 games (`thresholds.minGames`), ranked by a struggle index of (drop × log2 of games) and capped at 8 lines across both colours. Each line carries its SAN prefix as `lineMoves` for drill-through into /api/v1/me/games, plus a ready-made `gamesUrl`. Honesty rules: OTB (ECF) games carry no move lists and are excluded from line analysis, and `hasLineData` says whether ANY analysable games exist in the window — an empty `lines` array with `hasLineData: true` genuinely means no line clears the thresholds.",
        "parameters": [
          {
            "name": "range",
            "in": "query",
            "required": false,
            "description": "Time window: `30d`, `90d`, `1y` (default) or `all`.",
            "schema": {
              "type": "string",
              "enum": [
                "30d",
                "90d",
                "1y",
                "all"
              ]
            }
          },
          {
            "name": "provider",
            "in": "query",
            "required": false,
            "description": "Comma-separated sources to include: `chesscom`, `lichess`, `ecf` (OTB). Omit for all.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "timeClass",
            "in": "query",
            "required": false,
            "description": "Comma-separated time-control buckets: `bullet`, `blitz`, `rapid`, `standard`. Omit for all.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Qualifying problem lines, worst first (may be empty).",
            "content": {
              "application/json": {
                "example": {
                  "filters": {
                    "range": "1y",
                    "providers": null,
                    "timeClasses": null,
                    "opening": null
                  },
                  "hasLineData": true,
                  "thresholds": {
                    "minGames": 5,
                    "minDropPct": 8,
                    "cap": 8
                  },
                  "note": "OTB (ECF) games carry no move lists, so they are excluded from line analysis.",
                  "lines": [
                    {
                      "colour": "black",
                      "label": "Scandinavian Defense",
                      "moves": "1.e4 d5 2.exd5 Qxd5 3.Nc3 Qa5",
                      "lineMoves": "e4 d5 exd5 Qxd5 Nc3 Qa5",
                      "games": 12,
                      "wins": 3,
                      "draws": 2,
                      "losses": 7,
                      "scorePct": 33.3,
                      "colourBaselinePct": 48.9,
                      "struggleIndex": 55.9,
                      "gamesUrl": "https://chessfolio.io/games?lineMoves=e4%20d5%20exd5%20Qxd5%20Nc3%20Qa5&colour=black"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/me/report/latest": {
      "get": {
        "operationId": "getLatestReport",
        "summary": "Latest weekly report",
        "description": "The user's most recent Friday weekly report as its FROZEN payload: per-source sections (chess.com / Lichess / OTB) with games, rating movement, best win and toughest defeat, plus puzzles — exactly what the email and the public share page render, never recomputed after sending. `shareUrl` links the public share page (`/r/<slug>`), shareable without a token. Responds 404 when no report exists yet: reports generate on Friday mornings, and only for weeks with activity.",
        "responses": {
          "200": {
            "description": "The latest report wrapper: week window, sent time, share URL and the frozen payload.",
            "content": {
              "application/json": {
                "example": {
                  "weekStart": "2026-07-13",
                  "weekEnd": "2026-07-19",
                  "sentAt": "2026-07-17T09:00:00Z",
                  "shareUrl": "https://chessfolio.io/r/abc123def456",
                  "report": {
                    "note": "frozen weekly-report payload, exactly as emailed and rendered at shareUrl"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "404": {
            "description": "No weekly report exists for this user yet.",
            "content": {
              "application/json": {
                "example": {
                  "error": "No weekly report yet — reports generate on Fridays for weeks with activity."
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/me/puzzles": {
      "get": {
        "operationId": "getPuzzleStats",
        "summary": "Puzzle statistics",
        "description": "Cross-source puzzle activity and ratings: a 12-week solve summary with overall win rate, solve volume bucketed to suit the window (day for `30d`/`90d`, week for `1y`, month for `all` — `volume.bucket` says which), and rating series for Lichess puzzles and chess.com tactics. Rating series longer than 120 rows are evenly downsampled (first and last rows always kept) and flagged with `ratings.dataDownsampled: true`. Honesty rule: the chess.com tactics line is a PEAK-only rating — their public API exposes no current value — and its series label says so. Puzzle data responds to the `range` window only; game-source filters do not apply here.",
        "parameters": [
          {
            "name": "range",
            "in": "query",
            "required": false,
            "description": "Time window: `30d`, `90d`, `1y` (default) or `all`.",
            "schema": {
              "type": "string",
              "enum": [
                "30d",
                "90d",
                "1y",
                "all"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Activity summary, bucketed volume and puzzle-rating series.",
            "content": {
              "application/json": {
                "example": {
                  "range": "1y",
                  "activity": {
                    "solved": 412,
                    "winRate": 74.3,
                    "weekly": [
                      {
                        "weekStart": "2026-07-13",
                        "solved": 18
                      }
                    ]
                  },
                  "volume": {
                    "bucket": "week",
                    "points": [
                      {
                        "bucketStart": "2026-07-16",
                        "solved": 18
                      }
                    ]
                  },
                  "ratings": {
                    "data": [
                      {
                        "dateTs": 1752710400000,
                        "lichess": 1873,
                        "chesscom": 2011
                      }
                    ],
                    "series": [
                      {
                        "key": "lichess",
                        "label": "Lichess puzzles",
                        "colour": "#21e6c1"
                      },
                      {
                        "key": "chesscom",
                        "label": "Chess.com tactics (peak)",
                        "colour": "#9d4eff"
                      }
                    ],
                    "omitted": 0,
                    "dataDownsampled": false
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/tournaments/{tnr}/state": {
      "get": {
        "operationId": "getTournamentState",
        "summary": "Tournament state (public, no token)",
        "description": "PUBLIC, read-only snapshot of a Chess-Results tournament — no token required (source: chess-results.com). This is scraped public data with no personal scope, so unlike every /api/v1/me endpoint it carries no auth. Returns the seeded player list, published round pairings with results, current standings and any not-paired / requested-bye / withdrawal notes, plus a `snapshotAt` timestamp. Honesty rules: `snapshotAt` is the fetch time and the data MAY BE STALE (short-TTL cached upstream, not a live feed) — always read it; and if Chess-Results changes its page layout the endpoint fails loudly with 502 rather than returning half-parsed rows.",
        "security": [],
        "parameters": [
          {
            "name": "tnr",
            "in": "path",
            "required": true,
            "description": "Chess-Results tournament number — digits only, e.g. `651260`.",
            "schema": {
              "type": "string",
              "pattern": "^[0-9]+$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The tournament snapshot.",
            "content": {
              "application/json": {
                "example": {
                  "tnr": "651260",
                  "source": "chess-results.com",
                  "snapshotAt": "2026-07-21T10:00:00.000Z",
                  "name": "2022 Solihull Junior Open Under 11 Group A",
                  "seeds": [
                    {
                      "seedNo": 1,
                      "name": "He Tom Junde",
                      "rating": 1707,
                      "club": "St Mary's Harborne"
                    }
                  ],
                  "roundsPublished": 1,
                  "pairings": {
                    "1": [
                      {
                        "board": 1,
                        "white": {
                          "seedNo": 7,
                          "name": "Sagyaman Vassily M",
                          "rating": 1403
                        },
                        "black": {
                          "seedNo": 1,
                          "name": "He Tom Junde",
                          "rating": 1707
                        },
                        "result": "0 - 1"
                      }
                    ]
                  },
                  "standings": [
                    {
                      "rank": 1,
                      "seedNo": 2,
                      "name": "…",
                      "rating": 1661,
                      "club": "…",
                      "points": 4.5
                    }
                  ],
                  "notPaired": []
                }
              }
            }
          },
          "400": {
            "description": "`tnr` is not a bare number.",
            "content": {
              "application/json": {
                "example": {
                  "error": "Tournament number must be digits, e.g. 651260."
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TournamentRateLimited"
          },
          "502": {
            "$ref": "#/components/responses/TournamentUpstream"
          },
          "503": {
            "$ref": "#/components/responses/TournamentBusy"
          }
        }
      }
    },
    "/api/v1/tournaments/{tnr}/round1": {
      "get": {
        "operationId": "estimateTournamentRound1",
        "summary": "Estimate Round-1 pairings (public, no token)",
        "description": "PUBLIC, read-only ESTIMATE of Round-1 pairings for a Chess-Results tournament — no token required (source: chess-results.com). Honesty rules, which are contract not decoration: this is an ESTIMATE derived from the seed list, NOT the official pairing (the arbiter's real draw can differ), so `isEstimate` is always true; the opponent estimate is more reliable than colour, because Round-1 colours hinge on the initial-colour draw (see `colourNote`), so treat the colour as a coin-flip; and `staleWarning` is set when the underlying snapshot is more than a day old. Optional `target` returns just one player's board in `targetPairing`.",
        "security": [],
        "parameters": [
          {
            "name": "tnr",
            "in": "path",
            "required": true,
            "description": "Chess-Results tournament number — digits only, e.g. `651260`.",
            "schema": {
              "type": "string",
              "pattern": "^[0-9]+$"
            }
          },
          {
            "name": "target",
            "in": "query",
            "required": false,
            "description": "Player name (case-insensitive) to single out — their board is returned in `targetPairing`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The Round-1 estimate.",
            "content": {
              "application/json": {
                "example": {
                  "section": null,
                  "listDate": null,
                  "generatedAtUtc": "2026-07-21T10:00:00.000Z",
                  "snapshotAgeDays": 0,
                  "staleWarning": null,
                  "totalPlayers": 12,
                  "requestedByes": [],
                  "forcedBye": null,
                  "activePairedCount": 12,
                  "pairings": [
                    {
                      "top": {
                        "seedNo": 1,
                        "name": "He Tom Junde",
                        "rating": 1707,
                        "club": "St Mary's Harborne"
                      },
                      "bottom": {
                        "seedNo": 7,
                        "name": "Sagyaman Vassily M",
                        "rating": 1403,
                        "club": "…"
                      }
                    }
                  ],
                  "targetPairing": null,
                  "colourNote": "Round 1 colours depend on the initial-colour draw; the opponent estimate is more reliable than colour.",
                  "isEstimate": true
                }
              }
            }
          },
          "400": {
            "description": "`tnr` is not a bare number.",
            "content": {
              "application/json": {
                "example": {
                  "error": "Tournament number must be digits, e.g. 651260."
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TournamentRateLimited"
          },
          "502": {
            "$ref": "#/components/responses/TournamentUpstream"
          },
          "503": {
            "$ref": "#/components/responses/TournamentBusy"
          }
        }
      }
    },
    "/api/v1/tournaments/{tnr}/pairings": {
      "get": {
        "operationId": "estimateTournamentPairings",
        "summary": "Estimate next-round Swiss pairings (public, no token)",
        "description": "PUBLIC, read-only ESTIMATE of the next round's pairings for a Chess-Results Swiss tournament — no token required (source: chess-results.com). Honesty rules, which are contract not decoration: it runs the REAL FIDE Dutch pairing engine (bbpPairings) over the live standings, but it is an ESTIMATE, NOT the official pairing — `isEstimate` is always true — because the arbiter's Swiss-Manager draw can legitimately differ (accelerated pairings, custom settings, manual corrections). The older manual seeded-Swiss method is a teaching aid, not the target. Colours follow each player's prior-round colour history (see `colourNote`). `confidence` is a `high`/`medium`/`low` tier for how firm the estimate is and `assumptions` lists what the engine took as given. `round` picks which round to estimate (defaults to the next unplayed round); `target` spotlights one player by case-insensitive name, returning their board in `targetBoard` plus a what-if `scenarios` table. The underlying snapshot may be stale — read `snapshotAt`.",
        "security": [],
        "parameters": [
          {
            "name": "tnr",
            "in": "path",
            "required": true,
            "description": "Chess-Results tournament number — digits only, e.g. `651260`.",
            "schema": {
              "type": "string",
              "pattern": "^[0-9]+$"
            }
          },
          {
            "name": "round",
            "in": "query",
            "required": false,
            "description": "Which round to estimate. Defaults to the next unplayed round.",
            "schema": {
              "type": "integer",
              "minimum": 1
            }
          },
          {
            "name": "target",
            "in": "query",
            "required": false,
            "description": "Player name (case-insensitive) to single out — their board is returned in `targetBoard`, with a what-if `scenarios` table.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The next-round pairing estimate.",
            "content": {
              "application/json": {
                "example": {
                  "source": "chess-results.com",
                  "tnr": "651260",
                  "snapshotAt": "2026-07-21T10:00:00.000Z",
                  "roundToPair": 4,
                  "isEstimate": true,
                  "boards": [
                    {
                      "board": 1,
                      "white": {
                        "seedNo": 2,
                        "name": "Wesson Alexander",
                        "rating": 1661,
                        "club": "Camberley"
                      },
                      "black": {
                        "seedNo": 5,
                        "name": "He Tom Junde",
                        "rating": 1707,
                        "club": "St Mary's Harborne"
                      },
                      "pairingReason": "Both on 3/3; the higher half is due White on colour history."
                    }
                  ],
                  "targetBoard": {
                    "board": 1,
                    "white": {
                      "seedNo": 2,
                      "name": "Wesson Alexander",
                      "rating": 1661,
                      "club": "Camberley"
                    },
                    "black": {
                      "seedNo": 5,
                      "name": "He Tom Junde",
                      "rating": 1707,
                      "club": "St Mary's Harborne"
                    },
                    "pairingReason": "Both on 3/3; the higher half is due White on colour history."
                  },
                  "assumptions": [
                    "Standings read from the latest published round (3 of 5).",
                    "No accelerated pairings; default FIDE Dutch settings."
                  ],
                  "confidence": "medium",
                  "colourNote": "Colours follow prior-round history; an odd colour balance in a score group can still flip a board.",
                  "scenarios": {
                    "target": "He Tom Junde",
                    "rows": [
                      {
                        "scenario": "Wins on board 1",
                        "predictedOpponent": "Sagyaman Vassily M",
                        "colour": "white"
                      },
                      {
                        "scenario": "Draws",
                        "predictedOpponent": "Patel Rian",
                        "colour": "black"
                      }
                    ],
                    "paritySensitive": true
                  }
                }
              }
            }
          },
          "400": {
            "description": "`tnr` is not a bare number.",
            "content": {
              "application/json": {
                "example": {
                  "error": "Tournament number must be digits, e.g. 651260."
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/TournamentNoPairing"
          },
          "429": {
            "$ref": "#/components/responses/TournamentRateLimited"
          },
          "502": {
            "$ref": "#/components/responses/TournamentUpstream"
          },
          "503": {
            "$ref": "#/components/responses/TournamentBusy"
          }
        }
      }
    }
  }
}