-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathopenapi.yaml
More file actions
3702 lines (3638 loc) · 168 KB
/
Copy pathopenapi.yaml
File metadata and controls
3702 lines (3638 loc) · 168 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
openapi: 3.1.0
info:
title: Live Tennis API
version: "1.7.1"
contact:
name: Live Tennis API
url: https://livetennisapi.com
license:
name: MIT
url: https://github.com/livetennisapi/openapi/blob/main/LICENSE
termsOfService: https://livetennisapi.com/terms
description: |
Real-time tennis scores, player data, match-winner market prices, and
model-driven match analysis. Read-only. Coverage spans ATP, WTA,
Challenger, ITF and the junior Grand Slam draws — depth differs by tour
and surface; `GET /history/coverage` states the measured numbers.
Access is tiered (FREE / BASIC / PRO / ULTRA). Each tier includes
everything in the tiers below it; the concrete deltas are:
`FREE` — self-serve, no card (https://livetennisapi.com/subscribe/free).
Live and upcoming matches, current scores, players, fixtures, the
tournament catalogue (`/tournaments`), and your own usage stats.
30 requests/minute, 100/day. No historical results, no market prices,
no model fields, no WebSocket.
`BASIC` — adds historical data: the completed-match listing
(`/history/matches`, and `status=completed` on `/matches`), the
per-match point-by-point tape with the model win-probability on the
rows where the model ran
(`/history/matches/{matchId}`), the measured completeness rollup
(`/history/coverage`), and the results archive (1968–2022) —
deep results (`/history/archive/matches`), archive player bios
(`/history/archive/players`), career aggregates
(`/history/archive/career`) and head-to-head (`/h2h`).
60 requests/minute, 1,000/day.
`PRO` — adds match events (`/matches/{matchId}/events`), market prices
(`/markets`, `/markets/{matchId}/prices`, `/matches/{matchId}/prices`),
the pre-built monthly bulk history packages (`/history/packages`) and the
rank-ordered rankings listing (`/rankings?system=`).
300 requests/minute, 10,000/day.
`ULTRA` — adds model analysis (`/matches/{matchId}/analysis`), the live
model fields (`win_probability_p1`, `danger`) on every score object,
in-play match statistics (`/matches/{matchId}/statistics`), per-player
as-of ranking records (`/rankings?player=`), the as-of Elo tape
(`/rankings?system=elo` — both modes, plus `kind=elo` bulk packages),
rally construction
(`/rally/matches`, shot-by-shot charted data), career and per-match
charting stats (`/charting/players`, `/charting/matches/{chartingMatchId}`),
the WebSocket live feed at `/ws` and the high-fan-out push feed
(`/ws-token`), and outbound webhooks (direct keys). 600 requests/minute,
500,000/day.
History runs in two continuous halves, deliberately non-overlapping: the
point-by-point tape (2023→now) covers January 2023 to now, match by
match, point by point; the results archive (1968–2022) covers 1968
through 2022 as winner/loser-shaped RESULTS (final score, seeds, ranks at
the time — no point-by-point). The archive ends exactly where the tape
begins, so no match is ever served from two datasets.
A call above your tier returns `403 {"error":"upgrade_required"}` — never
a silent empty result.
CORS is enabled across the REST surface: every response carries
`Access-Control-Allow-Origin: *` (GET/OPTIONS, no credentials mode — there
is no cookie or session, and a wildcard origin is incompatible with
credentials by design). Putting a FREE key in browser code is acceptable —
it is capped and revocable; a paid key belongs server-side only.
The `/history/*` endpoints are also sold standalone as the **Historical
Data API** (no live-API subscription required): **Starter** — single-match
point-by-point tape reads via the API (tape plus the model win-probability
per point), all tours (ATP/WTA/Challenger/ITF/juniors), one match per
request, no bulk downloads; **Pro** — everything in Starter plus bulk
monthly package
downloads and higher rate limits; **Business** — everything in Pro plus
year-scale archive exports, top rate limits and priority support. One-off
1-month and 1-year access passes are available without a subscription.
The results archive (1968–2022) endpoints (`/history/archive/*`, `/h2h`)
ride with the same entitlement — any active History plan, Starter
included, opens them alongside the tape endpoints.
Plans and prices: https://livetennisapi.com/historical-tennis-data-api
All timestamps are UTC ISO 8601 with a `Z` suffix. List endpoints return
`{data, meta}`; single resources return the object directly. Ignore
unknown fields — additive changes land within v1.
A native WebSocket live feed (ULTRA) exists at `/ws` under the same base
URL. Subscribe with one JSON frame whose keys are `topics` and
(optionally) `signals`: `{"topics":["live-scores"]}` — `topics` may also
name `"match:<id>"`. The server acks with a `subscribed` frame, then
pushes `score` frames on every change plus a `ping` heartbeat roughly
every 15s. Score frames carry the ULTRA model fields
(`win_probability_p1`, `danger`) live; a null there means the model had
no output for that point, not that the field is REST-only. Opt into extra
signals with `{"topics":["live-scores"],"signals":["break_point"]}` to
also receive `break_point` and `break_point_result` frames (schemas
`BreakPoint` / `BreakPointResult`). Without `signals`, score frames only.
`signals` may also name `points` — the live per-point event stream: one
`point` frame (schema `PointFrame`) per persisted point of your
subscribed matches, ordered per match by `seq`. The signal is
config-gated and ships OFF by default; the `subscribed` ack echoes the
signals actually active, so `points` present in the ack means point
frames will flow and missing means they will not. Frames arrive only for
matches with `pbp_coverage: "point"` — a `game`-coverage match sends
none, honestly. Best-effort with NO replay: on reconnect (or to join
mid-match) catch up via `GET /matches/{matchId}/points?after_seq=` and
dedup by `seq`.
Max 2 concurrent connections per key. For high fan-out, `GET /ws-token`
mints a token for the separate push feed.
Getting a match id: it is the `id` field on any match object returned by
`GET /matches`, `GET /fixtures` or `GET /history/matches`, and the same value
works on every route that takes `matchId`.
servers:
- url: https://api.livetennisapi.com/api/public/v1
security:
- bearerAuth: []
- apiKeyHeader: []
paths:
/health:
get:
summary: Liveness probe (no auth)
operationId: healthCheck
security: []
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
status: { type: string, const: ok }
version: { type: string, const: v1 }
/matches:
get:
summary: List matches by lifecycle status (FREE)
description: >-
`status=live` and `status=upcoming` are the FREE current-state picture.
`status=completed` pages historical results and is part of the paid
History product — it requires BASIC (the same rule as
`/history/matches`) and returns `403 upgrade_required` on a FREE key.
The `player`, `country`, `from`/`to`, `tour` and `draw` filters are
optional, AND-composed, applied inside the query (before pagination),
and work on every status — omitting them returns exactly what the
endpoint returned before they existed.
operationId: listMatches
parameters:
- name: status
in: query
description: >-
`live` (default) and `upcoming` are FREE; `completed` requires
BASIC — paging completed results is the history surface.
schema: { type: string, enum: [live, upcoming, completed], default: live }
- $ref: "#/components/parameters/tour"
- $ref: "#/components/parameters/draw"
- $ref: "#/components/parameters/player"
- $ref: "#/components/parameters/country"
- $ref: "#/components/parameters/playedFrom"
- $ref: "#/components/parameters/playedTo"
- $ref: "#/components/parameters/limit"
- $ref: "#/components/parameters/offset"
responses:
"200":
description: Matches with latest score
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Match" }
meta: { $ref: "#/components/schemas/ListMeta" }
"400": { $ref: "#/components/responses/BadRequest" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/UpgradeRequired" }
"429": { $ref: "#/components/responses/RateLimited" }
/matches/{matchId}:
get:
summary: Full match detail (FREE; +market PRO, +analysis ULTRA)
operationId: getMatch
parameters: [ { $ref: "#/components/parameters/matchId" } ]
responses:
"200":
description: Match with score; `market` embed at PRO+, `analysis` embed at ULTRA
content:
application/json:
schema: { $ref: "#/components/schemas/MatchDetail" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/UpgradeRequired" }
"404": { $ref: "#/components/responses/NotFound" }
"429": { $ref: "#/components/responses/RateLimited" }
/matches/{matchId}/score:
get:
summary: Current score only — lowest-latency REST read (FREE)
description: >-
This is a POINT-IN-TIME SNAPSHOT: the single current state, overwritten
on every score commit. It carries no history and no accumulated
statistics.
For the SEQUENCE of states — who served each game, hold/break, every
score state in forward order — use
`/history/matches/{matchId}?sequence=clean`, which works on a LIVE
match, not only a completed one. For in-play statistics use
`/matches/{matchId}/statistics` (ULTRA); they are deliberately not on
this object, because they can be further behind the match than the
score and must carry their own `as_of`.
operationId: getMatchScore
parameters: [ { $ref: "#/components/parameters/matchId" } ]
responses:
"200":
description: Current score (ULTRA adds win_probability_p1 + danger)
content:
application/json:
schema: { $ref: "#/components/schemas/Score" }
"401": { $ref: "#/components/responses/Unauthorized" }
"404": { $ref: "#/components/responses/NotFound" }
"429": { $ref: "#/components/responses/RateLimited" }
/matches/{matchId}/events:
get:
summary: Match events, newest first (PRO)
operationId: listMatchEvents
parameters:
- $ref: "#/components/parameters/matchId"
- $ref: "#/components/parameters/limit"
- $ref: "#/components/parameters/offset"
responses:
"200":
description: Events
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Event" }
meta: { $ref: "#/components/schemas/ListMeta" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/UpgradeRequired" }
"429": { $ref: "#/components/responses/RateLimited" }
/matches/{matchId}/analysis:
get:
summary: Model analysis for a match (ULTRA)
operationId: getMatchAnalysis
parameters: [ { $ref: "#/components/parameters/matchId" } ]
responses:
"200":
description: Thesis + profile (either may be null)
content:
application/json:
schema: { $ref: "#/components/schemas/Analysis" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/UpgradeRequired" }
"404": { $ref: "#/components/responses/NotFound" }
"429": { $ref: "#/components/responses/RateLimited" }
/matches/{matchId}/statistics:
get:
summary: In-play statistics — aces, double faults, serve split, hold/break %, break points, service & return points (ULTRA)
description: >-
In-play statistics for one match, in TWO families that are deliberately
not merged.
DERIVED (the top level of `players.pN`) are rebuilt from the
point-by-point record: service and return games played and won, hold
and break percentage, break points faced, saved and converted, service
and return points.
MEASURED (`players.pN.measured`) are counted upstream, so they include
what no point record can yield — ACES AND DOUBLE FAULTS, the first- and
second-serve split, winners and unforced errors. Both families name
some of the same quantities, computed two entirely different ways; that
is a cross-check, not a duplication to collapse.
Measured coverage is not uniform and every measured field is optional —
an absent field is OMITTED, never zero-filled, so read the keys you are
given. Aces and double faults are present across every tour. The serve
split and break points saved are present on the main tours and absent
on ITF singles. Winners and unforced errors historically appeared on
a minority of main-tour matches and have not been delivered upstream
since 2026-07-12 (measured 2026-08-17).
`freshness.derived` and `freshness.measured` each carry their own
`coverage` (`live` | `final` | `stale` | `none` | `diverged`;
`final` = the closing figures of a completed match — a finished match
cannot be "stale", so its `age_seconds` is null), `as_of`,
`age_seconds` and `describes` — the match state the numbers describe.
On `diverged` the measured VALUES are withheld and
`freshness.measured_divergence` says why; the top-level `coverage` only
summarises the response. `none` on both returns 200 with null players,
not 404 — the match exists and holding nothing for it is the honest
answer.
THE TWO AGES USE DIFFERENT CLOCKS AND MUST NOT BE COMPARED. The derived
age is measured against the newest SCORE row, because between points
there is no new score either and wall-clock age would report staleness
that does not exist. The measured age is wall clock, because those are
fetched on a fixed cadence.
Tiebreak games are excluded from the DERIVED family and counted
separately; the live record collapses a whole tiebreak onto one entry,
so most of its points are lost.
operationId: getMatchStatistics
parameters: [ { $ref: "#/components/parameters/matchId" } ]
responses:
"200":
description: Statistics with their own coverage and as_of
content:
application/json:
schema: { $ref: "#/components/schemas/MatchStatistics" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/UpgradeRequired" }
"404": { $ref: "#/components/responses/NotFound" }
"429": { $ref: "#/components/responses/RateLimited" }
/matches/{matchId}/points:
get:
summary: Live per-point events in seq order — the REST catch-up for the WebSocket point stream (ULTRA)
description: >-
The live per-point event stream of one match, in `seq` order. The
WebSocket `point` frames are best-effort with NO replay, so this
endpoint is how you join mid-match and how you recover a dropped
connection: subscribe the WS first, then GET with `after_seq` set to
the last seq you hold, then dedup everything by `seq` — it is
per-match, monotonic and never skips a value, so it is the whole
reconciliation key.
READ THE COVERAGE HONESTLY BEFORE YOU BUILD ON IT. A match's stream
is per-point ONLY where a point-level feed covers it:
`pbp_coverage: "point"` means this match has a true per-point stream;
`"game"` means only the snapshot score path covers it — `points` is
empty and that is an answer, not an error. Per-point coverage is
never promised slate-wide; ITF and qualifying coverage in particular
is partial. `quality: "revised"` means the upstream feed rewrote an
already-served prefix at least once during this match; served rows
are never edited (append-only).
Each row is the state AFTER a played point: `score`/`sets`/`games`
(tiebreaks carry the running count in `score` with `games` frozen at
the pre-breaker score), its position (`set`/`game`/`number`),
`server` (of the next point), the derived `winner` (null when not
attributable to a single point — never guessed), and `ts` — CAPTURE
time, when our pipeline committed the state, because no feed asserts
a per-point clock and we fabricate none.
Up to 500 rows per page; `after_seq=last_seq` fetches the next page
while `has_more` is true. 404 unknown match; 400 `points_disabled`
while the surface is switched off server-side.
COMPLETED MATCHES: live capture is inherently partial — the stream
serves what arrived while the match ran, and the match-closing point
never streams live. Where a measured-complete recorded point sequence
of the finished match exists, this endpoint serves THAT instead — the
complete sequence projected into the same point-frame shape, love-love
opener through the match-closing point, `seq` contiguous 1..N. The
response field `basis` says which base served the page: `live` (the
persisted live stream rows — every live match, and any completed match
without a measured-complete recorded sequence) or `reconstruction`
(the projected complete sequence; `quality` is `clean`, every
transition measured legal). Completeness beats the partial live
capture wholesale — the two sequences are never interleaved (they
share no key, so any merge would fabricate an order). On projected
frames `ts` is null on every row: the recorded sequence carries no
per-point clock and we fabricate none. `after_seq` pagination and
`seq` dedup work identically on either basis, but the two bases are
different sequences: after a match completes and flips to
`reconstruction`, re-read from `after_seq=0` rather than resuming a
live cursor into it.
operationId: getMatchPoints
parameters:
- $ref: "#/components/parameters/matchId"
- name: after_seq
in: query
required: false
schema: { type: integer, minimum: 0, default: 0 }
description: >-
Return only points with `seq` greater than this — the resume
cursor. Pass the `last_seq` of the previous page (or the last seq
your WS stream delivered) to continue; 0 or absent reads from the
start of the match. A non-integer or negative value is a 400
`bad_after_seq`.
responses:
"200":
description: The point events page, seq order
content:
application/json:
schema: { $ref: "#/components/schemas/MatchPoints" }
"400": { $ref: "#/components/responses/BadRequest" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/UpgradeRequired" }
"404": { $ref: "#/components/responses/NotFound" }
"429": { $ref: "#/components/responses/RateLimited" }
/players:
get:
summary: Search players by name (FREE)
operationId: searchPlayers
parameters:
- name: search
in: query
schema: { type: string }
- $ref: "#/components/parameters/limit"
- $ref: "#/components/parameters/offset"
responses:
"200":
description: Players (ranked first; no stats object on the list)
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Player" }
meta: { $ref: "#/components/schemas/ListMeta" }
"401": { $ref: "#/components/responses/Unauthorized" }
"429": { $ref: "#/components/responses/RateLimited" }
/players/{playerId}:
get:
summary: One player's bio + ranking + cached stats (FREE)
operationId: getPlayer
parameters:
- name: playerId
in: path
required: true
schema: { type: integer }
responses:
"200":
description: Player with `stats` ({ratings, season})
content:
application/json:
schema: { $ref: "#/components/schemas/Player" }
"401": { $ref: "#/components/responses/Unauthorized" }
"404": { $ref: "#/components/responses/NotFound" }
"429": { $ref: "#/components/responses/RateLimited" }
/tournaments:
get:
summary: Tournament catalogue — the id space `Match.tournament_id` joins (FREE)
description: >-
Stable tournament identity, one row per tournament × event type, stable
across seasons. `city`/`country` come from a curated table and
`category` only where our catalogues agree unambiguously on an
exact-name join — each is null otherwise, never derived from the
tournament name.
operationId: listTournaments
parameters:
- name: search
in: query
schema: { type: string }
description: Case-insensitive substring match on the tournament name.
- $ref: "#/components/parameters/tour"
- $ref: "#/components/parameters/draw"
- $ref: "#/components/parameters/limit"
- $ref: "#/components/parameters/offset"
responses:
"200":
description: Tournaments, name order
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Tournament" }
meta: { $ref: "#/components/schemas/ListMeta" }
"400": { $ref: "#/components/responses/BadRequest" }
"401": { $ref: "#/components/responses/Unauthorized" }
"429": { $ref: "#/components/responses/RateLimited" }
/tournaments/{tournamentId}:
get:
summary: One tournament by its stable id (FREE)
operationId: getTournament
parameters:
- name: tournamentId
in: path
required: true
schema: { type: string }
description: The `tournament_id` carried on match objects.
responses:
"200":
description: The tournament
content:
application/json:
schema: { $ref: "#/components/schemas/Tournament" }
"401": { $ref: "#/components/responses/Unauthorized" }
"404": { $ref: "#/components/responses/NotFound" }
"429": { $ref: "#/components/responses/RateLimited" }
/markets:
get:
summary: Match-winner market(s) for a match (PRO)
operationId: listMarkets
parameters:
- name: match_id
in: query
required: true
schema: { type: integer }
responses:
"200":
description: Markets
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Market" }
meta:
type: object
properties:
match_id: { type: integer }
count: { type: integer }
"400": { $ref: "#/components/responses/BadRequest" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/UpgradeRequired" }
"429": { $ref: "#/components/responses/RateLimited" }
/markets/{matchId}/prices:
get:
summary: Market + recent price ticks per side, newest first (PRO)
operationId: getMarketPrices
parameters:
- $ref: "#/components/parameters/matchId"
- $ref: "#/components/parameters/limit"
responses:
"200":
description: Market with `prices`
content:
application/json:
schema: { $ref: "#/components/schemas/Market" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/UpgradeRequired" }
"404": { $ref: "#/components/responses/NotFound" }
"429": { $ref: "#/components/responses/RateLimited" }
/matches/{matchId}/prices:
get:
summary: Bare price ticks of the mapped match-winner market, newest first (PRO)
description: >-
Recent ticks only (no market wrapper). `limit` caps at 500; `minutes`
bounds the lookback window. 404 when the match has no mapped market.
operationId: listMatchPrices
parameters:
- $ref: "#/components/parameters/matchId"
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
- name: minutes
in: query
schema: { type: integer, minimum: 1, maximum: 1440 }
responses:
"200":
description: Price ticks
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Price" }
meta:
type: object
properties:
match_id: { type: integer }
count: { type: integer }
has_more:
type: boolean
description: >-
The window was clipped at limit — older ticks exist.
There is no offset here; raise limit or narrow
minutes.
limit: { type: integer }
minutes: { type: [integer, "null"] }
"400": { $ref: "#/components/responses/BadRequest" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/UpgradeRequired" }
"404": { $ref: "#/components/responses/NotFound" }
"429": { $ref: "#/components/responses/RateLimited" }
/history/matches:
get:
summary: Completed matches, newest first, with derived winner and tape coverage (BASIC)
description: >-
Requires BASIC on the live API, or any Historical Data API plan
(Starter and up — see https://livetennisapi.com/historical-tennis-data-api).
All tours, January 2023 → now (deeper results live in the results
archive, 1968–2022, at `/history/archive/matches`). Filter to a date
range with `from`/`to`, and by `tour`, `draw` (singles/doubles),
`player` (either participant) and `country` — same vocabulary as
`/matches`. Each item carries a
`tape` object saying what point-by-point data we hold for that match,
so a whole page can be qualified in one call instead of one request per
match. NOTE `?coverage=` is applied AFTER the page is cut, so a
filtered page is routinely shorter than `limit` (and may be empty)
while later pages still hold matching matches — a short filtered page
is not an end-of-data signal; `?points_complete=` filters the same
way.
operationId: listCompletedMatches
parameters:
- $ref: "#/components/parameters/limit"
- $ref: "#/components/parameters/offset"
- $ref: "#/components/parameters/playedFrom"
- $ref: "#/components/parameters/playedTo"
- $ref: "#/components/parameters/tour"
- $ref: "#/components/parameters/draw"
- $ref: "#/components/parameters/player"
- $ref: "#/components/parameters/country"
- name: coverage
in: query
required: false
schema: { $ref: "#/components/schemas/Coverage" }
description: >-
Keep only matches whose tape has this coverage. An unknown value is
a 400 `bad_coverage` listing the accepted values in `allowed`.
- name: points_complete
in: query
required: false
schema: { type: string, enum: ["true", "false"] }
description: >-
Keep only matches whose measured point-completeness ledger verdict
is this value — best-basis (the served tape OR an on-disk
reconstruction measured point-complete; fetch the latter with
`?points=complete` on the per-match tape). The ledger is a
per-match cache reconverged nightly. A match not yet measured
matches NEITHER value; anything but true/false is a 400
`bad_points_complete`. Applied AFTER the page is cut, exactly like
`?coverage=`.
responses:
"200":
description: Completed matches (`winner` = 1|2|null, from final sets), each with its tape coverage
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/HistoryMatch" }
meta: { $ref: "#/components/schemas/ListMeta" }
"400": { $ref: "#/components/responses/BadRequest" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/UpgradeRequired" }
"429": { $ref: "#/components/responses/RateLimited" }
/history/coverage:
get:
summary: Measured completeness rollup per tour × draw bucket (BASIC)
description: >-
Requires BASIC on the live API, or any Historical Data API plan
(Starter and up — see https://livetennisapi.com/historical-tennis-data-api).
The numbers to read BEFORE choosing what to backtest, in one call
instead of paging the archive. A PREBUILT snapshot rebuilt nightly
right after the completeness ledger reconverges — never computed at
read time — so `as_of` (= `built_at`) dates every number, and
`ledger_max_computed_at` is the newest underlying per-match
measurement. Buckets are atp/wta/challenger/itf/juniors ×
singles/doubles plus `other` (team ties, mixed, exhibitions, and
matches with no stated event type — counted, never dropped, so the
totals cannot lie), derived from the same registries as the `tour` and
`draw` fields. `method` states the full measurement rule in one
paragraph, so every number carries its own definition. As of
2026-08-18 the headline spread it exposes: 51.1% of ITF singles
matches are point-complete on the best basis against 3.5% of ITF
doubles — do not extrapolate a completeness rate across a tour group.
operationId: getHistoryCoverage
responses:
"200":
description: The rollup artifact, dated by its own as_of
content:
application/json:
schema: { $ref: "#/components/schemas/CoverageRollup" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/UpgradeRequired" }
"429": { $ref: "#/components/responses/RateLimited" }
"503":
description: >-
coverage_unavailable — the artifact has not been built yet (or is
unreadable). Honest and temporary; retry after the nightly build.
The rollup is never computed inline.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
/history/matches/{matchId}:
get:
summary: Per-match tape — point-by-point score + per-point model probabilities (BASIC)
description: >-
The tape is the point-by-point score sequence we hold for this match —
every recorded score row including the model fields
`win_probability_p1` and `danger` at that point — plus match metadata
and the model profiles produced during the match. The model fields here
are part of the paid History product by design, distinct from the
ULTRA-gated LIVE model fields. One match per request. Requires BASIC on
the live API, or the Historical Data API Starter plan and up.
The tape is NOT guaranteed to cover the whole match — check
`meta.coverage` and `meta.point_source` before backtesting. Rows
expanded after the fact from a finished-match point-by-point record
carry a null `timestamp` and null model fields; nothing is ever
synthesised.
WORKS ON A LIVE MATCH, not only a completed one. The tape is assembled
from whatever has been committed so far, so it is how you read the
point-by-point history of a match in progress — including games played
before you started watching, where we were already watching them. The
LIST endpoint is completed-only; get live ids from
`/matches?status=live`. `/matches/{matchId}/score` is one state; this
is the sequence of states.
operationId: getMatchTape
parameters:
- $ref: "#/components/parameters/matchId"
- name: sequence
in: query
required: false
schema: { type: string, enum: [raw, clean], default: raw }
description: >-
`raw` (default) is every row we committed — deliberately
non-monotonic, since independent sources race and a higher-trust
one may correct a lower-trust one backwards. `clean` returns one
row per distinct score state, keeping the last assertion of each.
An unknown value is a 400 `bad_sequence`.
- name: points
in: query
required: false
schema: { type: string, enum: [default, complete], default: default }
description: >-
`default` serves observed rows first — what our own pipeline
committed, a SAMPLED record of the match. `complete` is the
explicit opt-out of that precedence for consumers who want every
point: where a whole-match reconstruction exists it is served
WHOLE, in its own point order, with `point_winner` on every row
and null timestamps/model fields per the reconstruction contract.
Where none exists, the response is exactly the default read plus
`meta.points` (whose `available_complete` tells the cases apart) —
no error. Cannot combine with `sequence=clean` (400
`bad_combination` — the state-key collapse would delete the
repeated deuce states a complete point sequence contains). An
unknown value is a 400 `bad_points`; where not yet enabled,
`complete` answers 400 `points_read_disabled` rather than silently
serving the default. `coverage` and `meta.points` are orthogonal
axes: coverage says how the rows were OBTAINED, points says how
COMPLETE the sequence is — completeness is only ever claimed per
match, as measured.
responses:
"200":
description: The full tape (`match` + `tape` + `profiles` + coverage `meta`)
content:
application/json:
schema: { $ref: "#/components/schemas/HistoryTape" }
"400": { $ref: "#/components/responses/BadRequest" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/UpgradeRequired" }
"404": { $ref: "#/components/responses/NotFound" }
"429": { $ref: "#/components/responses/RateLimited" }
/history/archive/matches:
get:
summary: Results archive (1968–2022) — deep historical results (BASIC)
description: >-
Completed-match RESULTS from a licensed historical corpus — ATP and
WTA main draws, qualifying/challengers and futures tiers, 1968 through
2022. Winner/loser-shaped records with final score, round, seeds, the
players' ranks AT THE TIME, and per-match serve statistics where the
era recorded them. Requires BASIC on the live API, or any Historical
Data API plan (Starter and up).
A SEPARATE id space from `/matches` — archive people are identified by
the corpus person id and by name, never by roster player ids — and the
archive ends where our own point-by-point coverage begins (2023-01),
so no match is ever served from two datasets. `event_date` is the
TOURNAMENT START date, the only date records of this era carry.
operationId: listArchiveMatches
parameters:
- name: tour
in: query
schema: { type: string, enum: [atp, wta] }
description: The archive covers the ATP and WTA corpora only.
- name: name
in: query
schema: { type: string, minLength: 3 }
description: >-
Case-insensitive substring match on EITHER player's name (min 3
chars).
- name: from
in: query
schema: { type: string, format: date }
description: Earliest tournament start date (`YYYY-MM-DD`).
- name: to
in: query
schema: { type: string, format: date }
description: Latest tournament start date (`YYYY-MM-DD`).
- name: round
in: query
schema:
type: string
enum: [F, SF, QF, R16, R32, R64, R128, RR, BR, Q1, Q2, Q3, Q4, ER]
description: The archive's controlled round vocabulary.
- name: level
in: query
schema: { type: string }
description: >-
Source tier code: G=grand slam, M=masters, A=tour, F=finals,
D=davis cup, C=challenger, O=olympics; the futures tiers carry
their category codes (e.g. 15, 25) as published.
- $ref: "#/components/parameters/limit"
- $ref: "#/components/parameters/offset"
responses:
"200":
description: Archive results, newest tournament first
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/ArchiveMatch" }
meta: { $ref: "#/components/schemas/ListMeta" }
"400": { $ref: "#/components/responses/BadRequest" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/UpgradeRequired" }
"429": { $ref: "#/components/responses/RateLimited" }
/history/archive/matches/{archiveId}:
get:
summary: One archive result, with serve statistics where recorded (BASIC)
description: >-
Same entitlement as the archive listing. `stats` is null for the
(mostly pre-1991) rows the source never recorded statistics for —
never synthesised.
operationId: getArchiveMatch
parameters:
- name: archiveId
in: path
required: true
schema: { type: integer }
responses:
"200":
description: The archive record, `stats` included where the era recorded them
content:
application/json:
schema: { $ref: "#/components/schemas/ArchiveMatch" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/UpgradeRequired" }
"404": { $ref: "#/components/responses/NotFound" }
"429": { $ref: "#/components/responses/RateLimited" }
/history/archive/players:
get:
summary: Archive player bios — hand, DOB, country, height, career-high (BASIC)
description: >-
People of the results archive (1968–2022), in their own id space —
`id` is the corpus person id that archive match rows carry as
`winner.player_id` / `loser.player_id`, scoped per tour; never a
roster id. Career-high rank and the earliest week it was reached are
computed offline from the corpus's own weekly ranking tables. Null
fields are the era's silence, never guessed. Requires BASIC, or any
Historical Data API plan.
operationId: listArchivePlayers
parameters:
- name: name
in: query
schema: { type: string, minLength: 3 }
description: Case-insensitive substring filter (min 3 chars).
- name: tour
in: query
schema: { type: string, enum: [atp, wta] }
- $ref: "#/components/parameters/limit"
- $ref: "#/components/parameters/offset"
responses:
"200":
description: Archive people, ordered by name
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/ArchivePlayerBio" }
meta: { $ref: "#/components/schemas/ListMeta" }
"400": { $ref: "#/components/responses/BadRequest" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/UpgradeRequired" }
"429": { $ref: "#/components/responses/RateLimited" }
/history/archive/career:
get:
summary: Career aggregates over the results archive, 1968–2022 (BASIC)
description: >-
One player's whole archive career in one response: W-L record
(overall, by surface, by level, by year), titles, and the summed
serve-stat block with derived ratios. Everything is a sum or a ratio
of sums over rows you can fetch individually from
`/history/archive/matches` — nothing is modelled.
`serve.matches_with_stats` states the coverage honestly: the corpus
records per-match serve statistics from 1991 only, so a 1970s career
has a full W-L record and an empty serve block. Ambiguous name
fragments are refused with candidates (same rule as `/h2h`); an
unknown name is a 404. Requires BASIC, or any Historical Data API
plan.
operationId: getArchiveCareer
parameters:
- name: name
in: query
required: true
schema: { type: string, minLength: 3 }
description: >-
Player name (fragment, min 3 chars — must resolve to one person).
responses:
"200":
description: The career aggregate body
content:
application/json:
schema: { $ref: "#/components/schemas/ArchiveCareer" }
"400": { $ref: "#/components/responses/BadRequest" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/UpgradeRequired" }
"404": { $ref: "#/components/responses/NotFound" }
"429": { $ref: "#/components/responses/RateLimited" }
/h2h:
get:
summary: >-
Head-to-head across the results archive (1968–2022) and our own
completed matches (2023→now) (BASIC)
description: >-
The record between two players, assembled from BOTH halves of the
product: the results archive, where the winner is a stored column, and
our own completed matches, where the winner is derived from the final
recorded state. Names are the keys — archive people have no roster
ids. A fragment matching more than one player is refused with the
candidate list (`400 ambiguous_name`), because two people summed into
one record is a wrong answer, not a convenience. Totals count meetings
with a KNOWN winner; `undecided` counts the rest. Walkovers and
retirements are part of the record, and each meeting carries `outcome`
so you can exclude them. Requires BASIC, or any Historical Data API
plan. On ULTRA, a per-player `stats` block adds serve/return/break-point
aggregates over the pairing: `archive_serve` (serve-side, from 1991)
and `current` (2023+, adding return and break-point conversion, aces
and winners), each with `meetings_with_stats`.
operationId: getHeadToHead
parameters:
- name: p1
in: query
required: true
schema: { type: string, minLength: 3 }
description: First player name (fragment, min 3 chars).
- name: p2
in: query
required: true
schema: { type: string, minLength: 3 }
description: Second player name (fragment, min 3 chars).
responses:
"200":
description: >-
The head-to-head record; empty totals when no player matches the
names
content:
application/json:
schema: { $ref: "#/components/schemas/HeadToHead" }
"400": { $ref: "#/components/responses/BadRequest" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/UpgradeRequired" }
"429": { $ref: "#/components/responses/RateLimited" }
/history/packages:
get:
summary: List the pre-built monthly bulk history packages (PRO)
description: >-
Bulk downloads are a heavier product than single-match tape reads.
Requires PRO on the live API, or the Historical Data API Pro plan and
up, or a one-off package access pass. A key that can read the tape but
is not package-entitled receives `403 upgrade_required`.
operationId: listHistoryPackages
parameters:
- name: kind
in: query
required: false
schema: { type: string, enum: [tape, rankings, rally, archive, elo], default: tape }
description: >-
Package family. `tape` (default) = monthly point-by-point match
tapes; `rankings` = as-of ranking records (ULTRA); `rally` = the
charted rally corpus (shot-by-shot) as YEARLY exports (ULTRA);
`archive` = the results archive (1968–2022) as YEARLY exports,
same entitlement as the tape packages; `elo` = the as-of Elo tape
as YEARLY exports (ULTRA). The yearly kinds' `period`
is `YYYY`, one file per year, because a fixed historical corpus is
not an accruing monthly stream. The default means a tape-only
client never sees a new kind of row appear.
- name: year
in: query