---
title: "Model analysis for a match (ULTRA)"
method: GET
path: "/matches/{matchId}/analysis"
---

# Model analysis for a match (ULTRA)

`GET /matches/{matchId}/analysis`

## Request

- Base URL: `https://api.livetennisapi.com/api/public/v1`
- URL: `https://api.livetennisapi.com/api/public/v1/matches/{matchId}/analysis`
- Auth: one of:
  - HTTP bearer
  - API key in header `X-API-Key`

The model's thesis and profile for one match.

COVERAGE IS NOT UNIVERSAL, and a polling client should plan for that. Analysis is produced per match by the model pipeline rather than emitted for every fixture: over the seven days to 2026-08-27, 1,225 of 2,863 matches that went live or completed carried one (42.8%). A match that has none yet returns `404 {"error":"no_analysis"}` (since 2026-09-02; before that the body was a bare `not_found`) — that is the documented absence, not a fault, and it can turn into a 200 later in the same match once the pipeline has run. Never treat this 404 as a reason to retry harder. The body names which absence it is: `not_found` is an id that does not exist; `no_analysis` carries `match_id` and `coverage: "none"` for a real match with nothing computed.

FILTER THE SLATE FIRST. Every row of `GET /matches` and the detail carries `has_analysis` (every tier), the same fact this endpoint answers 404 about — read it there and call only the matches that carry one, instead of spending one 404 per match.

ONE CALL INSTEAD OF THREE. `GET /matches/{matchId}` carries the same thesis and profile in its `analysis` key on ULTRA, alongside `market` and `market_price` on PRO and above, next to the live score. It answers `200` whether or not analysis and a market exist — the keys are `null` instead — so a per-match poll built on the detail route replaces the score, analysis and prices calls with one request and never spends a call on a 404.

## Path parameters

- `matchId` integer, required

## Response `200`

Thesis + profile (either may be null)

- Analysis
  - `thesis` object, nullable
    - `pick_side` 1 | 2
    - `confidence` number, nullable
    - `win_probability_pick` number, nullable
    - `state` 'valid' | 'confirmed' | 'weakened' | 'broken' | 'null', nullable
    - `reasoning` string, nullable
    - `notes` object
      - `matchup` string, nullable
      - `environment` string, nullable
      - `fatigue` string, nullable
    - `scenario_playbook` unknown
    - `created_at` string, date-time, nullable
  - `profile` object, nullable
    - `win_probability_p1` number, nullable
    - `expected_closeness` number, nullable
    - `volatility_rating` 'low' | 'med' | 'high' | 'null', nullable
    - `stage` 'pregame' | 'live' | 'null', nullable: Did this forecast already see the match? null = UNKNOWN, never pregame.
    - `model_version` string, nullable: The prompt-scaffold version this forecast was produced under, so a forecast can be evaluated against the framing that produced it and a scaffold change reads as a regime break rather than unexplained drift. Null on profiles written before the stamp existed.
    - `input_state` object, nullable: For an in-play forecast (stage=live), the score it was actually looking at, so it can be evaluated against the position rather than only excluded. Null on a pregame profile (it saw no score) and null wherever the state could not be established. NOT reconstructed from the score at created_at — that timestamp is written after the model finishes, so it lands later than what the model saw.
      - `capture` 'writer' | 'recovered': writer = stamped as the forecast was produced, from the score row itself (carries live_score_id and stats). recovered = reconstructed from the stored prompt text of that same forecast; the score is exact but there is no live_score_id and no stats.
      - `live_score_id` integer, nullable
      - `total_games` integer, nullable
      - `score` object, nullable: server is the serving SIDE (1 or 2); on doubles that is the team, not the individual.
        - `sets` integer[], nullable
        - `games` unknown
        - `points` unknown
        - `server` integer, nullable
        - `is_tiebreak` boolean, nullable
        - `timestamp` string, date-time, nullable
      - `stats` object, nullable
    - `key_factors` string[], nullable
    - `created_at` string, date-time, nullable

## Other responses

- `401`: Missing, unknown, or disabled credentials
- `403`: Your tier doesn't unlock this endpoint
- `404`: `error: not_found` — no such match id. `error: no_analysis` (with `match_id`, `coverage: "none"`, `detail`) — the match exists and nothing has been computed for it; `has_analysis` on the match list says so without a probe.
- `410`: The match id existed and was merged into another match record (since 2026-09-05). The body is `MatchMerged`, not `Error`: it carries a forwarding address, which is the whole point of answering 410 rather than 404.
- `429`: Rate limit exceeded (Retry-After header present). Three body shapes, told apart by `error` and `scope`: the per-MINUTE limit (`rate_limited`, with `upgrade_url`, `tier` and `price` naming the next tier up); the per-DAY quota (`rate_limited` with `scope: "day"`, `limit_per_day`, and `resets_at` — the absolute ISO instant the daily window resets); and `abuse_throttled` with `retry_at_epoch` — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.

---

[API](https://skmtc.dev/livetennisapi/apis/live-tennis-api.md) · [All operations](https://skmtc.dev/livetennisapi/apis/live-tennis-api/llms.txt) · [OpenAPI document](https://skmtc.dev/livetennisapi/apis/live-tennis-api/revisions/7881947e8e43?raw)
