---
title: "Reconstructed 2013–2022 point-by-point tape for one archive result (ULTRA, or any History plan)"
method: GET
path: "/history/archive/matches/{archiveId}/tape"
---

# Reconstructed 2013–2022 point-by-point tape for one archive result (ULTRA, or any History plan)

`GET /history/archive/matches/{archiveId}/tape`

## Request

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

The score sequence behind a 2013–2022 archive result, REBUILT FROM THE PUBLIC RECORD. The result tells you what happened; this tells you how.

NOBODY WATCHED THESE MATCHES, and every field that would imply otherwise is null. `timestamp` is null on every row — not by convention, but because the production table has no timestamp column at all, so no repair, backfill or re-archive can put a wall clock on a 2016 row, and the promotion script refuses to run if such a column ever appears. `win_probability_p1` and `danger` are null on every row: the model was not running in 2016 and nothing has been back-computed and served as if it had been (`meta.model_rows` and `meta.observed_rows` are both 0, always). `profiles` is always `[]`. The rule we publish for the 2023+ tape — a non-null `timestamp` means we watched that point live — holds across both halves of the product.

YEARS: 2013–2022 ONLY. 97,901 matches and 14,340,663 rows. The results archive holds a further 977,903 matches from 1968–2012 and NONE of them has a tape — there is no public point-by-point record of those years to rebuild and we do not manufacture one. A match with no tape answers 404, which is also the answer when a tape exists and failed the proof below; the two are deliberately not distinguished.

COVERAGE OF THE ERA, thin spots beside the strong ones: 19.3% of archive matches played 2013–2022 overall, 44.9% of tour-level play. Main-draw tour buckets run 91.6–98.7% (ATP Masters 98.7%, ATP slam 98.0%, WTA Premier Mandatory 97.9%, WTA slam 97.4%, ATP 250–500 95.4%, WTA Premier 94.1%, WTA International 91.6%); ATP Challenger main draws 55.3% and Challenger qualifying 33.6%; slam QUALIFYING is thin at 16.0% (ATP) and 18.1% (WTA); ITF and futures are effectively absent — 25 of 116,575 ATP futures matches, 68 of 19,162 ITF M15, 48 of 9,380 ITF M25 — and this product does not cover them. If your work is ITF, this is the wrong dataset and we would rather you knew now. 31.0% of the corpus is qualifying-draw play, which is unusual at this scale and is the part you are least likely to hold already.

HOW A TAPE IS BOUND TO ITS MATCH. A tape attached to the wrong match is silent, permanent and poisons anything trained on it, so each one proves five things before publication: the EDITION binds by majority vote onto one archive tournament; the NAMES resolve to exactly one archive row with an unambiguous orientation; the ROUND agrees through an explicit map (this is what separates the qualifying and main-draw meetings of the same pair inside one edition); the SET SPINE derived from the rows equals the published final score exactly (a retirement matches on a one-directional prefix rule — a tape may stop short, never run past the printed score); and the vendor DATE lies inside the tournament's window. The tape is then walked step by step against 23 interior invariants — games and sets never regress or jump, a closed set holds a score a set can actually finish on, a tiebreak is flagged only where one can be played, points never run backwards inside a game, `seq` is contiguous. A tape that fails any clause or any invariant is REFUSED WHOLESALE rather than published against a guess, which is why this endpoint answers 404 instead of serving a probable match.

`archiveId` is an `archive_matches.id` (the `id` on a `/history/archive/matches` row), NOT a match id — the two are separate, overlapping integer spaces, so passing one to the other's routes resolves a different, real record without erroring. The response names it `meta.archive_match_id` for exactly that reason. Rows are WINNER-FIRST (`sets[0]` / `games[0]` / `points[0]` are the winner's, `server: 1` means the winner was serving), unlike the p1/p2-shaped 2023+ tape — a live match has no winner while it is being played.

This operation takes NO query parameters: `sequence` and `points` are ignored rather than refused. `meta.sequence` is always `raw` — a reconstructed tape has one source and no corrections, so there is nothing for a clean collapse to remove.

Tier: core ULTRA, or any active History plan (Starter included, which opens it on a FREE core key). The archive RESULT itself (`GET /history/archive/matches/{archiveId}`) stays on BASIC — core BASIC and core PRO read the result and are refused the tape, and the refusal is `403 upgrade_required` carrying `capability: archive_tape`. The per-year BULK files (`?kind=archive_tape`) are a separate gate: core ULTRA, or a History Pro/Business subscription, or an active one-off package window.

## Path parameters

- `archiveId` integer, required

## Response `200`

The reconstructed tape (`match` + `tape` + `tiebreaks` + an always-empty `profiles` + provenance `meta`)

- ArchiveTape: The reconstructed 2013-2022 point-by-point tape for one archive result. Same envelope as HistoryTape so one parser reads both halves of the tape product, with the differences that are TRUE: `match` is the archive result row (winner/loser-shaped), `profiles` is always empty, and every row carries a null `timestamp`, a null `age_seconds` and null model fields because nobody watched these matches and no model scored them. `meta` has no `match_id` — an archive id is not a match id.
  - `match` ArchiveMatch: One result of the results archive (1968–2022). Winner/loser-shaped — results data is recorded that way at the source, so the winner is a field, never an inference. Its own id space; `source_id` is the stable corpus key. A result played 2013–2022 may additionally carry a reconstructed point-by-point tape (`ArchiveTape`); results from 1968–2012 have none and never will.
    - `id` integer
    - `source_id` string
    - `tour` 'atp' | 'wta'
    - `level` string, nullable: Source tier code (G/M/A/F/D/C/O, or a futures category code as published).
    - `tournament` string, nullable
    - `surface` string, nullable
    - `draw_size` integer, nullable
    - `event_date` string, date, nullable: Tournament START date — per-match dates do not exist in this era's records, and we do not invent them.
    - `round` string, nullable
    - `best_of` integer, nullable
    - `minutes` integer, nullable
    - `winner` ArchivePlayer: One participant of an archive result, as the corpus records them.
      - `name` string, nullable
      - `hand` string, nullable
      - `country` string, nullable: 3-letter code, same vocabulary as `player.country`.
      - `rank` integer, nullable: The player's rank AT THE TIME of the match, as published.
      - `seed` integer, nullable
      - `player_id` integer, nullable: The corpus person id — joins `/history/archive/players` within the same tour. NOT a roster player id.
      - `height_cm` integer, nullable
      - `age` number, nullable: Age at the time of the match, as the corpus records it.
      - `entry` string, nullable: Draw entry where recorded (WC, Q, LL, PR, SE, ...) — null for direct acceptances.
    - `loser` ArchivePlayer: One participant of an archive result, as the corpus records them.
      - `name` string, nullable
      - `hand` string, nullable
      - `country` string, nullable: 3-letter code, same vocabulary as `player.country`.
      - `rank` integer, nullable: The player's rank AT THE TIME of the match, as published.
      - `seed` integer, nullable
      - `player_id` integer, nullable: The corpus person id — joins `/history/archive/players` within the same tour. NOT a roster player id.
      - `height_cm` integer, nullable
      - `age` number, nullable: Age at the time of the match, as the corpus records it.
      - `entry` string, nullable: Draw entry where recorded (WC, Q, LL, PR, SE, ...) — null for direct acceptances.
    - `score` string, nullable: The final score as published, e.g. "6-4 7-6(5)", "6-3 RET", "W/O".
    - `outcome` 'completed' | 'retired' | 'walkover' | 'default' | 'abandoned' | 'null', nullable: Parsed from the score's own vocabulary; null when unparseable — never guessed.
    - `stats` object, nullable: Detail endpoint only. {"winner":{...}, "loser":{...}} with aces, double_faults, serve_points, first_in, first_won, second_won, serve_games, bp_saved, bp_faced where the source recorded them; null otherwise (most rows before 1991) — never synthesised.
  - `tape` HistoryTapeRow[]: The score sequence in seq order, WINNER-FIRST (`sets[0]` / `games[0]` / `points[0]` are the winner's, and a `server` of 1 means the winner was serving) — see HistoryTapeRow. `point_winner` and `winner` (since 2026-09-22) are present; on a per-GAME tape, where consecutive rows differ by a whole game, each reads the side whose game count rose (the game point was theirs) and says nothing about the other points of that game.
    - `origin` 'observed' | 'reconstructed': `observed` — a row our own pipeline committed while the point was being played; `reconstructed` — a row expanded after the fact from a finished-match point-by-point record (null timestamp, null model fields). From the stored provenance, never inferred from a null timestamp. Added 2026-09-20.
    - `serve` 1 | 2 | null, nullable: The serve the point was played on (1 first, 2 second), joined from this match's per-point stream: paired by EXACT score state — set count, every set's games, in-game points and tiebreak flag all equal — and, where a state recurs (a deuce cycle), by nearest clock within 90 seconds (the tape row's observed clock against the stream point's), each stream point used at most once; otherwise null. Observed rows only: null on every reconstructed row, on a row no stream point matches, and on a recurring state with no clock within the window or two at the same distance. Never inferred from the score or the pattern of play. Added 2026-09-20; clock pairing 2026-09-21.
    - `outcome` 'ace' | 'double_fault' | 'winner' | 'forced_error' | 'unforced_error' | 'null', nullable: How the point ended, as an outside source states it — same join rule and same nulls as `serve`. `meta.enrichment` says what landed on this tape. Added 2026-09-20.
    - `sets` integer[]
    - `games` array[]
      - integer[]
    - `points` string[]: In-game points as tennis strings ("0", "15", "30", "40", "AD") — EXCEPT during a tiebreak, where they are the running tiebreak count as plain integer strings ("0", "1", "2", …); `is_tiebreak` tells the two notations apart.
    - `server` 1 | 2 | null, nullable: Who is serving — null where the feed never asserted it. On `?sequence=clean` the server is SETTLED per non-tiebreak game (the server is physically constant within a game, so the game's majority assertion fills nulls and corrects transient disagreements; a tie settles nothing). The raw sequence carries every row exactly as committed, so a transient wrong-server read can appear there and be corrected by a later row. TIEBREAK ROWS ARE NEVER SETTLED, on either sequence: the server genuinely changes during a breaker, so there is no constant to take a majority of, and the field is the source's own assertion carried through. Two consequences, both reported by a Basic customer on 2026-09-25 and confirmed in the code. The rotation cannot be reconstructed from the rows served wherever the tape is sampled — a breaker whose rows skip points gives you the server of a state, not of a numbered point, and `meta.points.transitions_legal` against `transitions_total` tells you whether that happened. And on the set roll-up row that carries `tiebreak_final`, `server` is the value of that committed set-end state: it is NOT a preserved copy of the final breaker point's server, and we make no claim that it is. Withhold server-dependent tiebreak analysis unless the encoding reconciles on the match in front of you.
    - `is_tiebreak` boolean
    - `tiebreak_final` integer[], nullable: Added 2026-09-24. The breaker's closing score in our player order, e.g. `[2, 7]`, on the row that CLOSES a tiebreak set. Absent on every other row, including the breaker rows themselves, which stay exactly as they were observed. Same value, from the same resolver, as this set's entry in the response's `tiebreaks` array, so the two never disagree. The row that closes a set carries the decided games before the set counter catches up, and since 2026-09-24 the games on the row decide it, so the field does not depend on which row the counter moves on. Before that a set whose counter lagged by a row was never stamped and stayed empty on every later read, while `tiebreaks` stated the final.
    - `win_probability_p1` number, nullable: The model read stamped on this row while the match was live. The tape carries NO model-regime stamp — there is no `model_version` or `generated_at` column here, nor in the bulk `tape` packages — so segregate a recorded tape corpus by this row's `timestamp` against the two regime boundaries documented on `Score.win_probability_meta`: 2026-08-18T08:29:28Z and 2026-09-16T07:59:54Z.
    - `danger` number, nullable
    - `timestamp` string, date-time, nullable
    - `accepted_at` string, date-time, nullable: On tape rows since 2026-09-24. The instant our arbiter accepted this state (UTC), stamped once at insert and never refreshed by a re-assertion. This is the clock to difference a timing study against: `timestamp` starts equal to it and is then refreshed while the owning source keeps re-asserting the unchanged state, so on most raw rows `timestamp` is a later re-assertion instant (measured on match 189679: 164 of its 171 observed rows carry a `timestamp` more than a second after `accepted_at`). Null on every reconstructed row, which never had a clock. IT ALSO HAS A START DATE ON THE ARCHIVED TAPE, and this description claimed the opposite ("it rides the whole observed corpus") until 2026-10-01. THAT DATE MOVED BACK SIX WEEKS ON 2026-10-01. A tape answered from the archive (`meta.from_archive: true`) now carries the clock on every observed row from 2026-08-11: 100.0% of every day from 2026-08-11 to 2026-10-01, 92.5% of 2026-08-10 (the live column's own first instant is `2026-08-10T08:07:42Z`), and 0.0% of every day before that. Over the whole archived observed corpus, 1,721,102 of 4,663,181 rows carry it (36.9%, against 307,631 before the backfill): 0.0% of every month to July 2026, 68.8% of August, 100.0% of September and of October. The boundary was 2026-09-23, the day the archive column landed, until a backfill took each missing value from the live row that archived row had been copied from: 1,413,471 clocks and 1,393,458 counters, inside 1,538,176 rows over 14,491 matches. Nothing is derived, rounded or taken from a neighbouring row, and a row whose live row was already trimmed at the 90-day retention stays null for good. Wire-verified on match 2168 (26 April 2026), before the boundary: 131 observed rows, `timestamp` on all 131 and `accepted_at` on none. Match 189679's own archived copy carried the clock on 0 of its 171 observed rows before that run and on 171 of 171 after it, which is why the 189679 measurement above holds either way: that match is served with `from_archive: false`. So read `meta.from_archive` before planning a timing study, and treat a null here as a boundary of the record rather than as a missing value.
    - `received_at` string, date-time, nullable: On tape rows since 2026-09-24. The instant this state reached our edge, before the arbiter ran, stamped once. `accepted_at` minus `received_at` is our own arbitration step; neither figure is a claim about how fast anything upstream is, and neither is the court's clock. `timestamp` is the only one of the three with no start date: this clock begins 2026-09-24 and `accepted_at` begins 2026-08-11 on an archived tape (this description said the other two had no start date until 2026-10-01, which was wrong about `accepted_at` — see `HistoryTapeRow.accepted_at`). This is also the one clock the 2026-10-01 archive backfill could not move: the live column landed 2026-09-24, a day AFTER the archive column, so every live row carrying an arrival clock was archived with it and the recoverable count is zero. It is stamped only as a state arrives, so it can never be filled in afterwards: the earliest one we hold is `2026-09-24T02:00:26Z`, and on every observed row accepted before that instant it is null while `timestamp` is populated. Measured on match 189679 (12 September) after a Basic customer reported it on 2026-09-25: 171 observed rows, `accepted_at` on all 171, `received_at` on none — a match served from its live rows, so it says nothing about the archived tape. Null on every reconstructed row.
    - `sequence` integer, nullable: On tape rows since 2026-09-26. This state's per-match ACCEPT COUNTER — the same number `GET /matches/{matchId}/withdrawals` publishes as `sequence` and `replaced_by_sequence`, and the same counter as `Score.sequence` on a live read. It is what makes the withdrawal ledger's pointer resolvable: told "sequence 104 was withdrawn, resume from 103", you can now find 103 on the tape, and because a withdrawn state never enters the record at all the withdrawal shows up here as a GAP in the run of counters. Before this the tape published no such number, so the pointer named a row nothing identified — reported by an Ultra customer who wrote that "the history rows lack sequence IDs, so these responses alone cannot reconstruct exactly what a live client received". NULL, NEVER FABRICATED, IN TWO CASES, and both are honest gaps rather than errors: a row whose archived copy could not be filled from its own live row, and the pre-2023 archive tape, which has no such counter because no live accept ever happened. The 2026-10-01 backfill moved the first of those from 2026-09-26 back to 2026-08-14, so the archived counter is on 68.2% of 2026-08-14, 99.3% of 08-15, 95.9% or more of every day from 2026-08-16 and 100% from 2026-09-01; before that the counter died with the live row at the 90-day retention. Unlike the two clocks it is NOT null on a reconstructed row: a counter is a fact about our own accept order, so wherever one is present a live reader really did receive that state at that position.
    - `winner` 1 | 2 | null, nullable: Who won the point that produced this row — `1` | `2`, judged from the score step between this row and the previous served row (the previous raw row on the raw sequence, the previous clean row under `?sequence=clean`), by the same rule the completeness ledger counts a legal transition with — and from nothing else: never the serve, the server, the outcome tag or the pattern of play. Null on the first row and wherever the step is not one attributable point: a re-sent row (an identical state — null on raw, collapsed away on clean), a set opener (same games totals, the arrays extended by `[0,0]`), a backward correction, a multi-game jump. A step whose games total rises by exactly one attributes to the side whose count rose (the game point was theirs) whatever in-game points the step skipped — a missed poll, or a game the strict expander withheld whole; `meta.points.games_withheld` says when the latter happened. Present on every row of every sequence, observed and reconstructed, and on the pre-2023 ArchiveTape; equal to `point_winner` wherever that older key is present. Derived once per response, never stored. Added 2026-09-22.
    - `point_winner` 1 | 2 | null, nullable: The same verdict as `winner` under the older key, kept unchanged for existing readers. Who won the point this row records — PRESENT ONLY on `?sequence=clean` and `?points=complete` rows, and only where the transition from the previous row of the served sequence is a single attributable point; null on gaps, torn rows and the first row. Never on the plain raw sequence (raw is deliberately non-monotonic: consecutive raw rows are corrections, not points — a `points=complete` read is the exception, because there the served order IS point order). Derived at read time, never stored or guessed. Since 2026-09-22 `winner` carries the same verdict on every row of every sequence.
  - `tiebreaks` object[], nullable: Per-set tiebreak final scores, same rule and shape as on HistoryTape. Usually PRESENT even on a per-game tape — the pre-2023 record expands points inside tiebreaks even where it does not elsewhere.
    - `p1` integer
    - `p2` integer
  - `profiles` object[]: Always empty. The key is kept so the envelope matches HistoryTape; no model analysis exists, or could exist, for a pre-2023 match.
  - `meta` object
    - `archive_match_id` integer: The `archive_matches.id` this tape belongs to. Named in full because it is NOT a match id — passing it to a `/matches/...` route resolves a different, real record without erroring.
    - `tiebreaks_source` string[], nullable: Aligned to `tiebreaks`, same contract as on HistoryTape. Every populated entry here reads `reconstruction` — the archive tape IS the reconstruction and nothing else is consulted.
    - `rows` integer: Rows returned. Read next to `granularity` before comparing it with a 2023+ tape's row count. Across the corpus a tape averages 146 rows (median 138, range 4-570); a per-game tape carries one row per game and is far shorter than a point tape of the same match.
    - `coverage` 'reconstructed' | 'reconstructed_partial': `reconstructed` (96.3% of the corpus) = the tape reproduces the published final score exactly, and that score is a match that finished. `reconstructed_partial` (3.7%) HAS TWO CAUSES and the label does not say which: either the match itself stopped early (a retirement or abandonment prefix), OR the tape is per-GAME — every one of the 556 game-granular tapes carries this label however complete it is, because the coverage vocabulary is closed and `game` has no word of its own. It therefore does NOT on its own mean incomplete match coverage: read `granularity` and the match's own score/outcome for that. Never `from_start` or `partial`: both assert we watched the match, and nobody watched these. On the 2023+ tape the same word means only short-at-either-end.
    - `granularity` 'point' | 'game': What one row resolves to. `point` = the record advances within a game — 99.4% of the corpus (97,293 matches). `game` = one row per game, with points expanded only inside tiebreaks — 556 matches, 555 of them in 2013 and one in 2014; every season from 2015 on is 100% point-granular. `point_winner` / `winner` on a per-game tape read the side whose game count rose on each row, and nothing about the game's other points. Orthogonal to `coverage` as an axis, though a `game` tape is always labelled `reconstructed_partial`.
    - `point_source` 'reconstructed': Always `reconstructed`. It is a property of the dataset, not a measurement of these rows, and there is no other value it can take.
    - `model_rows` integer: Always 0 — no model output exists for a pre-2023 match. Present so a client parsing both halves of the tape product reads one shape.
    - `observed_rows` integer: Always 0 — no row here was watched live.
    - `point_source_detail` string: Always `reconstruction from public record`. Stated in the payload so the provenance travels with the data rather than living only in a customer's memory of these docs.
    - `sequence` 'raw': Always `raw`. There is nothing to collapse — a reconstructed tape has one source and no corrections, so `?sequence=clean` has no meaning here; the parameter is ignored, not refused.
    - `generated_at` string, date-time

## Other responses

- `401`: Missing, unknown, or disabled credentials
- `403`: Your tier doesn't unlock this endpoint
- `404`: No tape for this archive result — it is 1968–2012, it is 2013–2022 and was never reconstructed, or a reconstruction exists and did not prove its binding. The three are deliberately not distinguished: "we hold something we will not stand behind" is not a state this product reports.
- `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)
