# Maestro Football Data API Maestro provides a read-only JSON API for canonical football fixtures, leagues, teams, players, and season-specific memberships. The dataset is curated and published by Maestro. Base URL: https://api.maistro.futbol Authentication: none API version: v1 Methods: GET only CORS: enabled for /v1/* ## Important semantics - `id` is Maestro's canonical identifier. Use it in path parameters. - `external_ids.api_football` is an external compatibility identifier. - To resolve that external identifier into a Maestro resource, use `?api_football=` on the resource's collection endpoint. - League/team and player/team relationships are season-specific. Relationship endpoints require a four-digit `season` query parameter. - League `seasons` are provider-managed metadata, newest first. At most one entry has `is_current: true`; there may temporarily be none between seasons or before metadata is imported. - Dataset updates are progressive, so a league may temporarily have an incomplete player directory while an update is running. - Nullable fields are returned as JSON `null`; they are not omitted. - Timestamps are ISO 8601 UTC strings. Cutoff parameters use the canonical JavaScript ISO instant form (for example, `2026-03-08T01:30:00.000Z`). ## Endpoints ### Health `GET /health` Response: `{"status":"ok"}` ### Fixtures `GET /v1/fixtures?league_id=&team_id=&season=&from=&to=&limit=<1-100>&offset=` Returns a paginated fixture collection ordered by kickoff. Every filter is optional; `from` and `to` form an inclusive UTC calendar-date range. `GET /v1/fixtures?api_football=` Returns one fixture resolved by its external compatibility ID. `GET /v1/fixtures/:id` Returns one fixture by Maestro ID. Fixture schedules are refreshed automatically before kickoff and canonical status/scores are refreshed throughout active matches. `last_synced_at` indicates the latest successful source refresh. `GET /v1/fixtures/:id/availability` Returns the latest canonical fixture-availability state and immutable observation metadata. `status` distinguishes the latest attempt as `unknown`, `failed`, `stale`, or `current`, while `report_status` independently identifies retained report freshness. `report` distinguishes a successful empty `no_reported_absences` response from `reported_absences`. Entries preserve raw source type/reason, normalized status, nullable canonical player resolution, and explicit source IDs. A failed refresh can retain the last successful report without presenting it as current. #### Match details Once a fixture has been played (`status.short` of `FT`, `AET`, or `PEN`) its statistics, events, lineups, and player details are ingested. Confirmed lineups are also synced independently shortly before kickoff and during live play. An unavailable resource returns `409 Conflict`; the fixture exists but that resource has not been published or ingested yet. The body says why: ```json {"error":"fixture details are not available yet","reason":"not_played","fixture_id":123,"status":"NS"} ``` `reason` is `not_played` (the match has not finished) or `pending_sync` (finished, ingestion has not completed; retry in a few minutes). An unknown fixture is `404` as elsewhere. Successful statistics, events, and players responses carry `details_synced_at`. A lineups response carries nullable `details_synced_at` plus `lineups_synced_at`, so it can be available before the other match details. Team and player references use the same `{ id, external_ids, name }` shape as elsewhere. `GET /v1/fixtures/:id/statistics` One entry per team with `is_home` and a `statistics` object keyed by Maestro's stat vocabulary: `shots`, `shots_on_target`, `shots_off_target`, `shots_blocked`, `shots_inside_box`, `shots_outside_box`, `corners`, `offsides`, `fouls`, `possession` (percent), `yellow_cards`, `red_cards`, `gk_saves`, `passes`, `passes_accurate`, `pass_pct`, `xg`, `goals_prevented`. Values are numbers or `null`; keys vary by competition and a key the source added that Maestro has not yet named appears under its raw label. `GET /v1/fixtures/:id/events` Chronological match events. `kind` is one of `goal`, `own_goal`, `penalty_goal`, `penalty_missed`, `yellow`, `second_yellow`, `red`, `substitution`, `var_goal_cancelled`, `var_goal_confirmed`, `var_penalty_confirmed`, `var_penalty_cancelled`, `var_card_upgrade`, `var_other`, `other`. `team` is the team credited with the event, so for an `own_goal` the `player` belongs to the other side. For a `substitution`, `player` came off and `related_player` came on; for a goal, `related_player` is the assist when known. `score` is the running score after the event. `minute` can be negative for incidents before kickoff, such as a booking during the warm-up. Penalty shootouts are not represented as events; see the fixture's `score.penalties`. `GET /v1/fixtures/:id/lineups` Available as soon as both confirmed team lineups are published, including before kickoff and during live play. Per team: `formation`, `coach`, nullable player-kit `colors` (`primary` and `number` as six-digit hex), `starters`, and `bench`. Each slot has `player`, `position` (`G`, `D`, `M`, `F`), `shirt_number`, and `grid` (`{ row, col }`, row 1 is the goalkeeper) when the source provides it. `GET /v1/fixtures/:id/players` Per team, every player who appeared with their match `statistics` (see `PlayerMatchStatistics`). Ordered starters first, then by minutes played. `GET /v1/players/:id/matches?season=&limit=<1-100>&offset=` The player's match log, newest first, with fixture context, `team`, `opponent`, `is_home`, the match `score` from the player's perspective, and `statistics`. `season` is optional. Defaults: `limit=50`, `offset=0`. ### Pre-match factual context `GET /v1/teams/:id/form?league_id=&season=&before=&limit=<1-10>` Returns the team's newest-first currently canonical resolved FT/AET/PEN matches whose kickoff is strictly before the cutoff, with canonical fixture, competition, and opponent identities plus bounded-sample and last-five aggregates. The team must be a member of that league season; missing rates remain `null`. `GET /v1/teams/:id/head-to-head?opponent_id=&before=&limit=<1-20>` Returns newest-first currently canonical resolved meetings whose kickoff is strictly before the cutoff across all imported competitions, with canonical fixture, competition, home/away identities, and final scores. Teams must differ. `GET /v1/leagues/:id/strength?season=&as_of=&limit=<7-10>` Returns recent PPG and competition ranks from currently canonical resolved matches whose kickoff is strictly before `as_of`. Teams need seven matches; equal rounded PPG values share a rank. Cup/non-league scopes return `available: false` and a reason rather than zero-filled strength. `before` and `as_of` are kickoff cutoffs, not reconstructive historical snapshots: fixture status and final-score revisions are stored only as their current canonical values. A fixture now resolved can therefore appear for an earlier cutoff if its kickoff precedes it. These endpoints are intended for the live pre-kickoff outlook, where the target and later kickoffs are excluded; they must not be used to claim an as-known-at historical state. ### Leagues `GET /v1/leagues?q=&limit=<1-100>&offset=` Returns a paginated league collection. `q` searches league name and country. Defaults: `limit=50`, `offset=0`. Every league includes its known season metadata, newest first. `GET /v1/leagues?api_football=` Returns one league resolved by its external compatibility ID, not a collection. `GET /v1/leagues/:id` Returns one league by Maestro ID. `GET /v1/leagues/:id/teams?season=` Returns teams belonging to a league in the requested season. Each team includes `membership: { season }`. `GET /v1/leagues/:id/standings?season=` Returns the last successfully synchronized official table grouped by competition group or conference. League and team `id` values are Maestro canonical IDs; provider IDs appear only under `external_ids.api_football`. Aggregate played/W/D/L, goals, points, form, and qualification or relegation `description` are preserved. `last_synced_at` identifies the authoritative table time. A successful provider response with no rows returns `groups: []`; a season never successfully synchronized returns 404. Invalid IDs or seasons return 400. Standings are fetched from API-Football by the Access-protected admin Worker after initial season import and once daily for provider-current seasons. Manual refresh uses `POST /imports/standings` with `{ "league_id": , "season": YYYY }`; `POST /imports/standings/active` bootstraps all current seasons. Durable, time-bucketed workflow IDs prevent duplicate daily provider calls. Unknown provider teams fail synchronization and leave the previous successful table intact. ### Teams `GET /v1/teams?q=&limit=<1-100>&offset=` Returns a paginated team collection. `q` searches name, short name, and code. Defaults: `limit=50`, `offset=0`. `GET /v1/teams?api_football=` Returns one team resolved by its external compatibility ID. `GET /v1/teams/:id` Returns one team by Maestro ID. `GET /v1/teams/:id/players?season=` Returns players belonging to a team in the requested season. Each player includes `membership: { season, position, shirt_number }`. ### Players `GET /v1/players?q=&limit=<1-100>&offset=` Returns a paginated player collection. `q` searches name, first name, and last name. Defaults: `limit=50`, `offset=0`. `GET /v1/players?api_football=` Returns one player resolved by its external compatibility ID. `GET /v1/players?api_football_ids=` Returns a player collection resolved from up to 100 external compatibility IDs. Duplicate IDs are removed, results preserve the requested ID order, and unknown IDs are omitted. This filter cannot be combined with `api_football` or `q`. `GET /v1/players/:id` Returns one player by Maestro ID. `GET /v1/players/:id/matches?season=` Match-by-match statistics for the player. Documented under Fixtures › Match details. `GET /v1/players/:id/teams?season=` Returns the player's known team memberships. Omit `season` to return all known seasons, or provide it to filter the result. The same team may appear once per season. Results are ordered by season descending, then team name. Each team includes `membership: { season, position, shirt_number }`. ### Analytics Season-level aggregates for the tactical questions Maestro exists to answer, served from the same match details. Every analytics endpoint is scoped to one league-season with required `league_id` and `season` query parameters, because opposition class is league-relative. Averages are per match unless stated; `null` means the source had no value for any match in the bucket. Every aggregate carries its match or appearance count so consumers can judge sample size. **Opposition class.** Each team in a league-season is classified `high`, `mid`, or `low` on two axes, `possession` and `xg`, by terciles of the season averages across the league's teams. A team needs at least 5 played matches to be classified; below that its class is `null` and its matches fall into an `unclassified` bucket. Teams with an identical average always share a class. Only matches with a recorded final score count anywhere in analytics. Classes are computed at request time, so early in a season most teams are unclassified and the picture settles as matches accrue. `GET /v1/leagues/:id/leaders?season=` Season leaders grouped into overall, attacking, passing, defending, and goalkeeping categories. Totals and rates are calculated from synced, played fixture player statistics. If a competition-season has a league stage, only rounds named `League Stage …` count; qualifying and playoff rounds are excluded because provider player-stat coverage is incomplete. Rate metrics require at least half of the leading appearance count for the selected scope; pass and save rates also enforce minimum sample sizes. Player and team IDs in leader rows are canonical Maestro IDs; source IDs are explicit under `external_ids.api_football`. `GET /v1/fixtures/:id/attacking-leaders` Returns attacking leaders for both canonical fixture teams in that fixture's league-season. Totals use only canonical `fixture_player_stats` rows attributed to the represented team, so transfer contexts remain separate. The top three are ordered by goal contributions descending, then goals, assists, minutes, and canonical player ID ascending. `coverage` reports eligible completed/detail-synchronised fixtures, those with player-stat rows, freshness, and availability; zero contributions are real values, while no player-stat coverage is unavailable rather than a fabricated zero. `GET /v1/teams/:id/attacking-leaders?league_id=&season=` Returns the same canonical, team-contextual resource after validating the team participated in that fixture league-season. `GET /v1/leagues/:id/profiles?season=` Every team's season profile: `matches`, per-match `averages` (goals_for, goals_against, possession, xg, xga, goals_prevented, shots, shots_on_target, shots_inside_box, shots_outside_box, passes, pass_pct, corners, opp_corners), and `classes: { possession, xg }`. Corner fields use `corners` for the team's own count and `opp_corners` for corners conceded. Profiles and team splits (including overall) expose `totals` and `coverage` objects with those two keys. Coverage is the number of matches recording that counter, independently for each side; averages divide by that count, not all played matches. Zero is recorded data; no recorded values gives a null total/average and zero coverage. Totals cover recorded matches only. Trend rows expose the two nullable per-match counters. `GET /v1/teams/:id/season-summary?league_id=&season=` A canonical-ID season record derived from resolved imported fixtures: overall, home, and away `played`, W/D/L, goals for/against, per-game values, and clean sheets. `coverage.played_fixtures` counts canonical FT/AET/PEN fixtures with both final scores, whether or not details exist; `detailed_fixtures` is the subset whose required detail endpoints synchronized. `latest_kickoff_at`, `latest_detailed_kickoff_at`, and `details_synced_at` make the gap explicit. Awarded, abandoned, cancelled, postponed, and terminal rows without scores are excluded. It is not a provider standings snapshot: zero is a real total and empty scopes use zero totals with null per-game values. `GET /v1/teams/:id/splits?league_id=&season=&by=possession_class|xg_class|formation|venue` The team's record and averages bucketed by the opponent's class, the opponent's formation, or home/away. Each split has W/D/L, `points_per_match`, goals, and `averages` including own and `opp_*` shot profiles, `xga`, `shot_accuracy`, and `xg_minus_goals` (positive means scoring below xG). An `overall` row aggregates every match for comparison. Default `by` is `possession_class`. `GET /v1/teams/:id/trend?league_id=&season=` One row per played match in kickoff order with result, venue, both formations, the team's own and opponent's stats, and the opponent's classes. Intended for time-series and scatter views. `GET /v1/teams/:id/timing?league_id=&season=` Match-state analysis from events: goals for and against, yellow and red cards, and substitutions counted by minute band (`0-15` … `76-90`, plus `ET` for extra time and `pre` for pre-match incidents) and by the score state before the event (`leading`, `level`, `trailing`); the average minute of the first substitution per score state; and `first_goal` records (W/D/L, points per match, goals) for matches where the team scored first versus conceded first. `GET /v1/players/:id/season-summary?season=` A canonical-ID player summary with one context per canonical league/team/season, so transfers and simultaneous competitions are retained. Each context's `summary` is derived from fixture player stats: appearances mean `minutes > 0`, starts use the lineup when available then the provider substitute flag, and rating is minutes-weighted over appearances with a recorded rating. Counting totals, per-90 values, and per-stat coverage retain missing-vs-zero semantics exactly as player splits; no recorded sample is `null`, not zero. Context coverage reports imported played team fixtures and detail freshness; player summary totals cover only the appearances represented in the context. `GET /v1/players/:id/splits?league_id=&season=&by=possession_class|xg_class|starter|venue` The player's appearances bucketed by opponent class, starter/bench, or venue. An appearance means minutes played; unused substitutes are excluded. Each split has appearances, starts, minutes, W/D/L, a minutes-weighted `rating`, `totals` and `per_90` for every counting stat, and `coverage[stat] = { appearances, minutes }`. Missing stats are not zeros: each total and per-90 value uses only appearances recording that stat, including recorded zeros. Per-90 is the total times 90 divided by that stat's covered minutes; no recorded sample yields `null`, not zero. The split's top-level appearances and minutes still describe the whole group. Derived rates (`shot_accuracy`, `pass_completion`, `duel_win_rate`, `dribble_success`, `save_rate`) use only appearances recording both required counters. Their samples can differ from either counter's individual coverage. A missing pair or zero denominator yields `null`; save rate is saves / (saves + goals conceded). `opponent` averages (shots, shots on target, inside/outside box, xg, possession) provide match-level context for what a goalkeeper or defender faced. Goalkeeper fields (`saves`, `goals_conceded`, `save_rate`) are `null` for outfield players. Shot location beyond inside/outside the box is not available from the source. `GET /v1/players/:id/trend?league_id=&season=` One row per appearance in kickoff order with the match context, starter status, the full stat line, and the opponent's stats and classes. ## Response shapes `Fixture` ```typescript { id: number; external_ids: { api_football: number }; league: { id: number; external_ids: { api_football: number }; name: string }; season: number; round: string | null; kickoff_at: string; status: { short: string; long: string; elapsed: number | null }; home: { team: FixtureTeam; score: number | null }; away: { team: FixtureTeam; score: number | null }; score: { halftime: { home: number | null; away: number | null }; fulltime: { home: number | null; away: number | null }; extratime: { home: number | null; away: number | null }; penalties: { home: number | null; away: number | null }; }; last_synced_at: string; created_at: string; updated_at: string; } // FixtureTeam { id: number; external_ids: { api_football: number }; name: string; short_name: string; logo_url: string | null; } ``` `League` ```typescript { id: number; external_ids: { api_football: number }; name: string; kind: string; country: string | null; logo_url: string | null; seasons: { season: number; is_current: boolean; start_date: string; // YYYY-MM-DD end_date: string; // YYYY-MM-DD }[]; // newest first; empty until season metadata has been imported created_at: string; updated_at: string; } ``` `Team` ```typescript { id: number; external_ids: { api_football: number }; name: string; short_name: string; code: string | null; country: string | null; founded: number | null; is_national: boolean; logo_url: string | null; created_at: string; updated_at: string; } ``` `Player` ```typescript { id: number; external_ids: { api_football: number }; name: string; first_name: string | null; last_name: string | null; birth_date: string | null; // YYYY-MM-DD birth_place: string | null; birth_country: string | null; nationality: string | null; height_cm: number | null; weight_kg: number | null; photo_url: string | null; created_at: string; updated_at: string; } ``` `PlayerMatchStatistics` ```typescript { minutes: number | null; shirt_number: number | null; position: string | null; rating: number | null; // source rating, e.g. 7.3 captain: boolean; substitute: boolean | null; // null when the source did not say offsides: number | null; shots: { total: number | null; on_target: number | null }; goals: { scored: number | null; conceded: number | null; assists: number | null; saves: number | null }; passes: { total: number | null; key: number | null; accurate: number | null }; // accurate is a count tackles: { total: number | null; blocks: number | null; interceptions: number | null }; duels: { total: number | null; won: number | null }; dribbles: { attempts: number | null; success: number | null; past: number | null }; fouls: { drawn: number | null; committed: number | null }; cards: { yellow: number | null; red: number | null }; penalties: { won: number | null; conceded: number | null; scored: number | null; missed: number | null; saved: number | null }; } ``` Paginated collections have this envelope: ```typescript { fixtures | leagues | teams | players: Resource[]; pagination: { total: number; limit: number; offset: number }; } ``` Relationship endpoints have these envelopes: ```typescript { league_id: number; season: number; teams: (Team & { membership: { season: number } })[] } { team_id: number; season: number; players: (Player & { membership: { season: number; position: string | null; shirt_number: number | null } })[] } { player_id: number; teams: (Team & { membership: { season: number; position: string | null; shirt_number: number | null } })[] } { player_id: number; matches: PlayerMatch[]; pagination: { total: number; limit: number; offset: number } } ``` Match detail endpoints share a `fixture_id` and reference teams and players as `{ id, external_ids: { api_football }, name }` (teams also carry `short_name`). Statistics, events, and players have `details_synced_at: string`; lineups have `lineups_synced_at: string | null` and `details_synced_at: string | null`. Errors use a consistent JSON envelope: ```json {"error":"human-readable message"} ``` Common statuses: `400` invalid parameters, `404` resource not found, `409` resource exists but match details are not available yet (see Match details), `500` unexpected server error. ## Examples List a league's fixtures for September 2026: `GET https://api.maistro.futbol/v1/fixtures?league_id=1&season=2026&from=2026-09-01&to=2026-09-30` Find Major League Soccer by name: `GET https://api.maistro.futbol/v1/leagues?q=Major%20League%20Soccer` Resolve external team ID 42: `GET https://api.maistro.futbol/v1/teams?api_football=42` List a Maestro team's 2026 player roster: `GET https://api.maistro.futbol/v1/teams/123/players?season=2026` List all known team memberships for a Maestro player: `GET https://api.maistro.futbol/v1/players/456/teams` Match statistics for a finished fixture: `GET https://api.maistro.futbol/v1/fixtures/789/statistics` A player's match log for one season: `GET https://api.maistro.futbol/v1/players/456/matches?season=2026` How a team fares against high-possession opponents: `GET https://api.maistro.futbol/v1/teams/25/splits?league_id=1&season=2026&by=possession_class` A goalkeeper's save rate and the shots they faced, by opponent class: `GET https://api.maistro.futbol/v1/players/289/splits?league_id=1&season=2026&by=possession_class` ## Guidance for agents 1. Search or resolve a resource before constructing relationship URLs. 2. Use the returned Maestro `id` in path parameters; do not place an external compatibility ID in `:id`. 3. Read `pagination.total` and request additional pages by increasing `offset` by `limit`. 4. Always supply `season` when requesting league teams or team players. Player team memberships accept `season` as an optional filter. Analytics endpoints require both `league_id` and `season`. 5. Treat an `unclassified` bucket or a `null` class as "not enough matches yet", not as a category of team. 6. Treat player team memberships as known imported history, not a declaration of a single current team. 7. Treat missing search results as absent from Maestro's current dataset, not proof that the resource does not exist globally.