-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathreference.html
More file actions
1642 lines (1564 loc) · 294 KB
/
Copy pathreference.html
File metadata and controls
1642 lines (1564 loc) · 294 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
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Live Tennis API — Full API Reference (text)</title>
<meta name="description" content="Complete text reference for the Live Tennis API: every endpoint, parameter, response field and plan tier. Real-time tennis scores, players, rankings, match-winner odds and model win-probability for ATP, WTA, Challenger and ITF.">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://docs.livetennisapi.com/reference.html">
<meta property="og:type" content="article">
<meta property="og:title" content="Live Tennis API — Full API Reference">
<meta property="og:url" content="https://docs.livetennisapi.com/reference.html">
<meta property="og:image" content="https://docs.livetennisapi.com/banner.jpg">
<link rel="icon" href="favicon.ico" sizes="any">
<script type="application/ld+json">
{"@context":"https://schema.org","@type":"TechArticle",
"headline":"Live Tennis API — Full API Reference",
"description":"Complete text reference for every Live Tennis API endpoint, parameter, response field and plan tier.",
"url":"https://docs.livetennisapi.com/reference.html",
"inLanguage":"en",
"isPartOf":{"@type":"WebSite","name":"Live Tennis API","url":"https://livetennisapi.com"},
"publisher":{"@type":"Organization","name":"Live Tennis API","url":"https://livetennisapi.com","logo":"https://docs.livetennisapi.com/icon-256.png"}}
</script>
<link rel="preload" href="fonts/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="fonts.css">
<style>
/* Design tokens (app/services/design_tokens.py). This page used to carry the
legacy marketing palette — every one of its six dark hexes was an exact key
in design_tokens.LEGACY, i.e. a colour the token module exists to retire. */
:root {
--bg:#0e0e0e; --panel:#141414; --struct:#2a2a2a;
--text:#e4e2e1; --muted:#84967e; --accent:#00ff41;
--r:6px; /* control radius; structure is square */
color-scheme: dark; /* was "normal", which invites the UA to
render form controls and scrollbars light */
}
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--text);
font:16px/1.65 'Inter',ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif; }
.wrap { max-width:960px; margin:0 auto; padding:40px 20px 80px; }
/* PROSE MEASURE. The wrap is 960px because the tables and code blocks need
it; running body text to that width gives ~110 characters a line, well past
the 45-75 the eye tracks comfortably. Only the prose is narrowed — tables,
pre and the endpoint sections keep the full width they need. */
main > p, main > ul:not(.toc), main > ol, .op > p, .banner > p, header > p { max-width:72ch; }
a { color:var(--accent); }
a:focus-visible, .scrollx:focus-visible, .skip-link:focus {
outline:2px solid var(--accent); outline-offset:2px; }
h1,h2,h3 { font-family:'Space Grotesk','Inter',ui-sans-serif,sans-serif; }
h1 { font-size:2rem; margin:0 0 .3em; }
h2 { margin-top:2.5em; padding-bottom:.3em; border-bottom:1px solid var(--struct); }
h3 { margin-top:2em; }
h4 { margin:1.4em 0 .4em; color:var(--muted); font-size:.85rem; text-transform:uppercase; letter-spacing:.08em; }
code { background:var(--panel); padding:.15em .4em; border-radius:var(--r); font-size:.9em;
font-family:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace; }
pre { background:var(--panel); border:1px solid var(--struct); padding:14px; border-radius:0;
overflow-x:auto; font-family:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace; }
pre code { background:none; padding:0; }
/* A long URL is better wrapped than hidden behind a scrollbar: it stays
copyable and every character is on screen. */
pre.urls { white-space:pre-wrap; overflow-wrap:anywhere; }
/* An inline <code> cannot scroll — it is inline. A long token inside one
(the /ws URL) pushed the whole document 44px wider than the viewport at
390px, which is a horizontal scrollbar on the PAGE, not in a block. */
code { overflow-wrap:anywhere; }
/* Wide blocks scroll in a focusable region rather than in the table itself,
so a keyboard can reach them. */
.scrollx { overflow-x:auto; max-width:100%; }
table { width:100%; border-collapse:collapse; margin:.6em 0 1.2em; }
th,td { text-align:left; padding:8px 10px; border-bottom:1px solid var(--struct);
vertical-align:top; font-size:.92rem; }
th { color:var(--muted); font-weight:600; }
/* Visually hidden, still announced. */
.vh { position:absolute; width:1px; height:1px; margin:-1px; padding:0;
overflow:hidden; clip:rect(0 0 0 0); clip-path:inset(50%); white-space:nowrap; border:0; }
.method { color:var(--accent); font-family:'JetBrains Mono',ui-monospace,monospace; }
.meta { color:var(--muted); font-size:.9rem; }
.summary { margin:.2em 0; }
.banner { background:var(--panel); border:1px solid var(--struct); border-left:3px solid var(--accent);
padding:14px 18px; border-radius:0; margin:1.5em 0; }
ul.toc { list-style:none; padding:0; }
ul.toc li { border-bottom:1px solid var(--struct); }
/* NAVIGATION TOUCH TARGETS. These stacked links were 19px tall and 11-12px
apart — the page's only chrome, and the hardest thing on it to hit. The two
inline links inside prose sentences are deliberately left alone: WCAG 2.5.8
exempts a link inline in a block of text, and padding them would break the
line box around them. */
ul.toc li a, .pagenav a, .footnav a {
display:flex; align-items:center; min-height:44px; padding:4px 2px; }
.pagenav, .footnav { display:flex; flex-wrap:wrap; gap:0 18px; margin:.4em 0; }
.skip-link { position:absolute; left:-9999px; top:0;
background:var(--panel); color:var(--accent); padding:12px 16px;
border:1px solid var(--struct); z-index:10; }
.skip-link:focus { left:8px; top:8px; }
/* THE TEACHING BLOCK. As a <pre> this was 62 columns wide and showed 35 of
them at 390px — every annotation sliced mid-word, which is precisely the
content a first-time reader needs most. As a two-column grid the alignment
that makes it teach survives on a wide screen, and on a narrow one the
explanation simply stacks under the code it explains. Nothing scrolls,
nothing is cut. */
.annot { display:grid; grid-template-columns:max-content 1fr; gap:2px 22px;
background:var(--panel); border:1px solid var(--struct);
padding:14px; margin:.6em 0 1.2em; overflow-x:auto; }
.annot dt { font-family:'JetBrains Mono',ui-monospace,monospace; font-size:.9em;
white-space:pre; margin:0; }
.annot dd { margin:0; color:var(--muted); font-size:.9em; align-self:center; }
@media (max-width:600px) {
.annot { grid-template-columns:1fr; gap:0; }
.annot dt { margin-top:.7em; white-space:pre-wrap; overflow-wrap:anywhere; }
.annot dt:first-child { margin-top:0; }
.annot dd { padding-bottom:.2em; }
}
/* Link-only table cells are targets, not prose, so they get the full 44px.
(The inline links inside sentences are left alone — see above.) */
.linkrow a { display:inline-flex; align-items:center; min-height:44px; }
/* SCROLL, OR WRAP. On a phone the code blocks showed as little as 54% of
their widest line, cut mid-token. Soft-wrapping them shows all of it, and
costs nothing on copy: a soft wrap is not a newline, so a copied curl
command is still byte-for-byte the command. Desktop has the room and keeps
the original hard lines. A table cannot wrap, so tables still scroll. */
@media (max-width:760px) {
pre { white-space:pre-wrap; overflow-wrap:anywhere; }
}
/* A horizontal scrollbar is invisible on touch until you already know it is
there, and the design system has no gradients to fade an edge with. So the
regions that genuinely still scroll get a scrollbar that is always drawn,
in tokens, plus a note in words. */
.scrollnote { display:none; color:var(--muted); font-size:.8rem; margin:-.4em 0 1em; }
@media (max-width:760px) {
.scrollnote { display:block; }
.scrollx { scrollbar-width:thin; scrollbar-color:var(--muted) var(--panel); }
.scrollx::-webkit-scrollbar { height:6px; -webkit-appearance:none; }
.scrollx::-webkit-scrollbar-track { background:var(--panel); }
.scrollx::-webkit-scrollbar-thumb { background:var(--muted); }
}
</style>
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<div class="wrap">
<header>
<h1>Live Tennis API — Full Reference</h1>
<p class="meta">Version 1.7.1 · OpenAPI 3.1.0</p>
<nav class="pagenav" aria-label="Related pages">
<a href="./">Interactive reference</a>
<a href="./openapi.yaml">OpenAPI spec</a>
<a href="https://livetennisapi.com">livetennisapi.com</a>
</nav>
<div class="banner">
<p><strong>This is the plain-text reference.</strong> It contains the same content as the
interactive documentation but requires no JavaScript, so it can be read by search engines,
answer engines and any HTTP client.</p>
</div>
</header>
<!-- Contents. There used to be one list, of the twelve endpoints, buried under
the seventh of nine sections; the prose sections it sat below were not
listed anywhere and eight of the nine headings had no id to link to. -->
<nav aria-labelledby="contents-heading">
<h2 id="contents-heading">Contents</h2>
<ul class="toc">
<li><a href="#quickstart">Quickstart — no code required</a></li>
<li><a href="#base-url">Base URL</a></li>
<li><a href="#authentication">Authentication</a></li>
<li><a href="#plans">Plans</a></li>
<li><a href="#clients">Official client libraries</a></li>
<li><a href="#conventions">Conventions</a></li>
<li><a href="#endpoints">Endpoints</a> — all 37, with parameters and responses</li>
<li><a href="#websocket">WebSocket feed (ULTRA)</a></li>
<li><a href="#faq">FAQ — plans and data depth</a></li>
<li><a href="#schemas">Schemas</a></li>
</ul>
</nav>
<main id="main">
<p>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; <code>GET /history/coverage</code> states the measured numbers.</p><p>Access is tiered (FREE / BASIC / PRO / ULTRA). Each tier includes
everything in the tiers below it; the concrete deltas are:</p><p><code>FREE</code> — self-serve, no card (<a href="https://livetennisapi.com/subscribe/free">https://livetennisapi.com/subscribe/free</a>).
Live and upcoming matches, current scores, players, fixtures, the
tournament catalogue (<code>/tournaments</code>), and your own usage stats.
30 requests/minute, 100/day. No historical results, no market prices,
no model fields, no WebSocket.</p><p><code>BASIC</code> — adds historical data: the completed-match listing
(<code>/history/matches</code>, and <code>status=completed</code> on <code>/matches</code>), the
per-match point-by-point tape with the model win-probability on the
rows where the model ran
(<code>/history/matches/{matchId}</code>), the measured completeness rollup
(<code>/history/coverage</code>), and the results archive (1968–2022) —
deep results (<code>/history/archive/matches</code>), archive player bios
(<code>/history/archive/players</code>), career aggregates
(<code>/history/archive/career</code>) and head-to-head (<code>/h2h</code>).
60 requests/minute, 1,000/day.</p><p><code>PRO</code> — adds match events (<code>/matches/{matchId}/events</code>), market prices
(<code>/markets</code>, <code>/markets/{matchId}/prices</code>, <code>/matches/{matchId}/prices</code>),
the pre-built monthly bulk history packages (<code>/history/packages</code>) and the
rank-ordered rankings listing (<code>/rankings?system=</code>).
300 requests/minute, 10,000/day.</p><p><code>ULTRA</code> — adds model analysis (<code>/matches/{matchId}/analysis</code>), the live
model fields (<code>win_probability_p1</code>, <code>danger</code>) on every score object,
in-play match statistics (<code>/matches/{matchId}/statistics</code>), per-player
as-of ranking records (<code>/rankings?player=</code>), the as-of Elo tape
(<code>/rankings?system=elo</code> — both modes, plus <code>kind=elo</code> bulk packages),
rally construction
(<code>/rally/matches</code>, shot-by-shot charted data), career and per-match
charting stats (<code>/charting/players</code>, <code>/charting/matches/{chartingMatchId}</code>),
the WebSocket live feed at <code>/ws</code> and the high-fan-out push feed
(<code>/ws-token</code>), and outbound webhooks (direct keys). 600 requests/minute,
500,000/day.</p><p>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.</p><p>A call above your tier returns <code>403 {"error":"upgrade_required"}</code> — never
a silent empty result.</p><p>CORS is enabled across the REST surface: every response carries
<code>Access-Control-Allow-Origin: *</code> (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.</p><p>The <code>/history/*</code> 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 (<code>/history/archive/*</code>, <code>/h2h</code>)
ride with the same entitlement — any active History plan, Starter
included, opens them alongside the tape endpoints.
Plans and prices: <a href="https://livetennisapi.com/historical-tennis-data-api">https://livetennisapi.com/historical-tennis-data-api</a></p><p>All timestamps are UTC ISO 8601 with a <code>Z</code> suffix. List endpoints return
<code>{data, meta}</code>; single resources return the object directly. Ignore
unknown fields — additive changes land within v1.</p><p>A native WebSocket live feed (ULTRA) exists at <code>/ws</code> under the same base
URL. Subscribe with one JSON frame whose keys are <code>topics</code> and
(optionally) <code>signals</code>: <code>{"topics":["live-scores"]}</code> — <code>topics</code> may also
name <code>"match:<id>"</code>. The server acks with a <code>subscribed</code> frame, then
pushes <code>score</code> frames on every change plus a <code>ping</code> heartbeat roughly
every 15s. Score frames carry the ULTRA model fields
(<code>win_probability_p1</code>, <code>danger</code>) 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 <code>{"topics":["live-scores"],"signals":["break_point"]}</code> to
also receive <code>break_point</code> and <code>break_point_result</code> frames (schemas
<code>BreakPoint</code> / <code>BreakPointResult</code>). Without <code>signals</code>, score frames only.</p><p><code>signals</code> may also name <code>points</code> — the live per-point event stream: one
<code>point</code> frame (schema <code>PointFrame</code>) per persisted point of your
subscribed matches, ordered per match by <code>seq</code>. The signal is
config-gated and ships OFF by default; the <code>subscribed</code> ack echoes the
signals actually active, so <code>points</code> present in the ack means point
frames will flow and missing means they will not. Frames arrive only for
matches with <code>pbp_coverage: "point"</code> — a <code>game</code>-coverage match sends
none, honestly. Best-effort with NO replay: on reconnect (or to join
mid-match) catch up via <code>GET /matches/{matchId}/points?after_seq=</code> and
dedup by <code>seq</code>.</p><p>Max 2 concurrent connections per key. For high fan-out, <code>GET /ws-token</code>
mints a token for the separate push feed.</p><p>Getting a match id: it is the <code>id</code> field on any match object returned by
<code>GET /matches</code>, <code>GET /fixtures</code> or <code>GET /history/matches</code>, and the same value
works on every route that takes <code>matchId</code>.
</p>
<h2 id="quickstart">Quickstart — no code required</h2>
<p>Paste this into a browser, with your key on the end. That's the whole setup:
no install, no headers, works on a phone.</p>
<pre class="urls"><code>https://api.livetennisapi.com/api/public/v1/matches?status=live&token=YOUR_KEY</code></pre>
<p>You'll get every live match. Here is one, and how to read it:</p>
<dl class="annot">
<dt>"players": { "p1": { "name": "Chase Ferguson" },
"p2": { "name": "Scott Jones" } }</dt>
<dd>who is playing</dd>
<dt>"sets": [1, 0]</dt>
<dd>p1 leads one set to nil</dd>
<dt>"games": [[6, 3], [4, 4]]</dt>
<dd>first list is p1, second is p2 — so 6-4 in the first set, 3-4 in the second</dd>
<dt>"points": ["0", "0"]</dt>
<dd>the game in progress</dd>
<dt>"server": 1</dt>
<dd>p1 is serving (2 = p2)</dd>
</dl>
<p><strong>Every score array is player-major:</strong> the first list belongs to player 1,
the second to player 2. Once that clicks, the rest of the API reads the same way.</p>
<p>Two more you can click, swapping <code>21131</code> for any <code>id</code> from the list above:</p>
<pre class="urls"><code>https://api.livetennisapi.com/api/public/v1/matches/21131?token=YOUR_KEY
https://api.livetennisapi.com/api/public/v1/matches/21131/score?token=YOUR_KEY</code></pre>
<h2 id="base-url">Base URL</h2>
<pre class="urls"><code>https://api.livetennisapi.com/api/public/v1</code></pre>
<h2 id="authentication">Authentication</h2>
<p>Three ways to present your key — all equivalent. Use the header in code; use
<code>?token=</code> when you just want to click a link or test from a browser or phone.
The <code>/health</code> endpoint needs no key.</p>
<pre><code>Authorization: Bearer twjp_...
X-API-Key: twjp_...
?token=twjp_... in the URL — browser-friendly</code></pre>
<p class="meta">A key in a URL can end up in browser history, server logs and referrer
headers, so prefer a header for anything automated or shared. For trying the API out,
clicking a link is the fastest route and that trade-off is fine.</p>
<h2 id="plans">Plans</h2>
<p>Every plan includes everything in the plans below it. The table shows the
<strong>delta</strong> — exactly what each upgrade adds, and the request budget you get.</p>
<div class="scrollx" tabindex="0" role="region" aria-label="Plans and pricing">
<table><caption class="vh">Plans — what each tier adds over the one below, its rate limit and price</caption>
<thead><tr><th scope="col">Plan</th><th scope="col">Adds</th><th scope="col">Rate limit</th><th scope="col">Price</th></tr></thead><tbody>
<tr><th scope="row">FREE</th><td>The current state of the game: live & upcoming matches, current scores, players, fixtures, your usage stats. No history, no market prices, no model fields, no WebSocket.</td><td>30/min · 100/day</td><td>$0 — no card</td></tr>
<tr><th scope="row">BASIC</th><td>Historical data, in two continuous halves: the point-by-point tape (2023→now) — the completed-match listing (<code>/history/matches</code>, <code>status=completed</code>) and the full per-match tape with the model win-probability on the rows where the model ran (<code>/history/matches/{matchId}</code>) and the measured completeness rollup per tour × draw bucket (<code>/history/coverage</code>) — and the results archive (1968–2022): deep results (<code>/history/archive/matches</code>), archive player bios, career aggregates and head-to-head (<code>/h2h</code>).</td><td>60/min · 1,000/day</td><td>$9.99/mo</td></tr>
<tr><th scope="row">PRO</th><td>Match events, market prices (<code>/markets</code>), the pre-built bulk history packages (<code>/history/packages</code>, JSONL/CSV), and the rank-ordered rankings listing (<code>/rankings?system=</code>).</td><td>300/min · 10,000/day</td><td>$29.99/mo</td></tr>
<tr><th scope="row">ULTRA</th><td>Model analysis, live <code>win_probability_p1</code> + <code>danger</code> on every score, in-play match statistics, live per-point events (<code>/matches/{matchId}/points</code> + the WebSocket <code>point</code> frames, where a point-level feed covers the match), per-player as-of ranking records, the as-of Elo tape (<code>/rankings?system=elo</code>), rally construction (shot-by-shot charted data), the WebSocket push feed, outbound webhooks.</td><td>600/min · 500,000/day</td><td>$99.99/mo</td></tr>
</tbody></table></div>
<p class="scrollnote">The table above scrolls sideways.</p>
<p>Calling an endpoint above your plan returns <code>403 {"error":"upgrade_required"}</code> —
never a silent empty result. <a href="https://livetennisapi.com/#pricing">See pricing</a>.</p>
<h3 id="history-plans">Historical Data API — standalone plans</h3>
<p>The <code>/history</code> endpoints are also sold on their own, without a live-API
subscription:</p>
<div class="scrollx" tabindex="0" role="region" aria-label="Historical Data API plans">
<table><caption class="vh">Historical Data API plans — what each adds</caption>
<thead><tr><th scope="col">Plan</th><th scope="col">Adds</th></tr></thead><tbody>
<tr><th scope="row">Starter</th><td>Single-match point-by-point tape reads via the API — the tape plus the model win-probability where computed — for all tours (ATP, WTA, Challenger, ITF), one match per request. No bulk downloads.</td></tr>
<tr><th scope="row">Pro</th><td>Everything in Starter, plus bulk monthly package downloads and higher rate limits.</td></tr>
<tr><th scope="row">Business</th><td>Everything in Pro, plus year-scale archive exports, top rate limits and priority support.</td></tr>
<tr><th scope="row">One-off passes</th><td>1-month and 1-year access passes, no subscription.</td></tr>
</tbody></table></div>
<p class="scrollnote">The table above scrolls sideways.</p>
<p>Plans and prices: <a href="https://livetennisapi.com/historical-tennis-data-api">https://livetennisapi.com/historical-tennis-data-api</a>.</p>
<h3 id="alerts-plans">Break-point Alerts — hosted alerts, no code</h3>
<p>A hosted companion product that pushes break-point alerts to your channels
(the same signal the ULTRA WebSocket <code>break_point</code> frame carries, without
running a client). <strong>Free</strong>: high-swing break points only (probability swing
≥ 0.15), one delivery channel. <strong>Pro ($9.99/mo)</strong>: every break point — no
swing floor — to unlimited channels: Telegram, Discord, email, SMS, WhatsApp.
Details: <a href="https://livetennisapi.com">livetennisapi.com</a>.</p>
<h2 id="clients">Official client libraries</h2>
<div class="scrollx" tabindex="0" role="region" aria-label="Official client libraries">
<table class="linkrow"><caption class="vh">Official client libraries — install command and source repository</caption>
<thead><tr><th scope="col">Language</th><th scope="col">Install</th><th scope="col">Source</th></tr></thead><tbody>
<tr><th scope="row">Python</th><td><code>pip install livetennisapi</code></td><td><a href="https://github.com/livetennisapi/livetennisapi-python">livetennisapi-python</a></td></tr>
<tr><th scope="row">JavaScript / TypeScript</th><td><code>npm install livetennisapi</code></td><td><a href="https://github.com/livetennisapi/livetennisapi-js">livetennisapi-js</a></td></tr>
<tr><th scope="row">MCP server (LLM agents)</th><td><code>npx livetennisapi-mcp</code></td><td><a href="https://github.com/livetennisapi/livetennisapi-mcp">livetennisapi-mcp</a></td></tr>
</tbody></table></div>
<h2 id="conventions">Conventions</h2>
<ul>
<li>Timestamps are UTC ISO 8601 with a <code>Z</code> suffix.</li>
<li>List endpoints return <code>{data, meta}</code>; single resources return the object directly.</li>
<li><code>limit</code> defaults to 50; the API rejects anything above 200. Paginate with <code>offset</code>.</li>
<li><strong>Ignore unknown fields.</strong> Additive changes ship within <code>v1</code>, so a client that
rejects unrecognised fields will break. Every official SDK parses permissively.</li>
<li><strong>Score shape:</strong> <code>sets</code> is <code>[sets_p1, sets_p2]</code>.
<code>games</code> is <code>[games_p1, games_p2]</code> where each side is a <em>per-set</em> list —
so <code>[[6,3,2],[4,6,1]]</code> reads 6-4, 3-6, 2-1. It is player-major, not set-major.</li>
</ul>
<h2 id="endpoints">Endpoints</h2>
<ul class="toc">
<li><a href="#healthCheck"><code>GET /health</code> — Liveness probe (no auth)</a></li>
<li><a href="#listMatches"><code>GET /matches</code> — List matches by lifecycle status (FREE)</a></li>
<li><a href="#getMatch"><code>GET /matches/{matchId}</code> — Full match detail (FREE; +market PRO, +analysis ULTRA)</a></li>
<li><a href="#getMatchScore"><code>GET /matches/{matchId}/score</code> — Current score only — lowest-latency REST read (FREE)</a></li>
<li><a href="#listMatchEvents"><code>GET /matches/{matchId}/events</code> — Match events, newest first (PRO)</a></li>
<li><a href="#getMatchAnalysis"><code>GET /matches/{matchId}/analysis</code> — Model analysis for a match (ULTRA)</a></li>
<li><a href="#getMatchStatistics"><code>GET /matches/{matchId}/statistics</code> — In-play statistics — aces, double faults, serve split, hold/break %, break points, service & return points (ULTRA)</a></li>
<li><a href="#getMatchPoints"><code>GET /matches/{matchId}/points</code> — Live per-point events in seq order — the REST catch-up for the WebSocket point stream (ULTRA)</a></li>
<li><a href="#searchPlayers"><code>GET /players</code> — Search players by name (FREE)</a></li>
<li><a href="#getPlayer"><code>GET /players/{playerId}</code> — One player's bio + ranking + cached stats (FREE)</a></li>
<li><a href="#listTournaments"><code>GET /tournaments</code> — Tournament catalogue — the id space `Match.tournament_id` joins (FREE)</a></li>
<li><a href="#getTournament"><code>GET /tournaments/{tournamentId}</code> — One tournament by its stable id (FREE)</a></li>
<li><a href="#listMarkets"><code>GET /markets</code> — Match-winner market(s) for a match (PRO)</a></li>
<li><a href="#getMarketPrices"><code>GET /markets/{matchId}/prices</code> — Market + recent price ticks per side, newest first (PRO)</a></li>
<li><a href="#listMatchPrices"><code>GET /matches/{matchId}/prices</code> — Bare price ticks of the mapped match-winner market, newest first (PRO)</a></li>
<li><a href="#listCompletedMatches"><code>GET /history/matches</code> — Completed matches, newest first, with derived winner and tape coverage (BASIC)</a></li>
<li><a href="#getHistoryCoverage"><code>GET /history/coverage</code> — Measured completeness rollup per tour × draw bucket (BASIC)</a></li>
<li><a href="#getMatchTape"><code>GET /history/matches/{matchId}</code> — Per-match tape — point-by-point score + per-point model probabilities (BASIC)</a></li>
<li><a href="#listArchiveMatches"><code>GET /history/archive/matches</code> — Results archive (1968–2022) — deep historical results (BASIC)</a></li>
<li><a href="#getArchiveMatch"><code>GET /history/archive/matches/{archiveId}</code> — One archive result, with serve statistics where recorded (BASIC)</a></li>
<li><a href="#listArchivePlayers"><code>GET /history/archive/players</code> — Archive player bios — hand, DOB, country, height, career-high (BASIC)</a></li>
<li><a href="#getArchiveCareer"><code>GET /history/archive/career</code> — Career aggregates over the results archive, 1968–2022 (BASIC)</a></li>
<li><a href="#getHeadToHead"><code>GET /h2h</code> — Head-to-head across the results archive (1968–2022) and our own completed matches (2023→now) (BASIC)</a></li>
<li><a href="#listHistoryPackages"><code>GET /history/packages</code> — List the pre-built monthly bulk history packages (PRO)</a></li>
<li><a href="#getHistoryPackage"><code>GET /history/packages/{period}</code> — One monthly package — manifest, or the bulk file itself (PRO)</a></li>
<li><a href="#listFixtures"><code>GET /fixtures</code> — Upcoming scheduled fixtures, earliest first (FREE)</a></li>
<li><a href="#getUsage"><code>GET /usage</code> — Your own usage vs quota (FREE — any tier)</a></li>
<li><a href="#listRankings"><code>GET /rankings</code> — Rankings and Elo — rank-ordered listing (PRO) or per-player as-of records (ULTRA); the as-of Elo tape is ULTRA in both modes</a></li>
<li><a href="#listRallyMatches"><code>GET /rally/matches</code> — Charted matches with shot-by-shot data (ULTRA)</a></li>
<li><a href="#getRallyMatch"><code>GET /rally/matches/{rallyMatchId}</code> — Rally construction for one charted match (ULTRA)</a></li>
<li><a href="#getMatchRally"><code>GET /history/matches/{matchId}/rally</code> — Rally construction by OUR match id (ULTRA)</a></li>
<li><a href="#getChartingPlayer"><code>GET /charting/players</code> — Career shot-level charting aggregate for one player (ULTRA)</a></li>
<li><a href="#getChartingMatch"><code>GET /charting/matches/{chartingMatchId}</code> — One charted match, every stat family for both players (ULTRA)</a></li>
<li><a href="#createWebhook"><code>POST /webhooks</code> — Register an outbound webhook (ULTRA, direct keys only)</a></li>
<li><a href="#listWebhooks"><code>GET /webhooks</code> — List your webhooks (ULTRA, direct keys only; never includes the secret)</a></li>
<li><a href="#deleteWebhook"><code>DELETE /webhooks/{webhookId}</code> — Remove one of your webhooks (ULTRA, direct keys only)</a></li>
<li><a href="#createWsToken"><code>GET /ws-token</code> — Mint a connection token for the high-fan-out push feed (ULTRA)</a></li>
</ul>
<section class="op" id="healthCheck">
<h3><span class="method">GET</span> <code>/health</code></h3>
<p class="summary">Liveness probe (no auth)</p>
<p class="meta">Plan required: <strong>—</strong> · operationId: <code>healthCheck</code></p>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /health responses"><table><caption class="vh">GET /health — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>OK</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /health response fields"><table><caption class="vh">GET /health — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>status</code></td><td>string</td><td></td></tr><tr><td><code>version</code></td><td>string</td><td></td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/health</code></pre>
</section>
<section class="op" id="listMatches">
<h3><span class="method">GET</span> <code>/matches</code></h3>
<p class="summary">List matches by lifecycle status (FREE)</p>
<p class="meta">Plan required: <strong>FREE</strong> · operationId: <code>listMatches</code></p>
<p><code>status=live</code> and <code>status=upcoming</code> are the FREE current-state picture. <code>status=completed</code> pages historical results and is part of the paid History product — it requires BASIC (the same rule as <code>/history/matches</code>) and returns <code>403 upgrade_required</code> on a FREE key. The <code>player</code>, <code>country</code>, <code>from</code>/<code>to</code>, <code>tour</code> and <code>draw</code> 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.</p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /matches parameters"><table><caption class="vh">GET /matches — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>status</code></td><td>query</td><td>string (live, upcoming, completed)</td><td>no</td><td><code>live</code> (default) and <code>upcoming</code> are FREE; <code>completed</code> requires BASIC — paging completed results is the history surface. Default <code>live</code>.</td></tr><tr><td><code>tour</code></td><td>query</td><td>string (atp, wta, challenger, itf, juniors)</td><td>no</td><td>Restrict results to one tour. Each value covers its singles and doubles draws, so <code>atp</code> includes ATP doubles and <code>juniors</code> covers the boys' and girls' Grand Slam draws. Omit for all tours. An unrecognised value is a 400 rather than a silent pass-through, so a caller never receives a tour it did not ask for. Applied before pagination, so <code>meta.count</code> reflects the filtered set.</td></tr><tr><td><code>draw</code></td><td>query</td><td>string (singles, doubles)</td><td>no</td><td>Draw filter (added 2026-08-18) — the axis the tour filter deliberately collapses; the two compose (<code>?tour=itf&draw=doubles</code> is the ITF doubles slice). Same vocabulary as the <code>draw</code> field on Match, decided by the same shared definition, so filter and field cannot disagree. A row whose draw is null — a team tie, or no stated event type and no doubles-team participant — matches NEITHER value: null is an answer, not a wildcard. Two honesty notes: on /tournaments the answer comes from the event type alone (a tournament row has no participants to supply the doubles-team evidence matches have), and draw=doubles alone also returns mixed and exhibition doubles that no tour value reaches. An unknown value is a 400 bad_draw with the allowed values.</td></tr><tr><td><code>player</code></td><td>query</td><td>array of integer</td><td>no</td><td>Filter to matches where this player id is EITHER participant. Repeatable (max 50 ids); multiple values return the deduplicated union. An unknown id returns an honest empty list, not an error; a non-integer value is a 400 <code>bad_request</code>. Before 2026-08-03 this parameter was accepted and silently ignored — treat any integration written against that behaviour as unfiltered.</td></tr><tr><td><code>country</code></td><td>query</td><td>string</td><td>no</td><td>Filter to matches where EITHER participant's <code>player.country</code> equals this lowercase 3-letter code — the same vocabulary the Player object returns (IOC-style codes, e.g. <code>ned</code>, <code>sui</code>, <code>gre</code>; NOT ISO-3166). Players with no recorded country never match, so a country filter excludes unknown-nationality matches rather than guessing. A value that is not 3 letters is a 400 <code>bad_country</code>.</td></tr><tr><td><code>from</code></td><td>query</td><td>string</td><td>no</td><td>Earliest play date, <code>YYYY-MM-DD</code> or an ISO-8601 UTC datetime. A bare date covers that whole day. An unparseable value is a 400, never a silently unfiltered 200.</td></tr><tr><td><code>to</code></td><td>query</td><td>string</td><td>no</td><td>Latest play date, same formats as <code>from</code> (a bare date includes everything played that day). <code>from</code> after <code>to</code> is a 400.</td></tr><tr><td><code>limit</code></td><td>query</td><td>integer</td><td>no</td><td> Default <code>50</code>.</td></tr><tr><td><code>offset</code></td><td>query</td><td>integer</td><td>no</td><td> Default <code>0</code>.</td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /matches responses"><table><caption class="vh">GET /matches — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>Matches with latest score</td></tr><tr><td><code>400</code></td><td>Bad query parameter</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /matches response fields"><table><caption class="vh">GET /matches — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>data</code></td><td>array of object</td><td></td></tr><tr><td><code>meta</code></td><td>object</td><td></td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/matches \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="getMatch">
<h3><span class="method">GET</span> <code>/matches/{matchId}</code></h3>
<p class="summary">Full match detail (FREE; +market PRO, +analysis ULTRA)</p>
<p class="meta">Plan required: <strong>FREE</strong> · operationId: <code>getMatch</code></p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /matches/{matchId} parameters"><table><caption class="vh">GET /matches/{matchId} — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>matchId</code></td><td>path</td><td>integer</td><td>yes</td><td>The match id. Every match route shares ONE id space, so the same value works everywhere <code>matchId</code> appears.
Get it from any match list, where it is the <code>id</code> field of each match object:
<code>GET /matches?status=live</code> (in progress), <code>GET /matches?status=upcoming</code> or <code>GET /fixtures</code> (scheduled), <code>GET /matches?status=completed</code> or <code>GET /history/matches</code> (finished).
Ids are stable for the life of a match, so one captured before it starts still resolves after it finishes.
</td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /matches/{matchId} responses"><table><caption class="vh">GET /matches/{matchId} — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>Match with score; <code>market</code> embed at PRO+, <code>analysis</code> embed at ULTRA</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>404</code></td><td>No such resource, or no data yet</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /matches/{matchId} response fields"><table><caption class="vh">GET /matches/{matchId} — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>id</code></td><td>integer</td><td></td></tr><tr><td><code>tournament</code></td><td>string</td><td></td></tr><tr><td><code>tour</code></td><td>string or null (atp, wta, challenger, itf, juniors, null)</td><td>The tour, in the SAME vocabulary the <code>tour</code> query filter accepts — both are derived from one registry, so a match selected by <code>?tour=X</code> always carries that value in <code>tour</code>. Null when the feed never stated a tour or the event type has no public tour name (exhibitions, team and mixed events) — never guessed. Safe to group and filter on; never parse the tournament name for this.</td></tr><tr><td><code>tournament_id</code></td><td>string or null</td><td>Stable tournament identity — one id per tournament × event type, stable across seasons. Joins <code>GET /tournaments/{tournamentId}</code>. Null on matches ingested before the catalogue covered their tournament. (Edge case — a match mislabelled as qualifying by the feed has its id re-pointed to the main-draw tournament when detected, so the id can change once, early, in that direction only.)</td></tr><tr><td><code>surface</code></td><td>string or null (hard, clay, grass, null)</td><td></td></tr><tr><td><code>indoor</code></td><td>boolean</td><td></td></tr><tr><td><code>format</code></td><td>string or null (BO3, BO5, null)</td><td></td></tr><tr><td><code>round</code></td><td>string or null</td><td></td></tr><tr><td><code>round_code</code></td><td>string or null (F, SF, QF, R16, R32, R64, R128, RR, BR, Q, Q1, Q2, Q3, Q4, ER, null)</td><td>The round in the archive's controlled vocabulary, normalized from the free-text label above (<code>Q</code> = qualifying round the feed does not number). This is the field to branch on; it matches <code>/history/archive/matches?round=</code> exactly. Null when the label is unrecognised — never guessed.</td></tr><tr><td><code>status</code></td><td>string (upcoming, live, completed, cancelled)</td><td></td></tr><tr><td><code>event_status</code></td><td>string or null (Retired, Cancelled, Walk Over, Postponed, Interrupted, null)</td><td>How the match ended (or paused) when it did not run its course: retirement, cancellation, walkover, postponement, or an in-play suspension (<code>Interrupted</code> — rain/darkness/medical; the match is paused, not over). NULL means the match completed normally OR the outcome was never resolved — the feed does not distinguish those. Two honest caveats: the value is cleared if a suspended match resumes (no record of the pause survives), and for <code>Retired</code>/<code>Walk Over</code> the withdrawing player is reported in <code>withdrew</code>, where derivable.</td></tr><tr><td><code>event_status_updated_at</code></td><td>string or null</td><td>When <code>event_status</code> last CHANGED, UTC (ISO-8601, <code>Z</code>) — added 2026-08-19. The instant WE recorded the walkover / retirement / cancellation / postponement / suspension (or its clearing), not when the tournament desk or the feed did: this is the field to measure our admin-status latency with. Bumps only on a change of value (a re-read of the same status never moves it; a clear back to null does). Null while <code>event_status</code> has never changed since the field was introduced (2026-08-19) — never backfilled, never guessed.</td></tr><tr><td><code>is_doubles</code></td><td>boolean</td><td>Doubles match — kept for compatibility, and LOSSY. Evidence order: a doubles-team participant proves true regardless of the event type; otherwise the feed's event type decides. The loss: false also covers "unknown" — a match with no stated event type and no team participant reads false here, which is not a claim of singles. Prefer <code>draw</code>, whose null says so honestly.</td></tr><tr><td><code>draw</code></td><td>string or null (singles, doubles, null)</td><td>The honest THREE-VALUED draw (added 2026-08-18) — same vocabulary as the <code>?draw=</code> filter, decided by the same shared definition, so filter and field cannot disagree. Evidence order as is_doubles: a doubles-team participant proves doubles over any event type; otherwise the feed's event type decides. Null means neither says anything — the feed stated no event type, or the match is part of a team tie (Davis Cup / BJK Cup / United Cup class), where one event type covers both singles and doubles rubbers and we will not guess which this is. Null is NOT singles.</td></tr><tr><td><code>scheduled_time</code></td><td>string or null</td><td></td></tr><tr><td><code>players</code></td><td>object</td><td></td></tr><tr><td><code>score</code></td><td>object or null</td><td></td></tr><tr><td><code>winner</code></td><td>integer or null</td><td>Completed matches only — derived from final sets. Served for the full archive age: a match older than the live-table window reads its final state from the same store the tape serves, so old completed matches carry a winner too.</td></tr><tr><td><code>withdrew</code></td><td>integer or null</td><td>Completed matches only — which player retired or conceded the walkover (1|2). Present only when <code>event_status</code> is <code>Retired</code>/<code>Walk Over</code> and the winner is derivable; the withdrawer is the loser by the rules of the sport. Absent means "not a withdrawal, or no evidence" — never a guess.</td></tr><tr><td><code>analysis</code></td><td>object</td><td>ULTRA only (absent below)</td></tr><tr><td><code>market</code></td><td>object or null</td><td>PRO+ only (absent below)</td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/matches/18953 \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="getMatchScore">
<h3><span class="method">GET</span> <code>/matches/{matchId}/score</code></h3>
<p class="summary">Current score only — lowest-latency REST read (FREE)</p>
<p class="meta">Plan required: <strong>FREE</strong> · operationId: <code>getMatchScore</code></p>
<p>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 <code>/history/matches/{matchId}?sequence=clean</code>, which works on a LIVE match, not only a completed one. For in-play statistics use <code>/matches/{matchId}/statistics</code> (ULTRA); they are deliberately not on this object, because they can be further behind the match than the score and must carry their own <code>as_of</code>.</p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /matches/{matchId}/score parameters"><table><caption class="vh">GET /matches/{matchId}/score — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>matchId</code></td><td>path</td><td>integer</td><td>yes</td><td>The match id. Every match route shares ONE id space, so the same value works everywhere <code>matchId</code> appears.
Get it from any match list, where it is the <code>id</code> field of each match object:
<code>GET /matches?status=live</code> (in progress), <code>GET /matches?status=upcoming</code> or <code>GET /fixtures</code> (scheduled), <code>GET /matches?status=completed</code> or <code>GET /history/matches</code> (finished).
Ids are stable for the life of a match, so one captured before it starts still resolves after it finishes.
</td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /matches/{matchId}/score responses"><table><caption class="vh">GET /matches/{matchId}/score — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>Current score (ULTRA adds win_probability_p1 + danger)</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>404</code></td><td>No such resource, or no data yet</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /matches/{matchId}/score response fields"><table><caption class="vh">GET /matches/{matchId}/score — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>sets</code></td><td>array of integer</td><td></td></tr><tr><td><code>games</code></td><td>array of array of integer</td><td>[games_p1, games_p2]; each a per-set list</td></tr><tr><td><code>points</code></td><td>array of string or null</td><td>In-game points as tennis strings ("0", "15", "40", "AD"). Entries can be NULL — observed live on completed matches, which also carry empty games arrays. Do not decode into non-nullable strings.</td></tr><tr><td><code>server</code></td><td>integer or null (1, 2, null)</td><td></td></tr><tr><td><code>is_tiebreak</code></td><td>boolean</td><td></td></tr><tr><td><code>win_probability_p1</code></td><td>number or null</td><td></td></tr><tr><td><code>danger</code></td><td>number or null</td><td></td></tr><tr><td><code>timestamp</code></td><td>string or null</td><td></td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/matches/18953/score \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="listMatchEvents">
<h3><span class="method">GET</span> <code>/matches/{matchId}/events</code></h3>
<p class="summary">Match events, newest first (PRO)</p>
<p class="meta">Plan required: <strong>PRO</strong> · operationId: <code>listMatchEvents</code></p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /matches/{matchId}/events parameters"><table><caption class="vh">GET /matches/{matchId}/events — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>matchId</code></td><td>path</td><td>integer</td><td>yes</td><td>The match id. Every match route shares ONE id space, so the same value works everywhere <code>matchId</code> appears.
Get it from any match list, where it is the <code>id</code> field of each match object:
<code>GET /matches?status=live</code> (in progress), <code>GET /matches?status=upcoming</code> or <code>GET /fixtures</code> (scheduled), <code>GET /matches?status=completed</code> or <code>GET /history/matches</code> (finished).
Ids are stable for the life of a match, so one captured before it starts still resolves after it finishes.
</td></tr><tr><td><code>limit</code></td><td>query</td><td>integer</td><td>no</td><td> Default <code>50</code>.</td></tr><tr><td><code>offset</code></td><td>query</td><td>integer</td><td>no</td><td> Default <code>0</code>.</td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /matches/{matchId}/events responses"><table><caption class="vh">GET /matches/{matchId}/events — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>Events</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /matches/{matchId}/events response fields"><table><caption class="vh">GET /matches/{matchId}/events — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>data</code></td><td>array of object</td><td></td></tr><tr><td><code>meta</code></td><td>object</td><td></td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/matches/18953/events \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="getMatchAnalysis">
<h3><span class="method">GET</span> <code>/matches/{matchId}/analysis</code></h3>
<p class="summary">Model analysis for a match (ULTRA)</p>
<p class="meta">Plan required: <strong>ULTRA</strong> · operationId: <code>getMatchAnalysis</code></p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /matches/{matchId}/analysis parameters"><table><caption class="vh">GET /matches/{matchId}/analysis — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>matchId</code></td><td>path</td><td>integer</td><td>yes</td><td>The match id. Every match route shares ONE id space, so the same value works everywhere <code>matchId</code> appears.
Get it from any match list, where it is the <code>id</code> field of each match object:
<code>GET /matches?status=live</code> (in progress), <code>GET /matches?status=upcoming</code> or <code>GET /fixtures</code> (scheduled), <code>GET /matches?status=completed</code> or <code>GET /history/matches</code> (finished).
Ids are stable for the life of a match, so one captured before it starts still resolves after it finishes.
</td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /matches/{matchId}/analysis responses"><table><caption class="vh">GET /matches/{matchId}/analysis — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>Thesis + profile (either may be null)</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>404</code></td><td>No such resource, or no data yet</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /matches/{matchId}/analysis response fields"><table><caption class="vh">GET /matches/{matchId}/analysis — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>thesis</code></td><td>object or null</td><td></td></tr><tr><td><code>profile</code></td><td>object or null</td><td></td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/matches/18953/analysis \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="getMatchStatistics">
<h3><span class="method">GET</span> <code>/matches/{matchId}/statistics</code></h3>
<p class="summary">In-play statistics — aces, double faults, serve split, hold/break %, break points, service & return points (ULTRA)</p>
<p class="meta">Plan required: <strong>ULTRA</strong> · operationId: <code>getMatchStatistics</code></p>
<p>In-play statistics for one match, in TWO families that are deliberately not merged.
DERIVED (the top level of <code>players.pN</code>) 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 (<code>players.pN.measured</code>) 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).
<code>freshness.derived</code> and <code>freshness.measured</code> each carry their own <code>coverage</code> (<code>live</code> | <code>final</code> | <code>stale</code> | <code>none</code> | <code>diverged</code>; <code>final</code> = the closing figures of a completed match — a finished match cannot be "stale", so its <code>age_seconds</code> is null), <code>as_of</code>, <code>age_seconds</code> and <code>describes</code> — the match state the numbers describe. On <code>diverged</code> the measured VALUES are withheld and <code>freshness.measured_divergence</code> says why; the top-level <code>coverage</code> only summarises the response. <code>none</code> 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.</p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /matches/{matchId}/statistics parameters"><table><caption class="vh">GET /matches/{matchId}/statistics — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>matchId</code></td><td>path</td><td>integer</td><td>yes</td><td>The match id. Every match route shares ONE id space, so the same value works everywhere <code>matchId</code> appears.
Get it from any match list, where it is the <code>id</code> field of each match object:
<code>GET /matches?status=live</code> (in progress), <code>GET /matches?status=upcoming</code> or <code>GET /fixtures</code> (scheduled), <code>GET /matches?status=completed</code> or <code>GET /history/matches</code> (finished).
Ids are stable for the life of a match, so one captured before it starts still resolves after it finishes.
</td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /matches/{matchId}/statistics responses"><table><caption class="vh">GET /matches/{matchId}/statistics — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>Statistics with their own coverage and as_of</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>404</code></td><td>No such resource, or no data yet</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /matches/{matchId}/statistics response fields"><table><caption class="vh">GET /matches/{matchId}/statistics — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>match_id</code></td><td>integer</td><td></td></tr><tr><td><code>coverage</code></td><td>string (live, final, stale, none, diverged)</td><td></td></tr><tr><td><code>as_of</code></td><td>string or null</td><td>When the underlying record was last updated (UTC)</td></tr><tr><td><code>age_seconds</code></td><td>integer or null</td><td>Behind the newest SCORE row, not the wall clock</td></tr><tr><td><code>games_counted</code></td><td>integer</td><td></td></tr><tr><td><code>tiebreak_games_excluded</code></td><td>integer</td><td>Tiebreaks are excluded — the live record collapses a whole tiebreak onto one entry</td></tr><tr><td><code>inconsistent_games_excluded</code></td><td>integer</td><td>Games whose recorded outcome is neither a legal hold nor a legal break</td></tr><tr><td><code>sets_covered</code></td><td>array of integer</td><td></td></tr><tr><td><code>freshness</code></td><td>object</td><td>Per-family coverage and age. Branch on this rather than on the top-level <code>coverage</code>, which only summarises the response. The two ages use DIFFERENT clocks and must not be compared: <code>derived.age_seconds</code> is relative to the newest score row (between points there is no new score either, so wall-clock age would report staleness that does not exist), while <code>measured.age_seconds</code> is wall clock, because those are fetched on a fixed cadence.</td></tr><tr><td><code>detail</code></td><td>string</td><td>Present only when coverage is none</td></tr><tr><td><code>players</code></td><td>object or null</td><td></td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/matches/18953/statistics \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="getMatchPoints">
<h3><span class="method">GET</span> <code>/matches/{matchId}/points</code></h3>
<p class="summary">Live per-point events in seq order — the REST catch-up for the WebSocket point stream (ULTRA)</p>
<p class="meta">Plan required: <strong>ULTRA</strong> · operationId: <code>getMatchPoints</code></p>
<p>The live per-point event stream of one match, in <code>seq</code> order. The WebSocket <code>point</code> 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 <code>after_seq</code> set to the last seq you hold, then dedup everything by <code>seq</code> — 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: <code>pbp_coverage: "point"</code> means this match has a true per-point stream; <code>"game"</code> means only the snapshot score path covers it — <code>points</code> 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. <code>quality: "revised"</code> 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: <code>score</code>/<code>sets</code>/<code>games</code> (tiebreaks carry the running count in <code>score</code> with <code>games</code> frozen at the pre-breaker score), its position (<code>set</code>/<code>game</code>/<code>number</code>), <code>server</code> (of the next point), the derived <code>winner</code> (null when not attributable to a single point — never guessed), and <code>ts</code> — 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; <code>after_seq=last_seq</code> fetches the next page while <code>has_more</code> is true. 404 unknown match; 400 <code>points_disabled</code> 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, <code>seq</code> contiguous 1..N. The response field <code>basis</code> says which base served the page: <code>live</code> (the persisted live stream rows — every live match, and any completed match without a measured-complete recorded sequence) or <code>reconstruction</code> (the projected complete sequence; <code>quality</code> is <code>clean</code>, 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 <code>ts</code> is null on every row: the recorded sequence carries no per-point clock and we fabricate none. <code>after_seq</code> pagination and <code>seq</code> dedup work identically on either basis, but the two bases are different sequences: after a match completes and flips to <code>reconstruction</code>, re-read from <code>after_seq=0</code> rather than resuming a live cursor into it.</p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /matches/{matchId}/points parameters"><table><caption class="vh">GET /matches/{matchId}/points — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>matchId</code></td><td>path</td><td>integer</td><td>yes</td><td>The match id. Every match route shares ONE id space, so the same value works everywhere <code>matchId</code> appears.
Get it from any match list, where it is the <code>id</code> field of each match object:
<code>GET /matches?status=live</code> (in progress), <code>GET /matches?status=upcoming</code> or <code>GET /fixtures</code> (scheduled), <code>GET /matches?status=completed</code> or <code>GET /history/matches</code> (finished).
Ids are stable for the life of a match, so one captured before it starts still resolves after it finishes.
</td></tr><tr><td><code>after_seq</code></td><td>query</td><td>integer</td><td>no</td><td>Return only points with <code>seq</code> greater than this — the resume cursor. Pass the <code>last_seq</code> 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 <code>bad_after_seq</code>. Default <code>0</code>.</td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /matches/{matchId}/points responses"><table><caption class="vh">GET /matches/{matchId}/points — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>The point events page, seq order</td></tr><tr><td><code>400</code></td><td>Bad query parameter</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>404</code></td><td>No such resource, or no data yet</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /matches/{matchId}/points response fields"><table><caption class="vh">GET /matches/{matchId}/points — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>match_id</code></td><td>integer</td><td></td></tr><tr><td><code>pbp_coverage</code></td><td>string (point, game)</td><td><code>point</code> = this match has a true per-point stream; <code>game</code> = only the snapshot score path covers it (<code>points</code> is empty — an answer, not an error).</td></tr><tr><td><code>quality</code></td><td>string (clean, revised)</td><td><code>revised</code> = the upstream feed rewrote an already-served prefix at least once during this match; served rows are never edited.</td></tr><tr><td><code>covers_from_start</code></td><td>boolean or null</td><td>Whether the persisted stream OPENS at the match's 0-0 opener — seq 1 exists and is the love-love state — i.e. whether replaying from <code>after_seq=0</code> yields the whole match or joins it mid-play. Null when the match has no rows at all (nothing to judge — null means not measured, never "no").</td></tr><tr><td><code>points</code></td><td>array of object</td><td></td></tr><tr><td><code>last_seq</code></td><td>integer</td><td>The resume cursor — pass as <code>after_seq</code> to continue.</td></tr><tr><td><code>has_more</code></td><td>boolean</td><td></td></tr><tr><td><code>basis</code></td><td>string (live, reconstruction)</td><td>Which base served this page. <code>live</code> = the persisted live stream rows (every live match, and any completed match without a measured-complete recorded sequence); <code>reconstruction</code> = the complete recorded point sequence of a finished match, projected into point frames at read time — includes the match-closing point, <code>seq</code> contiguous 1..N, <code>ts</code> null on every frame. Completeness beats the partial live capture wholesale; the two bases are never interleaved.</td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/matches/18953/points \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="searchPlayers">
<h3><span class="method">GET</span> <code>/players</code></h3>
<p class="summary">Search players by name (FREE)</p>
<p class="meta">Plan required: <strong>FREE</strong> · operationId: <code>searchPlayers</code></p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /players parameters"><table><caption class="vh">GET /players — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>search</code></td><td>query</td><td>string</td><td>no</td><td></td></tr><tr><td><code>limit</code></td><td>query</td><td>integer</td><td>no</td><td> Default <code>50</code>.</td></tr><tr><td><code>offset</code></td><td>query</td><td>integer</td><td>no</td><td> Default <code>0</code>.</td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /players responses"><table><caption class="vh">GET /players — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>Players (ranked first; no stats object on the list)</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /players response fields"><table><caption class="vh">GET /players — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>data</code></td><td>array of object</td><td></td></tr><tr><td><code>meta</code></td><td>object</td><td></td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/players \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="getPlayer">
<h3><span class="method">GET</span> <code>/players/{playerId}</code></h3>
<p class="summary">One player's bio + ranking + cached stats (FREE)</p>
<p class="meta">Plan required: <strong>FREE</strong> · operationId: <code>getPlayer</code></p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /players/{playerId} parameters"><table><caption class="vh">GET /players/{playerId} — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>playerId</code></td><td>path</td><td>integer</td><td>yes</td><td></td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /players/{playerId} responses"><table><caption class="vh">GET /players/{playerId} — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>Player with <code>stats</code> ({ratings, season})</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>404</code></td><td>No such resource, or no data yet</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /players/{playerId} response fields"><table><caption class="vh">GET /players/{playerId} — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>id</code></td><td>integer</td><td></td></tr><tr><td><code>name</code></td><td>string</td><td></td></tr><tr><td><code>tour</code></td><td>string or null</td><td>The record's OWN tour, which is NOT the <code>tour</code> filter vocabulary. It is granular (<code>juniors_boys</code>, <code>juniors_girls</code>, <code>challenger_men</code>) where the filter is grouped (<code>juniors</code>, <code>challenger</code>), and a doubles team reports it UPPERCASE (<code>ATP</code>) where an individual reports lowercase (<code>atp</code>). Treat it as an opaque string; do not parse it into the filter enum.</td></tr><tr><td><code>country</code></td><td>string or null</td><td></td></tr><tr><td><code>ranking</code></td><td>integer or null</td><td></td></tr><tr><td><code>ranking_points</code></td><td>integer or null</td><td></td></tr><tr><td><code>ranking_movement</code></td><td>string or null (up, down, same, null)</td><td></td></tr><tr><td><code>hand</code></td><td>string or null (R, L, null)</td><td></td></tr><tr><td><code>backhand</code></td><td>integer or null (1, 2, null)</td><td></td></tr><tr><td><code>birthday</code></td><td>string or null</td><td></td></tr><tr><td><code>is_doubles_team</code></td><td>boolean</td><td></td></tr><tr><td><code>data_completeness</code></td><td>object</td><td>How much biographical detail is known for this player, so a consumer can distinguish "not in the feed" from "not yet fetched" without probing. Present on every player in a match payload. Lower tours carry far less of it than main tour.</td></tr><tr><td><code>stats</code></td><td>object</td><td>Single-player endpoint only</td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/players/1104 \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="listTournaments">
<h3><span class="method">GET</span> <code>/tournaments</code></h3>
<p class="summary">Tournament catalogue — the id space <code>Match.tournament_id</code> joins (FREE)</p>
<p class="meta">Plan required: <strong>FREE</strong> · operationId: <code>listTournaments</code></p>
<p>Stable tournament identity, one row per tournament × event type, stable across seasons. <code>city</code>/<code>country</code> come from a curated table and <code>category</code> only where our catalogues agree unambiguously on an exact-name join — each is null otherwise, never derived from the tournament name.</p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /tournaments parameters"><table><caption class="vh">GET /tournaments — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>search</code></td><td>query</td><td>string</td><td>no</td><td>Case-insensitive substring match on the tournament name.</td></tr><tr><td><code>tour</code></td><td>query</td><td>string (atp, wta, challenger, itf, juniors)</td><td>no</td><td>Restrict results to one tour. Each value covers its singles and doubles draws, so <code>atp</code> includes ATP doubles and <code>juniors</code> covers the boys' and girls' Grand Slam draws. Omit for all tours. An unrecognised value is a 400 rather than a silent pass-through, so a caller never receives a tour it did not ask for. Applied before pagination, so <code>meta.count</code> reflects the filtered set.</td></tr><tr><td><code>draw</code></td><td>query</td><td>string (singles, doubles)</td><td>no</td><td>Draw filter (added 2026-08-18) — the axis the tour filter deliberately collapses; the two compose (<code>?tour=itf&draw=doubles</code> is the ITF doubles slice). Same vocabulary as the <code>draw</code> field on Match, decided by the same shared definition, so filter and field cannot disagree. A row whose draw is null — a team tie, or no stated event type and no doubles-team participant — matches NEITHER value: null is an answer, not a wildcard. Two honesty notes: on /tournaments the answer comes from the event type alone (a tournament row has no participants to supply the doubles-team evidence matches have), and draw=doubles alone also returns mixed and exhibition doubles that no tour value reaches. An unknown value is a 400 bad_draw with the allowed values.</td></tr><tr><td><code>limit</code></td><td>query</td><td>integer</td><td>no</td><td> Default <code>50</code>.</td></tr><tr><td><code>offset</code></td><td>query</td><td>integer</td><td>no</td><td> Default <code>0</code>.</td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /tournaments responses"><table><caption class="vh">GET /tournaments — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>Tournaments, name order</td></tr><tr><td><code>400</code></td><td>Bad query parameter</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /tournaments response fields"><table><caption class="vh">GET /tournaments — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>data</code></td><td>array of object</td><td></td></tr><tr><td><code>meta</code></td><td>object</td><td></td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/tournaments \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="getTournament">
<h3><span class="method">GET</span> <code>/tournaments/{tournamentId}</code></h3>
<p class="summary">One tournament by its stable id (FREE)</p>
<p class="meta">Plan required: <strong>FREE</strong> · operationId: <code>getTournament</code></p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /tournaments/{tournamentId} parameters"><table><caption class="vh">GET /tournaments/{tournamentId} — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>tournamentId</code></td><td>path</td><td>string</td><td>yes</td><td>The <code>tournament_id</code> carried on match objects.</td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /tournaments/{tournamentId} responses"><table><caption class="vh">GET /tournaments/{tournamentId} — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>The tournament</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>404</code></td><td>No such resource, or no data yet</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /tournaments/{tournamentId} response fields"><table><caption class="vh">GET /tournaments/{tournamentId} — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>id</code></td><td>string</td><td>The stable id <code>Match.tournament_id</code> joins.</td></tr><tr><td><code>name</code></td><td>string or null</td><td></td></tr><tr><td><code>tour</code></td><td>string or null (atp, wta, challenger, itf, juniors, null)</td><td></td></tr><tr><td><code>surface</code></td><td>string or null (hard, clay, grass, null)</td><td></td></tr><tr><td><code>indoor</code></td><td>boolean</td><td></td></tr><tr><td><code>city</code></td><td>string or null</td><td>Host city, from a curated table — null where not curated.</td></tr><tr><td><code>country</code></td><td>string or null</td><td>Host country, ISO-3166 alpha-2 — null where not curated. (NOTE this differs from <code>player.country</code> and the <code>?country=</code> filter, which use IOC-style lowercase 3-letter codes.)</td></tr><tr><td><code>category</code></td><td>string or null (grand_slam, masters_1000, tour_finals, atp_500, atp_250, wta_1000, wta_500, wta_250, wta_125, challenger, itf, juniors, null)</td><td>Tournament category where our catalogues agree unambiguously on an exact-name join — null otherwise, never derived from the name.</td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/tournaments/{tournamentId} \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="listMarkets">
<h3><span class="method">GET</span> <code>/markets</code></h3>
<p class="summary">Match-winner market(s) for a match (PRO)</p>
<p class="meta">Plan required: <strong>PRO</strong> · operationId: <code>listMarkets</code></p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /markets parameters"><table><caption class="vh">GET /markets — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>match_id</code></td><td>query</td><td>integer</td><td>yes</td><td></td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /markets responses"><table><caption class="vh">GET /markets — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>Markets</td></tr><tr><td><code>400</code></td><td>Bad query parameter</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /markets response fields"><table><caption class="vh">GET /markets — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>data</code></td><td>array of object</td><td></td></tr><tr><td><code>meta</code></td><td>object</td><td></td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/markets \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="getMarketPrices">
<h3><span class="method">GET</span> <code>/markets/{matchId}/prices</code></h3>
<p class="summary">Market + recent price ticks per side, newest first (PRO)</p>
<p class="meta">Plan required: <strong>PRO</strong> · operationId: <code>getMarketPrices</code></p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /markets/{matchId}/prices parameters"><table><caption class="vh">GET /markets/{matchId}/prices — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>matchId</code></td><td>path</td><td>integer</td><td>yes</td><td>The match id. Every match route shares ONE id space, so the same value works everywhere <code>matchId</code> appears.
Get it from any match list, where it is the <code>id</code> field of each match object:
<code>GET /matches?status=live</code> (in progress), <code>GET /matches?status=upcoming</code> or <code>GET /fixtures</code> (scheduled), <code>GET /matches?status=completed</code> or <code>GET /history/matches</code> (finished).
Ids are stable for the life of a match, so one captured before it starts still resolves after it finishes.
</td></tr><tr><td><code>limit</code></td><td>query</td><td>integer</td><td>no</td><td> Default <code>50</code>.</td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /markets/{matchId}/prices responses"><table><caption class="vh">GET /markets/{matchId}/prices — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>Market with <code>prices</code></td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>404</code></td><td>No such resource, or no data yet</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /markets/{matchId}/prices response fields"><table><caption class="vh">GET /markets/{matchId}/prices — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>id</code></td><td>integer</td><td></td></tr><tr><td><code>question</code></td><td>string or null</td><td></td></tr><tr><td><code>status</code></td><td>string or null (active, resolved, closed, null)</td><td></td></tr><tr><td><code>volume</code></td><td>number or null</td><td></td></tr><tr><td><code>liquidity</code></td><td>number or null</td><td></td></tr><tr><td><code>end_date</code></td><td>string or null</td><td></td></tr><tr><td><code>prices</code></td><td>array of object</td><td>Prices endpoint / match detail only; newest first</td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/markets/18953/prices \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="listMatchPrices">
<h3><span class="method">GET</span> <code>/matches/{matchId}/prices</code></h3>
<p class="summary">Bare price ticks of the mapped match-winner market, newest first (PRO)</p>
<p class="meta">Plan required: <strong>PRO</strong> · operationId: <code>listMatchPrices</code></p>
<p>Recent ticks only (no market wrapper). <code>limit</code> caps at 500; <code>minutes</code> bounds the lookback window. 404 when the match has no mapped market.</p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /matches/{matchId}/prices parameters"><table><caption class="vh">GET /matches/{matchId}/prices — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>matchId</code></td><td>path</td><td>integer</td><td>yes</td><td>The match id. Every match route shares ONE id space, so the same value works everywhere <code>matchId</code> appears.
Get it from any match list, where it is the <code>id</code> field of each match object:
<code>GET /matches?status=live</code> (in progress), <code>GET /matches?status=upcoming</code> or <code>GET /fixtures</code> (scheduled), <code>GET /matches?status=completed</code> or <code>GET /history/matches</code> (finished).
Ids are stable for the life of a match, so one captured before it starts still resolves after it finishes.
</td></tr><tr><td><code>limit</code></td><td>query</td><td>integer</td><td>no</td><td> Default <code>100</code>.</td></tr><tr><td><code>minutes</code></td><td>query</td><td>integer</td><td>no</td><td></td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /matches/{matchId}/prices responses"><table><caption class="vh">GET /matches/{matchId}/prices — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>Price ticks</td></tr><tr><td><code>400</code></td><td>Bad query parameter</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>404</code></td><td>No such resource, or no data yet</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /matches/{matchId}/prices response fields"><table><caption class="vh">GET /matches/{matchId}/prices — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>data</code></td><td>array of object</td><td></td></tr><tr><td><code>meta</code></td><td>object</td><td></td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/matches/18953/prices \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="listCompletedMatches">
<h3><span class="method">GET</span> <code>/history/matches</code></h3>
<p class="summary">Completed matches, newest first, with derived winner and tape coverage (BASIC)</p>
<p class="meta">Plan required: <strong>BASIC</strong> · operationId: <code>listCompletedMatches</code></p>
<p>Requires BASIC on the live API, or any Historical Data API plan (Starter and up — see <a href="https://livetennisapi.com/historical-tennis-data-api">https://livetennisapi.com/historical-tennis-data-api</a>). All tours, January 2023 → now (deeper results live in the results archive, 1968–2022, at <code>/history/archive/matches</code>). Filter to a date range with <code>from</code>/<code>to</code>, and by <code>tour</code>, <code>draw</code> (singles/doubles), <code>player</code> (either participant) and <code>country</code> — same vocabulary as <code>/matches</code>. Each item carries a <code>tape</code> 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 <code>?coverage=</code> is applied AFTER the page is cut, so a filtered page is routinely shorter than <code>limit</code> (and may be empty) while later pages still hold matching matches — a short filtered page is not an end-of-data signal; <code>?points_complete=</code> filters the same way.</p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/matches parameters"><table><caption class="vh">GET /history/matches — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>limit</code></td><td>query</td><td>integer</td><td>no</td><td> Default <code>50</code>.</td></tr><tr><td><code>offset</code></td><td>query</td><td>integer</td><td>no</td><td> Default <code>0</code>.</td></tr><tr><td><code>from</code></td><td>query</td><td>string</td><td>no</td><td>Earliest play date, <code>YYYY-MM-DD</code> or an ISO-8601 UTC datetime. A bare date covers that whole day. An unparseable value is a 400, never a silently unfiltered 200.</td></tr><tr><td><code>to</code></td><td>query</td><td>string</td><td>no</td><td>Latest play date, same formats as <code>from</code> (a bare date includes everything played that day). <code>from</code> after <code>to</code> is a 400.</td></tr><tr><td><code>tour</code></td><td>query</td><td>string (atp, wta, challenger, itf, juniors)</td><td>no</td><td>Restrict results to one tour. Each value covers its singles and doubles draws, so <code>atp</code> includes ATP doubles and <code>juniors</code> covers the boys' and girls' Grand Slam draws. Omit for all tours. An unrecognised value is a 400 rather than a silent pass-through, so a caller never receives a tour it did not ask for. Applied before pagination, so <code>meta.count</code> reflects the filtered set.</td></tr><tr><td><code>draw</code></td><td>query</td><td>string (singles, doubles)</td><td>no</td><td>Draw filter (added 2026-08-18) — the axis the tour filter deliberately collapses; the two compose (<code>?tour=itf&draw=doubles</code> is the ITF doubles slice). Same vocabulary as the <code>draw</code> field on Match, decided by the same shared definition, so filter and field cannot disagree. A row whose draw is null — a team tie, or no stated event type and no doubles-team participant — matches NEITHER value: null is an answer, not a wildcard. Two honesty notes: on /tournaments the answer comes from the event type alone (a tournament row has no participants to supply the doubles-team evidence matches have), and draw=doubles alone also returns mixed and exhibition doubles that no tour value reaches. An unknown value is a 400 bad_draw with the allowed values.</td></tr><tr><td><code>player</code></td><td>query</td><td>array of integer</td><td>no</td><td>Filter to matches where this player id is EITHER participant. Repeatable (max 50 ids); multiple values return the deduplicated union. An unknown id returns an honest empty list, not an error; a non-integer value is a 400 <code>bad_request</code>. Before 2026-08-03 this parameter was accepted and silently ignored — treat any integration written against that behaviour as unfiltered.</td></tr><tr><td><code>country</code></td><td>query</td><td>string</td><td>no</td><td>Filter to matches where EITHER participant's <code>player.country</code> equals this lowercase 3-letter code — the same vocabulary the Player object returns (IOC-style codes, e.g. <code>ned</code>, <code>sui</code>, <code>gre</code>; NOT ISO-3166). Players with no recorded country never match, so a country filter excludes unknown-nationality matches rather than guessing. A value that is not 3 letters is a 400 <code>bad_country</code>.</td></tr><tr><td><code>coverage</code></td><td>query</td><td>string (from_start, partial, reconstructed, reconstructed_partial, none)</td><td>no</td><td>Keep only matches whose tape has this coverage. An unknown value is a 400 <code>bad_coverage</code> listing the accepted values in <code>allowed</code>.</td></tr><tr><td><code>points_complete</code></td><td>query</td><td>string (true, false)</td><td>no</td><td>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 <code>?points=complete</code> 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 <code>bad_points_complete</code>. Applied AFTER the page is cut, exactly like <code>?coverage=</code>.</td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/matches responses"><table><caption class="vh">GET /history/matches — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>Completed matches (<code>winner</code> = 1|2|null, from final sets), each with its tape coverage</td></tr><tr><td><code>400</code></td><td>Bad query parameter</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/matches response fields"><table><caption class="vh">GET /history/matches — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>data</code></td><td>array of object</td><td></td></tr><tr><td><code>meta</code></td><td>object</td><td></td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/history/matches \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="getHistoryCoverage">
<h3><span class="method">GET</span> <code>/history/coverage</code></h3>
<p class="summary">Measured completeness rollup per tour × draw bucket (BASIC)</p>
<p class="meta">Plan required: <strong>BASIC</strong> · operationId: <code>getHistoryCoverage</code></p>
<p>Requires BASIC on the live API, or any Historical Data API plan (Starter and up — see <a href="https://livetennisapi.com/historical-tennis-data-api">https://livetennisapi.com/historical-tennis-data-api</a>). 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 <code>as_of</code> (= <code>built_at</code>) dates every number, and <code>ledger_max_computed_at</code> is the newest underlying per-match measurement. Buckets are atp/wta/challenger/itf/juniors × singles/doubles plus <code>other</code> (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 <code>tour</code> and <code>draw</code> fields. <code>method</code> 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.</p>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/coverage responses"><table><caption class="vh">GET /history/coverage — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>The rollup artifact, dated by its own as_of</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr><tr><td><code>503</code></td><td>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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/coverage response fields"><table><caption class="vh">GET /history/coverage — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>as_of</code></td><td>string</td><td>Equal to <code>built_at</code>, verbatim — the artifact's own clock, and the date to quote with every number in this response.</td></tr><tr><td><code>built_at</code></td><td>string</td><td>When the rollup was built (UTC).</td></tr><tr><td><code>ledger_max_computed_at</code></td><td>string or null</td><td>The newest underlying per-match measurement in the completeness ledger.</td></tr><tr><td><code>method</code></td><td>string</td><td>The full measurement rule for point_complete, in one paragraph — every number carries its own definition.</td></tr><tr><td><code>buckets</code></td><td>object</td><td>One CoverageBucket per tour × draw bucket (<code>atp_singles</code> … <code>juniors_doubles</code>, plus <code>other</code>). A bucket with zero completed matches is OMITTED rather than emitted as zeros — read a missing key as "nothing to count", not an error.</td></tr><tr><td><code>totals</code></td><td>object</td><td>The five verifiable numbers for one bucket.</td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/history/coverage \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="getMatchTape">
<h3><span class="method">GET</span> <code>/history/matches/{matchId}</code></h3>
<p class="summary">Per-match tape — point-by-point score + per-point model probabilities (BASIC)</p>
<p class="meta">Plan required: <strong>BASIC</strong> · operationId: <code>getMatchTape</code></p>
<p>The tape is the point-by-point score sequence we hold for this match — every recorded score row including the model fields <code>win_probability_p1</code> and <code>danger</code> 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 <code>meta.coverage</code> and <code>meta.point_source</code> before backtesting. Rows expanded after the fact from a finished-match point-by-point record carry a null <code>timestamp</code> 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 <code>/matches?status=live</code>. <code>/matches/{matchId}/score</code> is one state; this is the sequence of states.</p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/matches/{matchId} parameters"><table><caption class="vh">GET /history/matches/{matchId} — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>matchId</code></td><td>path</td><td>integer</td><td>yes</td><td>The match id. Every match route shares ONE id space, so the same value works everywhere <code>matchId</code> appears.
Get it from any match list, where it is the <code>id</code> field of each match object:
<code>GET /matches?status=live</code> (in progress), <code>GET /matches?status=upcoming</code> or <code>GET /fixtures</code> (scheduled), <code>GET /matches?status=completed</code> or <code>GET /history/matches</code> (finished).
Ids are stable for the life of a match, so one captured before it starts still resolves after it finishes.
</td></tr><tr><td><code>sequence</code></td><td>query</td><td>string (raw, clean)</td><td>no</td><td><code>raw</code> (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. <code>clean</code> returns one row per distinct score state, keeping the last assertion of each. An unknown value is a 400 <code>bad_sequence</code>. Default <code>raw</code>.</td></tr><tr><td><code>points</code></td><td>query</td><td>string (default, complete)</td><td>no</td><td><code>default</code> serves observed rows first — what our own pipeline committed, a SAMPLED record of the match. <code>complete</code> 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 <code>point_winner</code> on every row and null timestamps/model fields per the reconstruction contract. Where none exists, the response is exactly the default read plus <code>meta.points</code> (whose <code>available_complete</code> tells the cases apart) — no error. Cannot combine with <code>sequence=clean</code> (400 <code>bad_combination</code> — the state-key collapse would delete the repeated deuce states a complete point sequence contains). An unknown value is a 400 <code>bad_points</code>; where not yet enabled, <code>complete</code> answers 400 <code>points_read_disabled</code> rather than silently serving the default. <code>coverage</code> and <code>meta.points</code> 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. Default <code>default</code>.</td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/matches/{matchId} responses"><table><caption class="vh">GET /history/matches/{matchId} — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>The full tape (<code>match</code> + <code>tape</code> + <code>profiles</code> + coverage <code>meta</code>)</td></tr><tr><td><code>400</code></td><td>Bad query parameter</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>404</code></td><td>No such resource, or no data yet</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/matches/{matchId} response fields"><table><caption class="vh">GET /history/matches/{matchId} — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>match</code></td><td>object</td><td>Match metadata with the final score embedded.</td></tr><tr><td><code>tape</code></td><td>array of object</td><td>Chronological score sequence — see HistoryTapeRow.</td></tr><tr><td><code>tiebreaks</code></td><td>array or null</td><td>Per-set tiebreak final scores from OBSERVED states only, aligned to the sets of the final scoreline: <code>{"p1", "p2"}</code> for a 7-6 set whose observed maximum tiebreak state is a valid terminal shape (max >= 7, margin >= 2), null per set otherwise — a breaker whose closing point the feed skipped reads null rather than an under-report. Null when the match has no 7-6 set. Present on <code>raw</code> and <code>clean</code> alike.</td></tr><tr><td><code>profiles</code></td><td>array of object</td><td>Model profiles produced during the match, oldest first.</td></tr><tr><td><code>meta</code></td><td>object</td><td></td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/history/matches/18953 \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="listArchiveMatches">
<h3><span class="method">GET</span> <code>/history/archive/matches</code></h3>
<p class="summary">Results archive (1968–2022) — deep historical results (BASIC)</p>
<p class="meta">Plan required: <strong>BASIC</strong> · operationId: <code>listArchiveMatches</code></p>
<p>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 <code>/matches</code> — 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. <code>event_date</code> is the TOURNAMENT START date, the only date records of this era carry.</p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/archive/matches parameters"><table><caption class="vh">GET /history/archive/matches — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>tour</code></td><td>query</td><td>string (atp, wta)</td><td>no</td><td>The archive covers the ATP and WTA corpora only.</td></tr><tr><td><code>name</code></td><td>query</td><td>string</td><td>no</td><td>Case-insensitive substring match on EITHER player's name (min 3 chars).</td></tr><tr><td><code>from</code></td><td>query</td><td>string</td><td>no</td><td>Earliest tournament start date (<code>YYYY-MM-DD</code>).</td></tr><tr><td><code>to</code></td><td>query</td><td>string</td><td>no</td><td>Latest tournament start date (<code>YYYY-MM-DD</code>).</td></tr><tr><td><code>round</code></td><td>query</td><td>string (F, SF, QF, R16, R32, R64, R128, RR, BR, Q1, Q2, Q3, Q4, ER)</td><td>no</td><td>The archive's controlled round vocabulary.</td></tr><tr><td><code>level</code></td><td>query</td><td>string</td><td>no</td><td>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.</td></tr><tr><td><code>limit</code></td><td>query</td><td>integer</td><td>no</td><td> Default <code>50</code>.</td></tr><tr><td><code>offset</code></td><td>query</td><td>integer</td><td>no</td><td> Default <code>0</code>.</td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/archive/matches responses"><table><caption class="vh">GET /history/archive/matches — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>Archive results, newest tournament first</td></tr><tr><td><code>400</code></td><td>Bad query parameter</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/archive/matches response fields"><table><caption class="vh">GET /history/archive/matches — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>data</code></td><td>array of object</td><td></td></tr><tr><td><code>meta</code></td><td>object</td><td></td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/history/archive/matches \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="getArchiveMatch">
<h3><span class="method">GET</span> <code>/history/archive/matches/{archiveId}</code></h3>
<p class="summary">One archive result, with serve statistics where recorded (BASIC)</p>
<p class="meta">Plan required: <strong>BASIC</strong> · operationId: <code>getArchiveMatch</code></p>
<p>Same entitlement as the archive listing. <code>stats</code> is null for the (mostly pre-1991) rows the source never recorded statistics for — never synthesised.</p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/archive/matches/{archiveId} parameters"><table><caption class="vh">GET /history/archive/matches/{archiveId} — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>archiveId</code></td><td>path</td><td>integer</td><td>yes</td><td></td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/archive/matches/{archiveId} responses"><table><caption class="vh">GET /history/archive/matches/{archiveId} — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>The archive record, <code>stats</code> included where the era recorded them</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>404</code></td><td>No such resource, or no data yet</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/archive/matches/{archiveId} response fields"><table><caption class="vh">GET /history/archive/matches/{archiveId} — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>id</code></td><td>integer</td><td></td></tr><tr><td><code>source_id</code></td><td>string</td><td></td></tr><tr><td><code>tour</code></td><td>string (atp, wta)</td><td></td></tr><tr><td><code>level</code></td><td>string or null</td><td>Source tier code (G/M/A/F/D/C/O, or a futures category code as published).</td></tr><tr><td><code>tournament</code></td><td>string or null</td><td></td></tr><tr><td><code>surface</code></td><td>string or null</td><td></td></tr><tr><td><code>draw_size</code></td><td>integer or null</td><td></td></tr><tr><td><code>event_date</code></td><td>string or null</td><td>Tournament START date — per-match dates do not exist in this era's records, and we do not invent them.</td></tr><tr><td><code>round</code></td><td>string or null</td><td></td></tr><tr><td><code>best_of</code></td><td>integer or null</td><td></td></tr><tr><td><code>minutes</code></td><td>integer or null</td><td></td></tr><tr><td><code>winner</code></td><td>object</td><td>One participant of an archive result, as the corpus records them.</td></tr><tr><td><code>loser</code></td><td>object</td><td>One participant of an archive result, as the corpus records them.</td></tr><tr><td><code>score</code></td><td>string or null</td><td>The final score as published, e.g. "6-4 7-6(5)", "6-3 RET", "W/O".</td></tr><tr><td><code>outcome</code></td><td>string or null (completed, retired, walkover, default, abandoned, null)</td><td>Parsed from the score's own vocabulary; null when unparseable — never guessed.</td></tr><tr><td><code>stats</code></td><td>object or null</td><td>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.</td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/history/archive/matches/{archiveId} \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="listArchivePlayers">
<h3><span class="method">GET</span> <code>/history/archive/players</code></h3>
<p class="summary">Archive player bios — hand, DOB, country, height, career-high (BASIC)</p>
<p class="meta">Plan required: <strong>BASIC</strong> · operationId: <code>listArchivePlayers</code></p>
<p>People of the results archive (1968–2022), in their own id space — <code>id</code> is the corpus person id that archive match rows carry as <code>winner.player_id</code> / <code>loser.player_id</code>, 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.</p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/archive/players parameters"><table><caption class="vh">GET /history/archive/players — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>name</code></td><td>query</td><td>string</td><td>no</td><td>Case-insensitive substring filter (min 3 chars).</td></tr><tr><td><code>tour</code></td><td>query</td><td>string (atp, wta)</td><td>no</td><td></td></tr><tr><td><code>limit</code></td><td>query</td><td>integer</td><td>no</td><td> Default <code>50</code>.</td></tr><tr><td><code>offset</code></td><td>query</td><td>integer</td><td>no</td><td> Default <code>0</code>.</td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/archive/players responses"><table><caption class="vh">GET /history/archive/players — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>Archive people, ordered by name</td></tr><tr><td><code>400</code></td><td>Bad query parameter</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/archive/players response fields"><table><caption class="vh">GET /history/archive/players — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>data</code></td><td>array of object</td><td></td></tr><tr><td><code>meta</code></td><td>object</td><td></td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/history/archive/players \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="getArchiveCareer">
<h3><span class="method">GET</span> <code>/history/archive/career</code></h3>
<p class="summary">Career aggregates over the results archive, 1968–2022 (BASIC)</p>
<p class="meta">Plan required: <strong>BASIC</strong> · operationId: <code>getArchiveCareer</code></p>
<p>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 <code>/history/archive/matches</code> — nothing is modelled. <code>serve.matches_with_stats</code> 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 <code>/h2h</code>); an unknown name is a 404. Requires BASIC, or any Historical Data API plan.</p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/archive/career parameters"><table><caption class="vh">GET /history/archive/career — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>name</code></td><td>query</td><td>string</td><td>yes</td><td>Player name (fragment, min 3 chars — must resolve to one person).</td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/archive/career responses"><table><caption class="vh">GET /history/archive/career — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>The career aggregate body</td></tr><tr><td><code>400</code></td><td>Bad query parameter</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>404</code></td><td>No such resource, or no data yet</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/archive/career response fields"><table><caption class="vh">GET /history/archive/career — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>player</code></td><td>object</td><td></td></tr><tr><td><code>span</code></td><td>object</td><td></td></tr><tr><td><code>record</code></td><td>object</td><td></td></tr><tr><td><code>by_year</code></td><td>array of object</td><td></td></tr><tr><td><code>serve</code></td><td>object</td><td>Summed serve statistics + derived ratios; null ratios where the denominator is zero.</td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/history/archive/career \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="getHeadToHead">
<h3><span class="method">GET</span> <code>/h2h</code></h3>
<p class="summary">Head-to-head across the results archive (1968–2022) and our own completed matches (2023→now) (BASIC)</p>
<p class="meta">Plan required: <strong>BASIC</strong> · operationId: <code>getHeadToHead</code></p>
<p>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 (<code>400 ambiguous_name</code>), because two people summed into one record is a wrong answer, not a convenience. Totals count meetings with a KNOWN winner; <code>undecided</code> counts the rest. Walkovers and retirements are part of the record, and each meeting carries <code>outcome</code> so you can exclude them. Requires BASIC, or any Historical Data API plan. On ULTRA, a per-player <code>stats</code> block adds serve/return/break-point aggregates over the pairing: <code>archive_serve</code> (serve-side, from 1991) and <code>current</code> (2023+, adding return and break-point conversion, aces and winners), each with <code>meetings_with_stats</code>.</p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /h2h parameters"><table><caption class="vh">GET /h2h — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>p1</code></td><td>query</td><td>string</td><td>yes</td><td>First player name (fragment, min 3 chars).</td></tr><tr><td><code>p2</code></td><td>query</td><td>string</td><td>yes</td><td>Second player name (fragment, min 3 chars).</td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /h2h responses"><table><caption class="vh">GET /h2h — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>The head-to-head record; empty totals when no player matches the names</td></tr><tr><td><code>400</code></td><td>Bad query parameter</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /h2h response fields"><table><caption class="vh">GET /h2h — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>players</code></td><td>object or null</td><td>The resolved names ({"p1":{"name"}, "p2":{"name"}}); null when no player matches the fragments.</td></tr><tr><td><code>totals</code></td><td>object</td><td></td></tr><tr><td><code>by_surface</code></td><td>object</td><td>Per-surface win split of the decided meetings; keys are surface names plus 'unknown'.</td></tr><tr><td><code>meetings</code></td><td>array of object</td><td>Newest first, capped at 200. <code>era</code> says which half served the row — 'archive' rows carry <code>archive_match_id</code>/<code>level</code>/<code>score</code>; 'current' rows carry <code>match_id</code>/<code>round_code</code> and read their score from the match endpoints. <code>winner</code> is 1|2 OF THIS H2H (p1/p2 as requested), null when underivable.</td></tr><tr><td><code>stats</code></td><td>object or null</td><td>ULTRA only — per-player serve/return/break-point aggregates over the pairing, keyed <code>p1</code>/<code>p2</code>. Each side carries <code>archive_serve</code> (serve-side figures, meetings from 1991) and <code>current</code> (2023+, adding return and break-point conversion, aces and winners), each with its own <code>meetings_with_stats</code> sample size. Absent below ULTRA.</td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/h2h \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="listHistoryPackages">
<h3><span class="method">GET</span> <code>/history/packages</code></h3>
<p class="summary">List the pre-built monthly bulk history packages (PRO)</p>
<p class="meta">Plan required: <strong>PRO</strong> · operationId: <code>listHistoryPackages</code></p>
<p>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 <code>403 upgrade_required</code>.</p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/packages parameters"><table><caption class="vh">GET /history/packages — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>kind</code></td><td>query</td><td>string (tape, rankings, rally, archive, elo)</td><td>no</td><td>Package family. <code>tape</code> (default) = monthly point-by-point match tapes; <code>rankings</code> = as-of ranking records (ULTRA); <code>rally</code> = the charted rally corpus (shot-by-shot) as YEARLY exports (ULTRA); <code>archive</code> = the results archive (1968–2022) as YEARLY exports, same entitlement as the tape packages; <code>elo</code> = the as-of Elo tape as YEARLY exports (ULTRA). The yearly kinds' <code>period</code> is <code>YYYY</code>, 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. Default <code>tape</code>.</td></tr><tr><td><code>year</code></td><td>query</td><td>string</td><td>no</td><td>Year archive listing — every published month of the year (History Business, a 1-year package, or ULTRA).</td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/packages responses"><table><caption class="vh">GET /history/packages — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>Ready packages, newest period first</td></tr><tr><td><code>400</code></td><td>Bad query parameter</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/packages response fields"><table><caption class="vh">GET /history/packages — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>data</code></td><td>array of object</td><td></td></tr><tr><td><code>meta</code></td><td>object</td><td></td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/history/packages \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="getHistoryPackage">
<h3><span class="method">GET</span> <code>/history/packages/{period}</code></h3>
<p class="summary">One monthly package — manifest, or the bulk file itself (PRO)</p>
<p class="meta">Plan required: <strong>PRO</strong> · operationId: <code>getHistoryPackage</code></p>
<p>Without <code>format</code> returns the package manifest (file set, counts, sha256). With <code>format=jsonl</code> or <code>format=csv</code> streams that file as an attachment. Same entitlement as <code>/history/packages</code>. 404 when the month has not been built yet — list available months first.</p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/packages/{period} parameters"><table><caption class="vh">GET /history/packages/{period} — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>period</code></td><td>path</td><td>string</td><td>yes</td><td>Calendar month, <code>YYYY-MM</code> — except for the yearly kinds (<code>rally</code>, <code>archive</code>, <code>elo</code>), where it is the bare year <code>YYYY</code> (<code>400 bad_period</code> otherwise).</td></tr><tr><td><code>kind</code></td><td>query</td><td>string (tape, rankings, rally, archive, elo)</td><td>no</td><td>Package family; <code>rankings</code>, <code>rally</code> and <code>elo</code> require ULTRA or a History Pro/Business subscription (changed 2026-08-19). <code>rally</code> = the yearly charted rally corpus exports; <code>archive</code> = the yearly results archive (1968–2022) exports, same entitlement as the tape packages; <code>elo</code> = the yearly as-of Elo tape exports. Default <code>tape</code>.</td></tr><tr><td><code>format</code></td><td>query</td><td>string (jsonl, csv)</td><td>no</td><td>Omit for the JSON manifest; set to download the file.</td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/packages/{period} responses"><table><caption class="vh">GET /history/packages/{period} — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>The manifest (no <code>format</code>), or the bulk file as an attachment (<code>format=jsonl</code> streams NDJSON, <code>format=csv</code> streams CSV). A gzipped file (see the manifest's <code>compression</code>) is served as <code>application/gzip</code>, never with <code>Content-Encoding: gzip</code> — the manifest's <code>sha256</code> covers the exact bytes you receive.</td></tr><tr><td><code>400</code></td><td>Bad query parameter</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>404</code></td><td>No such resource, or no data yet</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/packages/{period} response fields"><table><caption class="vh">GET /history/packages/{period} — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>period</code></td><td>string</td><td>Calendar month, <code>YYYY-MM</code> — or the bare year <code>YYYY</code> on the yearly <code>rally</code>/<code>archive</code>/<code>elo</code> kinds.</td></tr><tr><td><code>status</code></td><td>string (ready)</td><td>Only built months are listed or served.</td></tr><tr><td><code>match_count</code></td><td>integer or null</td><td></td></tr><tr><td><code>row_count</code></td><td>integer or null</td><td></td></tr><tr><td><code>files</code></td><td>array of object</td><td>One entry per downloadable format.</td></tr><tr><td><code>built_at</code></td><td>string or null</td><td></td></tr><tr><td><code>kind</code></td><td>string (tape, rankings, rally, archive, elo)</td><td>Present only on non-tape packages, so the shape a tape client already parses is unchanged. On a rankings package <code>match_count</code> is the number of players covered and <code>row_count</code> the number of ranking records; on a rally package the counts are charted matches and points; on an archive package the counts are archive results; on an <code>elo</code> package <code>row_count</code> is the number of rating records.</td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/history/packages/{period} \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="listFixtures">
<h3><span class="method">GET</span> <code>/fixtures</code></h3>
<p class="summary">Upcoming scheduled fixtures, earliest first (FREE)</p>
<p class="meta">Plan required: <strong>FREE</strong> · operationId: <code>listFixtures</code></p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /fixtures parameters"><table><caption class="vh">GET /fixtures — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>tour</code></td><td>query</td><td>string (atp, wta, challenger, itf, juniors)</td><td>no</td><td>Restrict results to one tour. Each value covers its singles and doubles draws, so <code>atp</code> includes ATP doubles and <code>juniors</code> covers the boys' and girls' Grand Slam draws. Omit for all tours. An unrecognised value is a 400 rather than a silent pass-through, so a caller never receives a tour it did not ask for. Applied before pagination, so <code>meta.count</code> reflects the filtered set.</td></tr><tr><td><code>draw</code></td><td>query</td><td>string (singles, doubles)</td><td>no</td><td>Draw filter (added 2026-08-18) — the axis the tour filter deliberately collapses; the two compose (<code>?tour=itf&draw=doubles</code> is the ITF doubles slice). Same vocabulary as the <code>draw</code> field on Match, decided by the same shared definition, so filter and field cannot disagree. A row whose draw is null — a team tie, or no stated event type and no doubles-team participant — matches NEITHER value: null is an answer, not a wildcard. Two honesty notes: on /tournaments the answer comes from the event type alone (a tournament row has no participants to supply the doubles-team evidence matches have), and draw=doubles alone also returns mixed and exhibition doubles that no tour value reaches. An unknown value is a 400 bad_draw with the allowed values.</td></tr><tr><td><code>limit</code></td><td>query</td><td>integer</td><td>no</td><td> Default <code>50</code>.</td></tr><tr><td><code>offset</code></td><td>query</td><td>integer</td><td>no</td><td> Default <code>0</code>.</td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /fixtures responses"><table><caption class="vh">GET /fixtures — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>Fixtures with start time and player ids where resolved — the nulls are real states, not gaps (names are always present)</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /fixtures response fields"><table><caption class="vh">GET /fixtures — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>data</code></td><td>array of object</td><td></td></tr><tr><td><code>meta</code></td><td>object</td><td></td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/fixtures \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="getUsage">
<h3><span class="method">GET</span> <code>/usage</code></h3>
<p class="summary">Your own usage vs quota (FREE — any tier)</p>
<p class="meta">Plan required: <strong>FREE</strong> · operationId: <code>getUsage</code></p>
<p>Durable daily usage for the calling key: tier, limits, today's calls (current to the second) and a 30-day history. The per-minute window is on the X-RateLimit-* headers of every response, not here. Calls to this endpoint are quota-exempt — checking your usage never consumes it.</p>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /usage responses"><table><caption class="vh">GET /usage — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>Usage summary</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /usage response fields"><table><caption class="vh">GET /usage — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>principal</code></td><td>string</td><td>Opaque ref to your own key</td></tr><tr><td><code>tier</code></td><td>string (free, basic, pro, ultra)</td><td></td></tr><tr><td><code>base_tier</code></td><td>string</td><td>Subscription tier; equals <code>tier</code> unless a temporary grant is active</td></tr><tr><td><code>tier_expires_at</code></td><td>string or null</td><td>When a temporary tier grant reverts, else null</td></tr><tr><td><code>channel</code></td><td>string</td><td></td></tr><tr><td><code>limits</code></td><td>object</td><td></td></tr><tr><td><code>today</code></td><td>object</td><td></td></tr><tr><td><code>history</code></td><td>array of object</td><td>Last 30 days, oldest first</td></tr><tr><td><code>as_of</code></td><td>string</td><td></td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/usage \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="listRankings">
<h3><span class="method">GET</span> <code>/rankings</code></h3>
<p class="summary">Rankings and Elo — rank-ordered listing (PRO) or per-player as-of records (ULTRA); the as-of Elo tape is ULTRA in both modes</p>
<p class="meta">Plan required: <strong>PRO</strong> · operationId: <code>listRankings</code></p>
<p>Returns, per ranking system, the newest record effective ON OR BEFORE <code>as_of</code> — never one dated after it. Every other ranking field in this API is the player's CURRENT value joined at read time, so replaying an old match elsewhere shows today's ranks; this endpoint is the point-in-time answer. Systems are never collapsed into a single "rank": ATP/WTA and the ITF circuits carry rank+points, UTR carries a rating with null rank and points because it has neither. <code>meta.coverage.oldest_available</code> gives the earliest date each system can answer for — ITF and UTR observations reach back to 2026-06-01 (append-only per-player history accumulates from 2026-07-29) and nothing earlier can be reconstructed.
TWO MODES — with <code>player</code> ids (ULTRA) — the per-player point-in-time records described above. WITHOUT <code>player</code> (PRO) — the FULL published table in rank order for exactly one <code>system</code>, the newest week at or before <code>as_of</code>; rows carry <code>player_name</code> as published and a null <code>player_id</code> for players outside our roster, so the table has no silent holes. <code>utr</code> has no listing (a rating, not a ranking).
SYSTEM=UTR — observed ratings, honestly bounded. UTR records are observed from UTR's public search: a rating UTR withholds appears as ABSENT, never as 0, and <code>rating</code> is the only populated value — <code>rank</code> and <code>points</code> are always null. Per-player as-of ONLY — there is deliberately no UTR listing, because a table of only the players we happen to track would be a fake leaderboard. Per-player history accumulates from 2026-07-29; scattered earlier single-snapshot observations reach back to 2026-06-01. Coverage is a deliberate bias, not a roster mirror: the 24-hour sweep targets players with no official rank and no Elo rating (so it skews ITF), and among players active in the last 60 days it holds 931 of 5,606 ITF players (16.6%), 197 of 1,903 Challenger (10.4%), 43 of 573 WTA (7.5%) and 15 of 525 ATP (2.9%) — measured 2026-08-17.
SYSTEM=ELO — THE AS-OF ELO TAPE (ULTRA in BOTH modes). Our own computed Elo for 65,622 players on four independent ladders (overall, hard, clay, grass), back to 1877 (ATP) and 1968 (WTA), covering the main tours plus challengers plus the futures tier. It answers what a player was rated BEFORE a given match, which is the only shape a backtest can consume. It is a HISTORICAL TAPE rather than a live leaderboard — the corpus behind it is fixed and no longer receives new results, so <code>meta.coverage.newest_available</code> states the tape's head date on every response (2026-06-15 at publication), and you should read it before treating the table as current. A week's results become effective 14 days after that week begins — strictly after the longest event in tennis — so the failure direction is staleness, never look-ahead. <code>rating</code> is the Elo. <code>rank</code> is LISTING MODE ONLY and is null in per-player mode, because an Elo has no global rank at a past instant until you say which field and which activity window you mean. <code>points</code> is always null. <code>matches</code> is the count on THAT ladder, published so that a rating still near its 1500 cold start is visible rather than inferred. A ladder a player has never played is omitted, never substituted. Ratings are on our own scale, are not comparable with Elo published elsewhere, and do not decay — a surface played only a few weeks a year (grass in particular) moves slowly, so a rising player can sit below an established one for several seasons while beating them. A CURRENT per-player Elo remains free on <code>GET /players/{id}</code>; this is the point-in-time series, the leaderboard and the bulk export. That free rating is sourced differently and sits on a DIFFERENT scale — the two differ by roughly 150 Elo of per-player standard deviation — so never present a rating from one scale against a rating from the other. The Elo listing REQUIRES <code>tour</code> — the ATP and WTA walks are disjoint, so a combined leaderboard would not be comparable — and takes exactly one <code>surface</code> (default <code>overall</code>). <code>elo</code> is never implicit — omitting <code>system</code> returns the official systems only.</p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /rankings parameters"><table><caption class="vh">GET /rankings — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>player</code></td><td>query</td><td>array of integer</td><td>no</td><td>Player id — repeatable, max 50 (ULTRA per-player mode). OMIT it for the rank-ordered listing mode (PRO), which then requires exactly one <code>system</code>.</td></tr><tr><td><code>archive_player</code></td><td>query</td><td>array of integer</td><td>no</td><td>Historical-corpus person id — repeatable, max 50, <code>system=elo</code> only, and REQUIRES <code>tour</code> because that id is only unique within one tour. It is the same id <code>GET /history/archive/players</code> returns as its <code>id</code>. Use it to reach the ~62,000 rated people who have no row in our player roster; <code>player</code> reaches the rest.</td></tr><tr><td><code>tour</code></td><td>query</td><td>string (atp, wta)</td><td>no</td><td>REQUIRED for an <code>elo</code> listing and for <code>archive_player</code>. The ATP and WTA Elo walks are disjoint universes whose players never meet, so a combined leaderboard would not be comparable.</td></tr><tr><td><code>surface</code></td><td>query</td><td>array of string (overall, hard, clay, grass)</td><td>no</td><td><code>system=elo</code> only. Listing mode takes exactly one (default <code>overall</code>); per-player mode is repeatable and defaults to all four ladders. A ladder the player has never played is omitted, never substituted.</td></tr><tr><td><code>min_matches</code></td><td>query</td><td>integer</td><td>no</td><td><code>system=elo</code> listing only. Minimum matches on that ladder. Every ladder starts at 1500, so without a floor the top of the table is players who won a handful of matches from the starting rating. Echoed back in <code>meta.coverage.qualified</code>. Default <code>20</code>.</td></tr><tr><td><code>activity_weeks</code></td><td>query</td><td>integer</td><td>no</td><td><code>system=elo</code> listing only. The ladder must have moved within this many weeks of <code>as_of</code>. Elo does not decay, so without an activity window every leaderboard is topped permanently by players who have stopped playing. Echoed back in <code>meta.coverage.qualified</code>. Default <code>52</code>.</td></tr><tr><td><code>as_of</code></td><td>query</td><td>string</td><td>no</td><td>YYYY-MM-DD. Omit for the latest known record.</td></tr><tr><td><code>system</code></td><td>query</td><td>array of string (atp, wta, itf_jt, itf_mt, itf_wt, utr, elo)</td><td>no</td><td>Restrict to one or more systems. Omit for all of the official systems — <code>elo</code> is NEVER included implicitly and must be named, so an existing request's response is unchanged. Naming a system your plan does not cover refuses the whole call with 403 rather than silently returning the part you are entitled to.</td></tr><tr><td><code>limit</code></td><td>query</td><td>integer</td><td>no</td><td> Default <code>50</code>.</td></tr><tr><td><code>offset</code></td><td>query</td><td>integer</td><td>no</td><td> Default <code>0</code>.</td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /rankings responses"><table><caption class="vh">GET /rankings — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>Ranking records in force at <code>as_of</code></td></tr><tr><td><code>400</code></td><td>Bad query parameter</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /rankings response fields"><table><caption class="vh">GET /rankings — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>data</code></td><td>array of object</td><td></td></tr><tr><td><code>meta</code></td><td>object</td><td></td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/rankings \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="listRallyMatches">
<h3><span class="method">GET</span> <code>/rally/matches</code></h3>
<p class="summary">Charted matches with shot-by-shot data (ULTRA)</p>
<p class="meta">Plan required: <strong>ULTRA</strong> · operationId: <code>listRallyMatches</code></p>
<p>Charted matches, newest first. RALLY CONSTRUCTION IS THE LAYER BELOW THE TAPE: the tape says what the score became after each point, this says how the point was played.
It has its OWN id space. The charted corpus and our own match table are different populations — the corpus reaches back decades and concentrates on the biggest events, while our matches begin when our own collection did. Keying this on our match ids would hide most of it. Ask this endpoint for the authoritative coverage list rather than assuming a match is charted: charting is human work, so coverage is deep, not universal.</p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /rally/matches parameters"><table><caption class="vh">GET /rally/matches — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>player</code></td><td>query</td><td>string</td><td>no</td><td>Substring match on either player name.</td></tr><tr><td><code>from</code></td><td>query</td><td>string</td><td>no</td><td><code>YYYY-MM-DD</code>.</td></tr><tr><td><code>to</code></td><td>query</td><td>string</td><td>no</td><td><code>YYYY-MM-DD</code>.</td></tr><tr><td><code>surface</code></td><td>query</td><td>string</td><td>no</td><td></td></tr><tr><td><code>gender</code></td><td>query</td><td>string (M, W)</td><td>no</td><td></td></tr><tr><td><code>limit</code></td><td>query</td><td>integer</td><td>no</td><td> Default <code>50</code>.</td></tr><tr><td><code>offset</code></td><td>query</td><td>integer</td><td>no</td><td> Default <code>0</code>.</td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /rally/matches responses"><table><caption class="vh">GET /rally/matches — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>Charted matches, with per-match parse-quality counts</td></tr><tr><td><code>400</code></td><td>Bad query parameter</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /rally/matches response fields"><table><caption class="vh">GET /rally/matches — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>data</code></td><td>array of object</td><td></td></tr><tr><td><code>meta</code></td><td>object</td><td></td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/rally/matches \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="getRallyMatch">
<h3><span class="method">GET</span> <code>/rally/matches/{rallyMatchId}</code></h3>
<p class="summary">Rally construction for one charted match (ULTRA)</p>
<p class="meta">Plan required: <strong>ULTRA</strong> · operationId: <code>getRallyMatch</code></p>
<p>One charted match with its points, in play order. Paged with <code>limit</code>/<code>offset</code>; <code>meta.total</code> is the match's full point count.</p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /rally/matches/{rallyMatchId} parameters"><table><caption class="vh">GET /rally/matches/{rallyMatchId} — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>rallyMatchId</code></td><td>path</td><td>integer</td><td>yes</td><td></td></tr><tr><td><code>limit</code></td><td>query</td><td>integer</td><td>no</td><td> Default <code>50</code>.</td></tr><tr><td><code>offset</code></td><td>query</td><td>integer</td><td>no</td><td> Default <code>0</code>.</td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /rally/matches/{rallyMatchId} responses"><table><caption class="vh">GET /rally/matches/{rallyMatchId} — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>The charted match with its <code>rally</code> points</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>404</code></td><td>No such resource, or no data yet</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /rally/matches/{rallyMatchId} response fields"><table><caption class="vh">GET /rally/matches/{rallyMatchId} — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>rally_match_id</code></td><td>integer</td><td>The id this product is keyed on.</td></tr><tr><td><code>source_id</code></td><td>string</td><td></td></tr><tr><td><code>match_id</code></td><td>integer or null</td><td>OUR match id, when the charted match is also one we hold. Null otherwise — most charted matches predate our own collection.</td></tr><tr><td><code>date</code></td><td>string or null</td><td></td></tr><tr><td><code>tournament</code></td><td>string or null</td><td></td></tr><tr><td><code>round</code></td><td>string or null</td><td></td></tr><tr><td><code>surface</code></td><td>string or null</td><td></td></tr><tr><td><code>gender</code></td><td>string or null (M, W, null)</td><td></td></tr><tr><td><code>best_of</code></td><td>integer or null</td><td></td></tr><tr><td><code>players</code></td><td>array of object</td><td></td></tr><tr><td><code>points</code></td><td>integer</td><td>Charted points in this match.</td></tr><tr><td><code>points_parsed</code></td><td>integer</td><td>How many of them our parser read cleanly — the per-match quality number.</td></tr><tr><td><code>meta</code></td><td>object</td><td></td></tr><tr><td><code>rally</code></td><td>array of object</td><td></td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/rally/matches/{rallyMatchId} \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="getMatchRally">
<h3><span class="method">GET</span> <code>/history/matches/{matchId}/rally</code></h3>
<p class="summary">Rally construction by OUR match id (ULTRA)</p>
<p class="meta">Plan required: <strong>ULTRA</strong> · operationId: <code>getMatchRally</code></p>
<p>Rally construction addressed by OUR match id, resolved through the optional link. Answers <code>404 {"error":"not_charted"}</code> when we hold the match but nobody charted it — deliberately distinct from "no such match", because most of our matches are not charted and a consumer walking the archive must tell them apart.</p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/matches/{matchId}/rally parameters"><table><caption class="vh">GET /history/matches/{matchId}/rally — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>matchId</code></td><td>path</td><td>integer</td><td>yes</td><td>The match id. Every match route shares ONE id space, so the same value works everywhere <code>matchId</code> appears.
Get it from any match list, where it is the <code>id</code> field of each match object:
<code>GET /matches?status=live</code> (in progress), <code>GET /matches?status=upcoming</code> or <code>GET /fixtures</code> (scheduled), <code>GET /matches?status=completed</code> or <code>GET /history/matches</code> (finished).
Ids are stable for the life of a match, so one captured before it starts still resolves after it finishes.
</td></tr><tr><td><code>limit</code></td><td>query</td><td>integer</td><td>no</td><td> Default <code>50</code>.</td></tr><tr><td><code>offset</code></td><td>query</td><td>integer</td><td>no</td><td> Default <code>0</code>.</td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/matches/{matchId}/rally responses"><table><caption class="vh">GET /history/matches/{matchId}/rally — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>The charted match with its <code>rally</code> points</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>404</code></td><td>No such resource, or no data yet</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /history/matches/{matchId}/rally response fields"><table><caption class="vh">GET /history/matches/{matchId}/rally — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>rally_match_id</code></td><td>integer</td><td>The id this product is keyed on.</td></tr><tr><td><code>source_id</code></td><td>string</td><td></td></tr><tr><td><code>match_id</code></td><td>integer or null</td><td>OUR match id, when the charted match is also one we hold. Null otherwise — most charted matches predate our own collection.</td></tr><tr><td><code>date</code></td><td>string or null</td><td></td></tr><tr><td><code>tournament</code></td><td>string or null</td><td></td></tr><tr><td><code>round</code></td><td>string or null</td><td></td></tr><tr><td><code>surface</code></td><td>string or null</td><td></td></tr><tr><td><code>gender</code></td><td>string or null (M, W, null)</td><td></td></tr><tr><td><code>best_of</code></td><td>integer or null</td><td></td></tr><tr><td><code>players</code></td><td>array of object</td><td></td></tr><tr><td><code>points</code></td><td>integer</td><td>Charted points in this match.</td></tr><tr><td><code>points_parsed</code></td><td>integer</td><td>How many of them our parser read cleanly — the per-match quality number.</td></tr><tr><td><code>meta</code></td><td>object</td><td></td></tr><tr><td><code>rally</code></td><td>array of object</td><td></td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/history/matches/18953/rally \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="getChartingPlayer">
<h3><span class="method">GET</span> <code>/charting/players</code></h3>
<p class="summary">Career shot-level charting aggregate for one player (ULTRA)</p>
<p class="meta">Plan required: <strong>ULTRA</strong> · operationId: <code>getChartingPlayer</code></p>
<p>The deepest serve/return profile we hold, from the Match Charting Project: serve placement (deuce/ad × wide/body/T), return depth and outcomes, net and serve-and-volley conversion, clutch break/game/set-point serving and returning, winners and unforced errors by wing, and rally-length and shot-direction tendencies — summed over the player's charted matches. <code>name</code> (min 3 chars) is the key; a fragment matching more than one charted person is refused with the candidate list, and <code>gender=men|women</code> disambiguates. Every field is a raw SUM over the player's Total rows and <code>matches_charted</code> states the sample. COVERAGE IS CURATED — 11,646 charted matches across both tours back to the 1960s, concentrated on the majors, NOT full-slate coverage.</p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /charting/players parameters"><table><caption class="vh">GET /charting/players — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>name</code></td><td>query</td><td>string</td><td>yes</td><td>Player name (fragment, min 3 chars).</td></tr><tr><td><code>gender</code></td><td>query</td><td>string (men, women)</td><td>no</td><td>Disambiguates a fragment that matches one charted person per tour side.</td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /charting/players responses"><table><caption class="vh">GET /charting/players — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>The player's summed charting families with the sample size</td></tr><tr><td><code>400</code></td><td>Bad query parameter</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>404</code></td><td>No such resource, or no data yet</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /charting/players response fields"><table><caption class="vh">GET /charting/players — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>player</code></td><td>object</td><td></td></tr><tr><td><code>matches_charted</code></td><td>integer</td><td></td></tr><tr><td><code>coverage</code></td><td>string</td><td></td></tr><tr><td><code>families</code></td><td>object</td><td>Per-family summed numeric columns.</td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/charting/players \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="getChartingMatch">
<h3><span class="method">GET</span> <code>/charting/matches/{chartingMatchId}</code></h3>
<p class="summary">One charted match, every stat family for both players (ULTRA)</p>
<p class="meta">Plan required: <strong>ULTRA</strong> · operationId: <code>getChartingMatch</code></p>
<p>Every Match Charting Project stat family for one charted match, both players, with the per-set split (row/set 1, 2, Total) exactly as charted. <code>chartingMatchId</code> is this product's own id space (1960–2026, mostly matches with no counterpart in the live table).</p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /charting/matches/{chartingMatchId} parameters"><table><caption class="vh">GET /charting/matches/{chartingMatchId} — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>chartingMatchId</code></td><td>path</td><td>integer</td><td>yes</td><td></td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /charting/matches/{chartingMatchId} responses"><table><caption class="vh">GET /charting/matches/{chartingMatchId} — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>The charted match's stat families, both players, per set</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>404</code></td><td>No such resource, or no data yet</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /charting/matches/{chartingMatchId} response fields"><table><caption class="vh">GET /charting/matches/{chartingMatchId} — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>charting_match_id</code></td><td>integer</td><td></td></tr><tr><td><code>mcp_id</code></td><td>string</td><td></td></tr><tr><td><code>gender</code></td><td>string</td><td></td></tr><tr><td><code>players</code></td><td>object</td><td></td></tr><tr><td><code>families</code></td><td>object</td><td></td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/charting/matches/{chartingMatchId} \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="createWebhook">
<h3><span class="method">POST</span> <code>/webhooks</code></h3>
<p class="summary">Register an outbound webhook (ULTRA, direct keys only)</p>
<p class="meta">Plan required: <strong>ULTRA</strong> · operationId: <code>createWebhook</code></p>
<p>We POST the same frames the WebSocket sends to your HTTPS endpoint on every live score commit. Up to 3 webhooks per key (<code>409 webhook_limit</code> past that). The response is the ONLY time the signing secret is shown — store it.
Each delivery carries <code>X-LTAPI-Signature</code> (<code>sha256=<hex></code> — HMAC-SHA256 of the RAW request body with your webhook secret; verify with a constant-time compare), <code>X-LTAPI-Timestamp</code> (Unix seconds at send time — reject stale replays at your edge) and <code>X-LTAPI-Event</code> (the frame type: <code>score</code>, <code>break_point</code>, <code>break_point_result</code> or <code>point</code>).
Delivery is best-effort, at-most-once, no replay: one attempt per frame with a ~3s timeout and redirects disabled. Every <code>score</code> frame is the complete current score, so a missed delivery self-corrects on the next commit. A <code>point</code> frame is an EVENT, not a state — a missed one does NOT self-correct; recover it with <code>GET /matches/{matchId}/points?after_seq=</code> and dedup by <code>seq</code>. After 25 consecutive failures the webhook is disabled automatically (<code>enabled:false</code>, <code>last_error</code> set — visible in <code>GET /webhooks</code>); delete and re-register to resume.</p>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="POST /webhooks responses"><table><caption class="vh">POST /webhooks — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>201</code></td><td>Created — includes <code>secret</code> (shown exactly once)</td></tr><tr><td><code>400</code></td><td>Bad query parameter</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>409</code></td><td>Webhook limit reached (3 per key) — delete an existing webhook first</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/webhooks \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="listWebhooks">
<h3><span class="method">GET</span> <code>/webhooks</code></h3>
<p class="summary">List your webhooks (ULTRA, direct keys only; never includes the secret)</p>
<p class="meta">Plan required: <strong>ULTRA</strong> · operationId: <code>listWebhooks</code></p>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /webhooks responses"><table><caption class="vh">GET /webhooks — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>Your webhooks</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /webhooks response fields"><table><caption class="vh">GET /webhooks — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>data</code></td><td>array of object</td><td></td></tr><tr><td><code>meta</code></td><td>object</td><td></td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/webhooks \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="deleteWebhook">
<h3><span class="method">DELETE</span> <code>/webhooks/{webhookId}</code></h3>
<p class="summary">Remove one of your webhooks (ULTRA, direct keys only)</p>
<p class="meta">Plan required: <strong>ULTRA</strong> · operationId: <code>deleteWebhook</code></p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="DELETE /webhooks/{webhookId} parameters"><table><caption class="vh">DELETE /webhooks/{webhookId} — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>webhookId</code></td><td>path</td><td>integer</td><td>yes</td><td></td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="DELETE /webhooks/{webhookId} responses"><table><caption class="vh">DELETE /webhooks/{webhookId} — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>Deleted</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>404</code></td><td>No such resource, or no data yet</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="DELETE /webhooks/{webhookId} response fields"><table><caption class="vh">DELETE /webhooks/{webhookId} — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>deleted</code></td><td>integer</td><td></td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/webhooks/{webhookId} \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<section class="op" id="createWsToken">
<h3><span class="method">GET</span> <code>/ws-token</code></h3>
<p class="summary">Mint a connection token for the high-fan-out push feed (ULTRA)</p>
<p class="meta">Plan required: <strong>ULTRA</strong> · operationId: <code>createWsToken</code></p>
<p>Returns a short-lived signed token plus the push WebSocket URL and the channel vocabulary: <code>match:{match_id}</code> per-match streams and <code>slate:all</code> for every live score frame. Frames are the same allowlist score objects the polling endpoints return. This is a separate surface from the native <code>/ws</code> feed described above — same ULTRA gate, built for high fan-out (no shared connection ceiling), and the recommended home for continuous/production streaming.
The endpoint speaks the **Centrifugo client protocol** (v2, JSON). Easiest path: the official Python (<code>livetennisapi</code> ≥ 1.4.0) and JS (≥ 1.5.0) SDKs ship a built-in <code>PushStream</code> client — no extra dependency. Raw protocol, if you prefer your own client: (1) open a WebSocket to <code>ws_url</code>; (2) send <code>{"connect": {"token": "<token>"}, "id": 1}</code> — the token goes INSIDE this JSON frame, never as a raw first message; (3) subscribe per channel with <code>{"subscribe": {"channel": "slate:all"}, "id": 2}</code>; (4) publications arrive as <code>{"push": {"channel": ..., "pub": {"data": <frame>}}}</code>; (5) the server's heartbeat is an empty JSON object <code>{}</code> — reply with <code>{}</code> promptly or you will be disconnected. Messages may batch several newline-delimited JSON objects. Tokens are short-lived and the connection closes around token expiry: mint a fresh token on EVERY reconnect and re-subscribe.
The <code>channels</code> object lists only channels that will actually deliver for your key right now (a channel name in this response is a promise). Where enabled server-side, additional channel families appear: <code>point:match:{match_id}</code> / <code>point:slate</code> (per-point events), listed — as <code>point_match</code> / <code>point_slate</code> in the vocabulary — only for keys whose plan carries the point surface, and <code>signal:match:{match_id}</code> / <code>signal:slate</code> (derived <code>break_point</code>, <code>break_point_result</code> and <code>divergence</code> events). A family absent from the response will not deliver for your key right now. Deliberately separate channels: a <code>slate:all</code> subscriber asked for score states and never starts receiving events unasked. Point and signal frames are events, not states — a missed point does NOT self-correct on the next frame; recover it via <code>GET /matches/{matchId}/points?after_seq=</code> and dedup by <code>seq</code>.</p>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /ws-token responses"><table><caption class="vh">GET /ws-token — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>Connection token, push URL and channel vocabulary</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /ws-token response fields"><table><caption class="vh">GET /ws-token — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>token</code></td><td>string</td><td></td></tr><tr><td><code>expires_in</code></td><td>integer</td><td></td></tr><tr><td><code>ws_url</code></td><td>string</td><td>The push WebSocket URL to connect to with the token.</td></tr><tr><td><code>channels</code></td><td>object</td><td>Channel vocabulary — <code>match</code> is the per-match pattern (<code>match:{id}</code>), <code>slate</code> is the every-live-score channel (<code>slate:all</code>). A channel listed here will actually deliver for your key; one missing will not.</td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/ws-token \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
<h2 id="websocket">WebSocket feed (ULTRA)</h2>
<p>A native WebSocket live feed is available at <code>https://api.livetennisapi.com/api/public/v1/ws</code>. Subscribe with
<code>{"topics":["live-scores"]}</code> or
<code>{"topics":["match:<id>"]}</code>. The server acknowledges with a
<code>subscribed</code> frame, then pushes <code>score</code> frames on every change plus a
<code>ping</code> heartbeat roughly every 15 seconds.</p>
<p>Opt into extra signals by adding a <code>signals</code> array to the subscribe frame —
<code>{"topics":["live-scores"],"signals":["break_point"]}</code> — to also receive a
<code>break_point</code> frame the instant a break point arises and a
<code>break_point_result</code> frame when it resolves. Their shapes are the
<code>BreakPoint</code> and <code>BreakPointResult</code> schemas below. Without
<code>signals</code> the feed pushes <code>score</code> frames only, exactly as before.</p>
<p><code>signals</code> may also name <code>points</code> — the live per-point event
stream: one <code>point</code> frame per persisted point of your subscribed matches
(shape <code>PointFrame</code> below), ordered per match by <code>seq</code>. The signal
is config-gated and ships off by default; the <code>subscribed</code> ack echoes the
signals actually active, so <code>points</code> missing from the ack means no point
frames will flow. Frames arrive only for matches with
<code>pbp_coverage: "point"</code> — a <code>game</code>-coverage match sends none,
honestly. Point frames are events, not states, and there is no WS replay: a missed
one does not self-correct on the next frame — on reconnect, or to join mid-match,
catch up via <code>GET /matches/{matchId}/points?after_seq=</code> and dedup by
<code>seq</code>. The push feed carries the same frames on their own channel family
(<code>point:match:{matchId}</code> and <code>point:slate</code>), deliberately separate
from the score channels.</p>
<h2 id="faq">FAQ — plans and data depth</h2>
<h3 id="faq-how-much">How much data can I access on each plan?</h3>
<p><strong>FREE</strong> sees the current state of the game only — live and upcoming matches,
scores, players and fixtures — at 100 requests/day. <strong>BASIC</strong> adds every
completed match and its full point-by-point tape (with the model
win-probability where computed), one match per request, at 1,000/day.
<strong>PRO</strong> adds whole months of history in a single bulk file (JSONL or CSV),
plus match events and market prices, at 10,000/day. <strong>ULTRA</strong> adds model
analysis, the live model fields and the WebSocket push feed, at 500,000/day.
Coverage is identical on every plan: all tours, ATP through ITF — the plans
differ in which data products and volumes they unlock, never in which
tournaments you see.</p>
<h3 id="faq-how-far-back">How far back does history go?</h3>
<p><strong>1968.</strong> History runs in two continuous, non-overlapping halves. The
<strong>point-by-point tape (2023→now)</strong>: <code>/history/matches</code> pages every completed
match from January 2023 on, all tours, newest first — filter a window with
<code>from</code>/<code>to</code> — with the per-match point-by-point tape at
<code>/history/matches/{matchId}</code>. The <strong>results archive (1968–2022)</strong>:
<code>/history/archive/matches</code> serves winner/loser-shaped RESULTS — ATP and WTA,
main draws, qualifying and the ITF/futures tiers, 1968 through 2022 — with
final score, seeds, ranks at the time, and per-match serve statistics where
the era recorded them (from 1991). The archive ends exactly where the tape
begins, so no match is ever served from two datasets. Bulk: tape packages are
built per calendar month, archive packages per year
(<code>?kind=archive</code>); <code>GET /history/packages</code> lists exactly which periods
exist and is always the authoritative answer. Year-scale exports are part of
the Historical Data API Business plan.</p>
<h3 id="faq-whats-in-tape">What's in the point-by-point tape?</h3>
<p>One row per recorded point state, chronological: <code>sets</code>, per-set <code>games</code>,
in-game <code>points</code>, the <code>server</code>, the tiebreak flag, and the model's
<code>win_probability_p1</code> and <code>danger</code> on the rows where the model ran (null
elsewhere — check <code>meta.model_rows</code>); rows we watched live carry a real
timestamp, reconstructed rows a null one.
<code>GET /history/matches/{matchId}</code> returns it per match (shape
<code>HistoryTape</code>: match metadata + tape + the model profiles produced during the
match). Add <code>?points=complete</code> to opt into a whole-match reconstruction
where one exists — the response's <code>meta.points</code> block reports the measured
point-completeness of exactly the sequence you were served, per match, never as
a blanket claim. Filter the listing by that measured verdict with
<code>?points_complete=true</code> on <code>/history/matches</code>. The monthly bulk
packages' base files carry each match's default read — the same tape the API
serves — and a month may also list the complete-basis addendum files
(<code>tennis_history_points_complete_<period>.jsonl.gz</code>/<code>.csv.gz</code>): the same
tape <code>?points=complete</code> serves, for exactly the matches whose complete point
sequence exists only as the on-disk reconstruction. Existing base files are
never rewritten by the addendum; their <code>sha256</code> values do not move.
Measured completeness also differs sharply by draw on some circuits — as of
2026-08-18, 51.1% of ITF singles matches are point-complete on the best basis
against 3.5% of ITF doubles — which is exactly the split the <code>?draw=</code> filter
and <code>GET /history/coverage</code> (the per-bucket rollup, rebuilt nightly, dated by
its own <code>as_of</code>) exist to expose. Do not extrapolate a completeness rate
across a <code>?tour=</code> group.</p>
<h2 id="schemas">Schemas</h2>
<section id="schema-error">
<h3>Error</h3>
<div class="scrollx" tabindex="0" role="region" aria-label="Error fields"><table><caption class="vh">Error schema — fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead>
<tbody><tr><td><code>error</code></td><td>string</td><td>Stable machine-readable code, e.g. bad_date, bad_coverage, bad_sequence, bad_points, bad_points_complete, bad_combination, points_read_disabled, bad_after_seq, points_disabled, bad_period, bad_year, bad_format, bad_tour, bad_country, ambiguous_name, not_charted, not_found, upgrade_required, rate_limited, abuse_throttled.</td></tr>
<tr><td><code>detail</code></td><td>string</td><td>Human-readable explanation, when one adds anything.</td></tr>
<tr><td><code>allowed</code></td><td>array of string</td><td>On a rejected enumerated parameter, the values that would have been accepted (e.g. the coverage vocabulary for bad_coverage).</td></tr></tbody></table></div>
</section>
<section id="schema-listmeta">
<h3>ListMeta</h3>
<div class="scrollx" tabindex="0" role="region" aria-label="ListMeta fields"><table><caption class="vh">ListMeta schema — fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead>
<tbody><tr><td><code>limit</code></td><td>integer</td><td></td></tr>
<tr><td><code>offset</code></td><td>integer</td><td></td></tr>
<tr><td><code>count</code></td><td>integer</td><td></td></tr>
<tr><td><code>total</code></td><td>integer or null</td><td>Size of the whole filtered set. Null when it cannot be counted cheaply.</td></tr>
<tr><td><code>has_more</code></td><td>boolean</td><td>More results exist beyond this page. Read this rather than comparing count to limit.</td></tr></tbody></table></div>
</section>
<section id="schema-score">
<h3>Score</h3>
<p>ULTRA adds win_probability_p1 + danger.</p>
<div class="scrollx" tabindex="0" role="region" aria-label="Score fields"><table><caption class="vh">Score schema — fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead>