Repository navigation
Expand file tree
/
Copy pathchangelog.html
More file actions
846 lines (831 loc) · 188 KB
/
Copy pathchangelog.html
File metadata and controls
846 lines (831 loc) · 188 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
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Live Tennis API — Changelog</title>
<meta name="description" content="Every change to the Live Tennis API specification, dated and versioned: additive within v1, newest first. Current version 1.13.60, last change 2026-10-03.">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://docs.livetennisapi.com/changelog.html">
<meta property="og:type" content="article">
<meta property="og:title" content="Live Tennis API — Changelog">
<meta property="og:url" content="https://docs.livetennisapi.com/changelog.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 — Changelog",
"description":"Every change to the Live Tennis API specification, dated and versioned.",
"url":"https://docs.livetennisapi.com/changelog.html",
"dateModified":"2026-10-03",
"inLanguage":"en",
"isPartOf":{"@type":"WebSite","name":"Live Tennis API","url":"https://livetennisapi.com"},
"publisher":{"@type":"Organization","@id":"https://livetennisapi.com/#org","name":"JSB Holdings LLC","alternateName":"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 fetchpriority="high">
<link rel="preload" href="fonts/space-grotesk-latin-700.woff2" as="font" type="font/woff2" crossorigin fetchpriority="high">
<link rel="preload" href="fonts/jetbrains-mono-latin-400.woff2" as="font" type="font/woff2" crossorigin fetchpriority="low">
<style>
/* Self-hosted webfonts (latin subsets), shared by index.html and reference.html.
Both pages previously shipped `document.fonts.size === 0` — reference.html
rendered entirely in the system UI stack and Space Grotesk, the product's
display face, appeared on neither page.
These are served from this origin, so the docs have no font dependency they
cannot verify: nothing is fetched from Google Fonts or any other third party,
and a blocked CDN cannot change how the page reads.
Family names must stay EXACTLY 'Inter' / 'Space Grotesk' / 'JetBrains Mono' —
they are the names the rest of the product's stylesheets use, and the ones
Scalar is handed via --scalar-font / --scalar-font-code.
Only the weights actually used are shipped (180 KB total):
Inter 400/500/600/700 body, UI, Scalar's semibold + bold
Space Grotesk 500/700 display: headings
JetBrains Mono 400/500 code, numerals */
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 400; font-display: swap;
src: url('fonts/inter-latin-400.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 500; font-display: swap;
src: url('fonts/inter-latin-500.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 600; font-display: swap;
src: url('fonts/inter-latin-600.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 700; font-display: swap;
src: url('fonts/inter-latin-700.woff2') format('woff2'); }
@font-face { font-family: 'Space Grotesk'; font-style: normal; font-weight: 500; font-display: swap;
src: url('fonts/space-grotesk-latin-500.woff2') format('woff2'); }
@font-face { font-family: 'Space Grotesk'; font-style: normal; font-weight: 700; font-display: swap;
src: url('fonts/space-grotesk-latin-700.woff2') format('woff2'); }
@font-face { font-family: 'JetBrains Mono'; font-style: normal; font-weight: 400; font-display: swap;
src: url('fonts/jetbrains-mono-latin-400.woff2') format('woff2'); }
@font-face { font-family: 'JetBrains Mono'; font-style: normal; font-weight: 500; font-display: swap;
src: url('fonts/jetbrains-mono-latin-500.woff2') format('woff2'); }
</style>
<style>
/* Self-hosted webfonts (latin subsets), shared by index.html and reference.html.
Both pages previously shipped `document.fonts.size === 0` — reference.html
rendered entirely in the system UI stack and Space Grotesk, the product's
display face, appeared on neither page.
These are served from this origin, so the docs have no font dependency they
cannot verify: nothing is fetched from Google Fonts or any other third party,
and a blocked CDN cannot change how the page reads.
Family names must stay EXACTLY 'Inter' / 'Space Grotesk' / 'JetBrains Mono' —
they are the names the rest of the product's stylesheets use, and the ones
Scalar is handed via --scalar-font / --scalar-font-code.
Only the weights actually used are shipped (180 KB total):
Inter 400/500/600/700 body, UI, Scalar's semibold + bold
Space Grotesk 500/700 display: headings
JetBrains Mono 400/500 code, numerals */
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 400; font-display: swap;
src: url('fonts/inter-latin-400.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 500; font-display: swap;
src: url('fonts/inter-latin-500.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 600; font-display: swap;
src: url('fonts/inter-latin-600.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 700; font-display: swap;
src: url('fonts/inter-latin-700.woff2') format('woff2'); }
@font-face { font-family: 'Space Grotesk'; font-style: normal; font-weight: 500; font-display: swap;
src: url('fonts/space-grotesk-latin-500.woff2') format('woff2'); }
@font-face { font-family: 'Space Grotesk'; font-style: normal; font-weight: 700; font-display: swap;
src: url('fonts/space-grotesk-latin-700.woff2') format('woff2'); }
@font-face { font-family: 'JetBrains Mono'; font-style: normal; font-weight: 400; font-display: swap;
src: url('fonts/jetbrains-mono-latin-400.woff2') format('woff2'); }
@font-face { font-family: 'JetBrains Mono'; font-style: normal; font-weight: 500; font-display: swap;
src: url('fonts/jetbrains-mono-latin-500.woff2') format('woff2'); }
</style><style>
/* Documentation shares the product palette while keeping reference content compact. */
:root{--bg:#0b100e;--panel:#131c16;--struct:#2d3b31;--text:#f0f4ee;--muted:#afbeb2;--ours:#b7f875;--accent:#b7f875;--r:8px;color-scheme:dark}
*{box-sizing:border-box}body{background:var(--bg);color:var(--text);font-family:Inter,system-ui,sans-serif;margin:0}a{color:var(--accent);text-underline-offset:4px}a:focus-visible,button:focus-visible,summary:focus-visible,[tabindex]:focus-visible{outline:2px solid #d1ffa7;outline-offset:4px}img{max-width:100%}.docs-site-header{display:flex;align-items:center;gap:24px;justify-content:space-between;max-width:1280px;margin:auto;padding:22px 36px;border-bottom:1px solid var(--struct);position:relative;z-index:2}.docs-brand{font:700 17px/1.3 'Space Grotesk',Inter,sans-serif;display:flex;align-items:center;gap:10px;text-decoration:none;color:var(--text);white-space:nowrap}.docs-brand span{font:400 12px/1.5 Inter,sans-serif;color:var(--muted);border-left:1px solid var(--struct);padding-left:12px;margin-left:5px}.docs-desktop-nav{display:flex;gap:26px}.docs-desktop-nav a{font-size:13px;text-decoration:none;min-height:44px;display:flex;align-items:center;color:var(--muted)}.docs-desktop-nav a:hover,.docs-desktop-nav a[aria-current=page]{color:var(--accent)}.docs-key{display:inline-flex;align-items:center;gap:20px;min-height:44px;padding:8px 15px;background:var(--accent);color:var(--bg);font-size:12px;text-decoration:none;border-radius:7px}.docs-topic-menu{max-width:1208px;margin:0 auto;border-bottom:1px solid var(--struct);color:var(--muted)}.docs-topic-menu summary{cursor:pointer;min-height:44px;padding:14px 0;font-size:12px}.docs-topic-menu nav{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 24px;padding-bottom:16px}.docs-topic-menu a{display:flex;align-items:center;min-height:44px;font-size:12px;color:var(--muted);text-decoration:none}.docs-topic-menu a:hover,.docs-topic-menu a[aria-current]{color:var(--accent)}
.docs-page .wrap{max-width:1120px;padding:58px 36px 80px;min-width:0}.docs-page .wrap>header{padding-bottom:28px;margin-bottom:32px;border-bottom:1px solid var(--struct)}.docs-page h1{font:700 clamp(34px,4.2vw,58px)/1.09 'Space Grotesk',Inter,sans-serif;letter-spacing:-.05em;max-width:22ch;margin:12px 0 24px;overflow-wrap:anywhere}.docs-page h2{font-size:26px;letter-spacing:-.03em;line-height:1.25;margin-top:48px}.docs-page h3{font-size:20px;letter-spacing:-.02em;line-height:1.4;overflow-wrap:anywhere}.docs-page h4{color:var(--muted);font-size:11px;letter-spacing:.12em;margin-top:28px}.docs-page .meta,.docs-page .eyebrow{color:var(--muted);font-size:13px}.docs-page .wrap>header>.meta:first-child,.docs-page .eyebrow{text-transform:uppercase;font-size:10px;letter-spacing:.15em;color:var(--accent)}.docs-page .wrap>header>p:not(.meta){max-width:76ch;line-height:1.8}.docs-page .pagenav{gap:8px 12px}.docs-page .pagenav a{padding:7px 12px;border:1px solid var(--struct);border-radius:6px;min-height:40px;font-size:12px;text-decoration:none}.docs-page .pagenav a:hover{background:var(--panel);border-color:#637958}.docs-page .banner{border-radius:8px;padding:18px 22px;background:var(--panel);border-color:var(--struct);border-left:3px solid var(--accent)}.docs-page .banner p{font-size:14px;margin:0;line-height:1.7}.docs-page .toc{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 24px}.docs-page .toc li{font-size:13px}.docs-page .toc li a{font-size:14px;min-height:46px}.docs-page .op{margin-top:42px;padding-top:4px;min-width:0}.docs-page pre{border-radius:10px;padding:22px;font-size:12px;line-height:1.8;max-width:100%;white-space:pre-wrap;overflow-wrap:anywhere}.docs-page pre code{font-size:inherit}.docs-page code{overflow-wrap:anywhere;word-break:normal}.docs-page .scrollx{border:1px solid var(--struct);border-radius:8px;margin:14px 0 18px;max-width:100%;min-width:0}.docs-page .scrollx table{margin:0;min-width:540px}.docs-page table th{font-size:11px;text-transform:uppercase;letter-spacing:.06em;background:var(--panel);padding:12px 14px}.docs-page table td{font-size:13px;padding:13px 14px}.docs-page table tr:last-child td{border-bottom:0}.docs-page .annot{border-radius:8px;padding:18px}.docs-page footer{margin-top:60px}.docs-page footer hr{border:0;border-top:1px solid var(--struct);margin:20px 0}.docs-changelog article{border-top:1px solid var(--struct);padding:24px 0;max-width:80ch}.docs-changelog article h2{border:0;margin-top:0;display:flex;align-items:baseline;gap:20px;flex-wrap:wrap}.docs-changelog time{font:400 12px/1.5 Inter,sans-serif;color:var(--muted)}
/* A useful landing page stays visible before the optional explorer is loaded. */
.docs-home #static-intro{position:relative;inset:auto;max-width:1208px;max-height:none;overflow:visible;min-height:0;padding:64px 0 48px;margin:auto;background:none;z-index:0}.docs-home #static-intro[hidden]{display:none}.docs-home .docs-hero{display:grid;grid-template-columns:1.2fr 1fr;gap:70px;align-items:center;margin-bottom:68px}.docs-home .docs-eyebrow{color:var(--accent);font-size:10px;letter-spacing:.18em;text-transform:uppercase;margin:0 0 22px}.docs-home #static-intro h1{font:700 clamp(40px,5vw,70px)/1.04 'Space Grotesk',Inter,sans-serif;letter-spacing:-.055em;color:var(--text);margin:0 0 24px}.docs-home .docs-lede{font-size:16px;line-height:1.8;color:var(--muted);max-width:55ch}.docs-home .docs-actions{display:flex;gap:18px;align-items:center;flex-wrap:wrap;margin:28px 0 14px}.docs-home #open-explorer{padding:13px 18px;font:500 13px/1.5 Inter,sans-serif;border:0;border-radius:8px;background:var(--accent);color:var(--bg);min-height:48px;cursor:pointer}.docs-home .docs-actions>a{font-size:13px;min-height:44px;display:inline-flex;align-items:center}.docs-home .docs-code{padding:24px;background:var(--panel);border:1px solid #3b4d3e;border-radius:12px;min-width:0;box-shadow:0 24px 80px #0005}.docs-home .docs-code-label{font-size:10px;letter-spacing:.14em;color:var(--muted);display:flex;justify-content:space-between;text-transform:uppercase;border-bottom:1px solid var(--struct);padding-bottom:18px;margin-bottom:22px}.docs-home .docs-code pre{font:12px/1.9 'JetBrains Mono',monospace;white-space:pre-wrap;overflow-wrap:anywhere;margin:0;color:var(--text)}.docs-home .docs-code .code-accent{color:var(--accent)}.docs-home .docs-code>p{font-size:12px;line-height:1.7;color:var(--muted);border-top:1px solid var(--struct);padding-top:16px;margin:22px 0 0}.docs-home .docs-section-title{display:flex;align-items:baseline;justify-content:space-between;gap:20px;margin:0 0 20px}.docs-home #static-intro h2{font:700 27px/1.2 'Space Grotesk',Inter,sans-serif;letter-spacing:-.035em;margin:0}.docs-home .docs-section-title p{font-size:12px;color:var(--muted);margin:0}.docs-home .docs-topics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.docs-home .docs-topics>a{border:1px solid var(--struct);border-radius:9px;display:flex;flex-direction:column;gap:14px;padding:22px;text-decoration:none;min-width:0;color:var(--text);background:#0f1712}.docs-home .docs-topics>a:hover{background:var(--panel);border-color:#6c885c}.docs-home .docs-topics strong{font-size:14px;font-weight:500;line-height:1.4}.docs-home .docs-topics span{font-size:12px;line-height:1.7;color:var(--muted)}.docs-home .docs-topics small{font-size:10px;color:var(--accent);font-family:'JetBrains Mono',monospace}.docs-home .docs-resources{display:flex;gap:20px;flex-wrap:wrap;border-top:1px solid var(--struct);padding:24px 0;margin-top:32px}.docs-home .docs-resources a{font-size:12px;min-height:32px;display:flex;align-items:center}.docs-home .docs-clients{display:flex;flex-wrap:wrap;gap:12px 24px;color:var(--muted);font-size:12px}.docs-home .docs-clients code{color:var(--text);font-size:11px}.docs-home .docs-corner{display:none}.docs-home #boot{position:relative;inset:auto;max-width:1208px;margin:0 auto;padding:20px;min-height:0;border-bottom:1px solid var(--struct);pointer-events:none}.docs-home #boot[hidden]{display:none}.docs-home #boot a{pointer-events:auto}.docs-home .skip-link{position:absolute;left:-9999px;z-index:20;background:var(--panel);padding:14px;color:var(--accent)}.docs-home .skip-link:focus{left:8px;top:8px}
@media(max-width:1280px){.docs-topic-menu{margin:0 36px}.docs-home #static-intro{padding:52px 36px}.docs-home .docs-hero{gap:36px}}
@media(max-width:900px){.docs-desktop-nav{gap:16px}.docs-home .docs-hero{grid-template-columns:1fr;gap:28px;margin-bottom:44px}.docs-home .docs-code{max-width:620px}.docs-home .docs-topics{grid-template-columns:repeat(2,minmax(0,1fr))}.docs-home .docs-section-title{display:block}.docs-home .docs-section-title p{margin-top:10px}.docs-page .wrap{padding-top:36px}}
@media(max-width:640px){.docs-site-header{padding:16px 20px;gap:12px;flex-wrap:wrap}.docs-brand{font-size:14px;gap:8px}.docs-brand img{width:22px;height:22px}.docs-brand span{font-size:10px;padding-left:8px;margin-left:0}.docs-desktop-nav{display:none}.docs-key{font-size:10px;gap:8px;padding:7px 10px}.docs-topic-menu{margin:0 20px}.docs-topic-menu nav{grid-template-columns:1fr}.docs-page .wrap{padding:30px 20px 60px}.docs-page h1{font-size:36px}.docs-page .toc{grid-template-columns:1fr}.docs-page h3{font-size:18px}.docs-page .pagenav a{min-height:44px}.docs-page pre{padding:16px;font-size:11px}.docs-page .wrap>header>p:not(.meta){font-size:15px}.docs-home #static-intro{padding:38px 20px}.docs-home .docs-lede{font-size:15px}.docs-home .docs-code{padding:18px}.docs-home .docs-code pre{font-size:11px}.docs-home .docs-topics>a{padding:18px 15px}.docs-home .docs-resources{gap:8px 20px}.docs-home .docs-resources a{min-height:44px}.docs-home .docs-actions{gap:8px 18px}}
@media(max-width:360px){.docs-brand span{display:none}.docs-home .docs-topics{grid-template-columns:1fr}.docs-page h1{font-size:32px}}
@media(prefers-reduced-motion:no-preference){a,button{transition:color .15s,background .15s,border-color .15s}}
.docs-page p,.docs-page li,.docs-page a,.docs-page td{overflow-wrap:anywhere}.docs-page .scrollx{width:100%;overflow-x:auto}
</style>
</head>
<body class="docs-page docs-changelog">
<a class="skip-link" href="#main">Skip to content</a>
<header class="docs-site-header"><a class="docs-brand" href="./"><img src="logo.svg" width="28" height="28" alt="">Live Tennis API <span>Docs</span></a><nav class="docs-desktop-nav" aria-label="Documentation"><a href="./">Overview</a><a href="./reference.html">API reference</a><a href="./changelog.html" aria-current="page">Changelog</a></nav><a class="docs-key" href="https://livetennisapi.com/subscribe/free">Get a free key <span aria-hidden="true">↗</span></a></header><details class="docs-topic-menu"><summary>Explore the documentation</summary><nav aria-label="Documentation topics"><a href="./">Overview</a><a href="./reference.html">API reference</a><a href="./changelog.html" aria-current="page">Changelog</a><a href="./live-scores.html">Live tennis scores API</a><a href="./players-and-tournaments.html">Tennis players, tournaments and rankings API</a><a href="./tennis-odds.html">Tennis odds API — markets and price ticks</a><a href="./point-by-point-history.html">Point-by-point tennis data API</a><a href="./historical-results-archive.html">Historical tennis results API — 1968 onward</a><a href="./shot-level-rally-data.html">Shot-by-shot tennis rally and charting API</a><a href="./broadcast-graphics.html">Tennis scores for on-air graphics</a><a href="./push-feed-and-webhooks.html">Tennis WebSocket feed and webhooks</a><a href="./auth-quota-and-health.html">Tennis API authentication, quota and status</a></nav></details>
<main class="wrap" id="main">
<header>
<p class="eyebrow">Live Tennis API · docs</p>
<h1>Changelog</h1>
<p>Every change to the specification, dated and versioned. The API surface is <code>v1</code>; changes within it are additive only. Current version <strong>1.13.60</strong>, last change <time datetime="2026-10-03">2026-10-03</time>. Also as <a href="https://docs.livetennisapi.com/reference.html">the full reference</a>, <a href="https://docs.livetennisapi.com/openapi.yaml">OpenAPI YAML</a> / <a href="https://docs.livetennisapi.com/openapi.json">JSON</a>, and <a href="https://livetennisapi.com/facts.json">the dated facts file</a>.</p>
</header>
<article id="v1.13.60"><h2>1.13.60 <time datetime="2026-10-03">2026-10-03</time></h2>
<h3>Added</h3>
<ul>
<li><strong>The hosted MCP endpoint, which this reference had never mentioned.</strong> A remote</li>
<p>Streamable HTTP server has run at <code>https://mcp.livetennisapi.com/mcp</code> for weeks. The only MCP line here named the self-run package, <code>npx livetennisapi-mcp</code>. A reader who wanted an LLM agent over this API was therefore told to install and host one. The string <code>mcp.livetennisapi.com</code> appeared zero times in the reference, zero times in <code>llms.txt</code> and zero times in the README. An engineer evaluating the API found the endpoint by going looking for it, which is what prompted this. A new <strong>MCP server</strong> section now sets the hosted and self-run routes side by side. The client-libraries table, <code>llms.txt</code> and the README name the endpoint too. Everything in the section was read off the live server on 2026-10-03, not taken from a release note. <code>initialize</code> reports <code>serverInfo</code> version <strong>1.5.0</strong>. <code>tools/list</code> returns <strong>24</strong> tools, set-identical to the 24 the section lists by name. Both documented auth headers were called and both work: <code>Authorization: Bearer twjp_...</code> and <code>X-API-Key: twjp_...</code>. The rate headers read <code>limit=300</code> on a key and <code>limit=60</code> without one. The section states both, and states that the caller's own plan limit still applies underneath. That second sentence is load-bearing. The hosted cap sits above a free key's 30 a minute, so publishing it alone would read as a quota raise. With no key at all only <code>check_api_status</code> answers, and it returns the free-key URL. <code>GET /mcp</code> answers 405 <code>"use POST"</code>, so the section says POST only. <code>GET /health</code> reports the running version. Every plan reaches MCP, free keys included, and each tool reaches exactly what that plan reaches. No price is quoted anywhere, because the endpoint takes the key a customer already has. No field, parameter or type changed, and no endpoint of the REST API changed.</p>
</ul></article>
<article id="v1.13.59"><h2>1.13.59 <time datetime="2026-10-02">2026-10-02</time></h2>
<h3>Added</h3>
<ul>
<li><strong><code>?is_qualifying=</code> on <code>/matches</code>, <code>/history/matches</code> and <code>/fixtures</code>, and <code>?gender=</code> documented on the same three.</strong> The <code>is_qualifying</code> field has been published on every match and fixture since 2026-08-12 and this reference already called it "the only field that separates a qualifying draw from the main draw" and told callers to "filter accordingly" — while the parameter they would reach for did not exist and was accepted and ignored. Measured on prod before the change: <code>/matches?status=completed&limit=50</code> returned the same fifty ids under <code>is_qualifying=true</code>, <code>is_qualifying=false</code> and no filter at all, every one of the fifty rows carrying <code>is_qualifying: false</code>, while the <code>?draw=</code> control changed the page. The filter is applied in SQL before the page limit on <code>status=live</code>, <code>upcoming</code>, <code>completed</code> and <code>cancelled</code> and on the archive listing, so a deep crawl can page a single draw rather than filtering a full corpus client-side. It is <strong>three-valued like the field</strong>: a row whose flag is <code>null</code> matches neither <code>true</code> nor <code>false</code> and is returned only when the filter is omitted, because returning it under <code>false</code> would assert "main draw" on the strength of nothing. Coverage, measured 2026-10-02 over completed matches: the flag is stated on <strong>98.9%</strong> of the last thirty days and <strong>98.3%</strong> of 2026 outside the UTR circuit, which is where almost all unstated rows are (1,695 of the 1,831 in the last thirty days). Anything other than true/false is a 400 <code>bad_is_qualifying</code>. Asked for by a customer splitting qualifying from main draw for a match-prediction model. <code>?gender=men|women</code> has worked on the match and archive listings since 2026-09-09 and was never documented here; it is now, with the same null contract — a mixed-doubles or team-tie row matches neither value. No field or type changed.</li>
</ul>
<h3>Fixed</h3>
<ul>
<li><strong><code>?gender=</code> was accepted and ignored on <code>/matches?status=live</code> and <code>?status=upcoming</code>.</strong> Those two statuses are served from a short-lived slate cache keyed only on the status, and the gate that decides whether a request may use it never learned about <code>gender</code>, so a filtered request was answered with the whole slate under a 200. Measured on prod before the fix: <code>status=live</code> returned the same twenty-five rows for <code>gender=men</code>, <code>gender=women</code> and no filter — six of them women's matches and seven with no gender stated — and <code>status=upcoming</code> the same sixty rows of a hundred and forty-three under every value. <code>status=completed</code> and <code>status=cancelled</code> are not cached and answered correctly throughout (sixty of sixty, pure), so the filter itself was never wrong: the request did not reach it. The live listing is polled continuously, so the cache was effectively always warm and the filter was dropped on every call rather than occasionally. Present from 2026-09-09 to 2026-10-02. Callers who filtered the live or upcoming slate by gender and trusted the result were served the unfiltered slate and should re-read anything derived from it; every row carried a correct <code>gender</code> field throughout, so a client that filtered on the field rather than the parameter was unaffected.</li>
</ul></article>
<article id="v1.13.58"><h2>1.13.58 <time datetime="2026-10-01">2026-10-01</time></h2>
<h3>Changed</h3>
<ul>
<li><strong>The archived tape's accept clock now begins 2026-08-11, six weeks earlier than 1.13.57 published, because the missing values were recovered rather than written off.</strong> 1.13.57 measured the boundary honestly at 2026-09-23, the day <code>history_archived_scores.content_changed_at</code> landed as a catalogue-only column with no backfill. What that measurement did not say is that the values were still reachable: for every archive row copied before the column existed, the live row it was copied from may still be inside the 90-day retention, and that row holds the value the archiver would itself have written. A backfill carried them across on 2026-10-01 in six bounded tranches, <strong>1,413,471 accept clocks and 1,393,458 accept counters, inside 1,538,176 rows over 14,491 matches</strong>, and left <code>fillable=0</code>. Nothing is derived, rounded or taken from a neighbouring row, and a row whose live row had already been trimmed stays null for good. Measured after the run: the archive carries the clock on <strong>100.0% of every day from 2026-08-11 to 2026-10-01</strong>, 92.5% of 2026-08-10 (the live column's own first instant is <code>2026-08-10T08:07:42Z</code>) and 0.0% of every day before it, which is <strong>1,721,102 of 4,663,181</strong> archived observed rows against 307,631 before. <code>HistoryTapeRow.sequence</code>, the accept counter, has its own later floor because <code>live_scores.match_seq</code> landed after the clock: nothing to 2026-08-13, 68.2% of 08-14, 99.3% of 08-15, then 95.9% or more of every day from <strong>2026-08-16</strong> and 100% from 2026-09-01. Two boundaries, not one. <code>received_at</code> could not move at all and the reference now says so: its live column landed 2026-09-24, a day AFTER the archive column, so every live row carrying an arrival clock was archived with it and the recoverable count is zero. Wire-verified both sides: match 2168 (26 April 2026), before the boundary, still answers 131 observed rows with <code>timestamp</code> on all 131 and <code>accepted_at</code> on none; match 189679's archived copy carried the clock on 0 of its 171 observed rows before the run and on 171 of 171 after it. The practical consequence for a timing study is that the clock no longer disappears when a match ages out of live retention. No field, endpoint or type changed.</li>
</ul></article>
<article id="v1.13.57"><h2>1.13.57 <time datetime="2026-10-01">2026-10-01</time></h2>
<h3>Fixed</h3>
<ul>
<li><strong><code>HistoryTapeRow.accepted_at</code> was documented as covering the whole observed corpus, and on an archived tape it begins 2026-09-23.</strong> The field is the one this reference tells a timing study to use, and its description read "It rides the whole observed corpus, so a study that spans 2026-09-24 must use this one and not <code>received_at</code>", while <code>received_at</code> said "THIS CLOCK HAS A START DATE AND THE OTHER TWO DO NOT". Both sentences were wrong about <code>accepted_at</code>. Measured over the whole archived observed corpus: <strong>307,512 of 4,659,024 rows carry it</strong>, which is 0.0% of every month to August 2026 (286 rows in all of August), 27.3% of September and 100% of October. Wire-verified on match 2168 (26 April 2026), answered with <code>meta.from_archive: true</code>: 131 observed rows, <code>timestamp</code> on all 131 and <code>accepted_at</code> on none. The measurement quoted in 1.13.5x — match 189679, <code>accepted_at</code> on all 171 observed rows — is correct and does not generalise, because that match is still inside live retention and is answered from its live rows (<code>from_archive: false</code>); its own archived copy carries the clock on <strong>0 of 367</strong> rows. So coverage depends on which copy answers the read, <code>meta.from_archive</code> is how you tell, and a null here is a boundary of the record rather than a missing value. <code>timestamp</code> is the only one of the three clocks with no start date. Found while answering a customer who asked for "a timestamp precise enough to line up with exchange quotes" — the exact question the wrong sentence was there to answer. No field, endpoint or type changed.</li>
</ul></article>
<article id="v1.13.56"><h2>1.13.56 <time datetime="2026-10-01">2026-10-01</time></h2>
<h3>Fixed</h3>
<ul>
<li><strong>An exact player name that sat inside a longer name could not be asked about on any of the three name endpoints.</strong> Every name lookup is a substring match, so there was no way to say "exactly this one", and a person whose name is a prefix of somebody else's was unreachable: <code>GET /h2h?p1=Carlos%20Alcaraz</code> answered <code>400 ambiguous_name</code> listing <code>Carlos Alcaraz</code> and <code>Carlos Alcaraz Gonzalez</code>, with no string that could pick between them. Measured on the live roster and corpus: <strong>14 of the 1,177 ranked players</strong> on <code>/h2h</code>, <strong>21 of 1,500</strong> distinct archive names on <code>/history/archive/career</code> and <strong>4 of 1,739</strong> charted names on <code>/charting/players</code>, the men's world #2 and #3 among them. Passing a name exactly now selects that person on all three. A name given in part is still a fragment and still refuses with the candidate list, so <code>Sinner</code> continues to list Jannik and Martin rather than guessing, and a single name charted under both genders on <code>/charting/players</code> still needs <code>gender</code>. The refusal also reports the candidates it would have shown before the exact match was applied, so a hint never gets shorter than it was. Verified on the wire: Alcaraz against Sinner returns 18 meetings 11-7 with the surface split and the ULTRA aggregates. No field, endpoint, parameter or error code changed.</li>
</ul></article>
<article id="v1.13.55"><h2>1.13.55 <time datetime="2026-09-29">2026-09-29</time></h2>
<h3>Fixed</h3>
<ul>
<li><strong>ITF World Tennis Tour doubles began stating <code>outcome</code>, and the reference still said no doubles draw carries either field.</strong> The 1.13.42 note measured 454 completed doubles matches and found no tagged row. That was true when it was written. The first doubles row carried a tag on 2026-09-23, and by 2026-09-29 <code>GET /matches/{id}/points</code> published <code>outcome</code> on <strong>104 of 596</strong> completed ITF doubles matches, 17%, against <strong>0 of 371</strong> for the ATP, WTA and Challenger doubles draws in the same window. The vocabulary is ace and double fault, as on the ITF singles draws. <code>serve</code> is still unstated on every doubles draw, measured 0, and that sentence is unchanged. The cause sat upstream of the tagger: a dedup change let a doubles fixture be adopted and oriented by its member sets instead of being refused, so the ITF per-point join reached rows it had never been able to match. 17% falls between "almost none" and "a clear majority", so the description states it as a measured fraction and sends the reader to <code>enrichment</code> per match instead of to the draw. WTA main-tour qualifying has carried that treatment since 1.13.42. Found by the every-sweep tag-coverage check. No field, endpoint or behaviour changed.</li>
</ul></article>
<article id="v1.13.54"><h2>1.13.54 <time datetime="2026-09-28">2026-09-28</time></h2>
<h3>Added</h3>
<ul>
<li><strong>The two broadcast surfaces, live since 2026-09-24 and documented nowhere a customer could read.</strong> <code>GET /broadcast/match/{matchId}</code> and <code>GET /broadcast/live</code> have served every ULTRA key for four days and appeared in no published reference: the write-up went into the tennis repository, which nothing serves. Both are now specified, with the 54-field <code>BroadcastObject</code> schema in the same order the API returns it, verified field by field against a live response rather than transcribed from the source. The object exists because a graphics template binds each field to a fixed path, so three properties are contractual rather than incidental and are documented as such: it is FLAT, every key is PRESENT on every read (<code>null</code>, never missing — a missing key breaks a binding), and the score is already in DISPLAY form (<code>p1_points</code> reads <code>"40"</code> or <code>"AD"</code>, <code>set_line</code> is one string, each set has its own column). It is a projection of what the API already publishes plus the break-, set- and match-point flags the scoring pipeline derives; nothing in it is a new fact and nothing is guessed.</li>
<li><strong>The 1 Hz polling contract, which is the part a live scoreboard gets wrong.</strong> Every response carries a strong <code>ETag</code> and <code>Cache-Control: no-cache, max-age=1</code>, so <code>If-None-Match</code> turns an unchanged state into a 304 with no body; ULTRA's 600-a-minute burst window means ten single-match pollers at 1 Hz fit on one key, and <code>/broadcast/live</code> stays one request however many matches are live. A new state is detected on <code>sequence</code>, never on <code>served_at</code>, which changes on every read — stated on both routes because polling on <code>served_at</code> is the mistake that looks like it works.</li>
</ul></article>
<article id="v1.13.53"><h2>1.13.53 <time datetime="2026-09-26">2026-09-26</time></h2>
<h3>Added</h3>
<ul>
<li><strong>The tape now publishes the accept counter the withdrawal ledger points at.</strong> <code>GET /matches/{matchId}/withdrawals</code> tells you a <code>sequence</code> was withdrawn and to resume from <code>replaced_by_sequence</code>, and until today no served history-tape row carried that number, so the pointer named a row nothing identified. <code>HistoryTapeRow.sequence</code> is the same per-match accept counter as <code>Score.sequence</code> on a live read. Because a withdrawn state never enters the record at all, a withdrawal now reads as a GAP in the tape's run of counters, which is what makes "resume from the predecessor" resolvable against the served history. Reported by an Ultra customer who verified three withdrawal records against the tape and wrote that "the history rows lack sequence IDs, so these responses alone cannot reconstruct exactly what a live client received". Null, never fabricated, on a row archived before 2026-09-26 (the archive did not keep the counter, so it died with the live row at retention) and on the pre-2023 archive tape, which has no such counter. Unlike <code>accepted_at</code> and <code>received_at</code> it is NOT null on a reconstructed row: a counter is a fact about our own accept order.</li>
</ul>
<h3>Fixed</h3>
<ul>
<li><strong><code>Score.sequence</code> said the tape does not carry it, and the stated reason was the gap.</strong> The description read "absent on history-tape rows, which are already served in order". Being served in order does not resolve a <code>replaced_by_sequence</code>, which is a pointer at a counter rather than a position in a list. Corrected, and it now points at <code>HistoryTapeRow.sequence</code>.</li>
</ul></article>
<article id="v1.13.52"><h2>1.13.52 <time datetime="2026-09-25">2026-09-25</time></h2>
<h3>Added</h3>
<ul>
<li><strong>The tape's two accept clocks are published.</strong> <code>HistoryTapeRow.accepted_at</code> and <code>HistoryTapeRow.received_at</code> have ridden every observed row of <code>GET /history/matches/{matchId}</code> since 2026-09-24 and were documented nowhere, so a customer doing timing research on a tape had no published field to read. <code>accepted_at</code> is the instant our arbiter accepted the state, stamped once and never refreshed, which is the clock to difference against: <code>timestamp</code> starts equal to it and is then refreshed while the owning source keeps re-asserting the unchanged state, so on most raw rows <code>timestamp</code> is a later re-assertion instant (on match 189679, 164 of its 171 observed rows carry a <code>timestamp</code> more than a second after <code>accepted_at</code>). <code>received_at</code> is the instant the state reached our edge before arbitration, so <code>accepted_at</code> minus <code>received_at</code> is our own arbitration step. Neither figure is a claim about upstream speed and neither is the court's clock. Both are null on every reconstructed row, which never had a clock.</li>
<li><strong>The arrival clock's start date, which is the part that bites.</strong> <code>received_at</code> is stamped only as a state arrives, so it can never be filled in afterwards. The earliest one we hold is <code>2026-09-24T02:00:26Z</code>; on every observed row accepted before that instant it is null while <code>timestamp</code> and <code>accepted_at</code> are populated. A study that spans the boundary has to read <code>accepted_at</code>, which rides the whole observed corpus. Measured on match 189679 (12 September) after a Basic customer reported it: 171 observed rows, <code>accepted_at</code> on all 171, <code>received_at</code> on none.</li>
<li><strong>What <code>server</code> guarantees on a tiebreak row, and what it does not.</strong> Tiebreak rows are never settled on either sequence. The server genuinely changes during a breaker, so there is no constant to take a majority of, and the field is the source's own assertion carried through. Where a breaker's rows skip points the rotation cannot be reconstructed from what is served: you have the server of a state, not of a numbered point, and <code>meta.points.transitions_legal</code> against <code>transitions_total</code> tells you whether that happened. On the set roll-up row carrying <code>tiebreak_final</code>, <code>server</code> is the value of that committed set-end state and is NOT a preserved copy of the final breaker point's server. Reported by a Basic customer who could not reconcile 21 tiebreak transitions against the standard rotation and was right not to.</li>
</ul>
<h3>Fixed</h3>
<ul>
<li><strong><code>Score.accepted_at</code> said "absent on history-tape rows".</strong> It has been present on them since 2026-09-24, so the description had been wrong for a day, on the one field a timing study is told to use. Corrected, and the tape row now documents its own copy.</li>
</ul></article>
<article id="v1.13.51"><h2>1.13.51 <time datetime="2026-09-25">2026-09-25</time></h2>
<h3>Added</h3>
<ul>
<li><strong>The <code>score_withdrawn</code> frame is documented on the push feed page, where a streaming consumer looks for it.</strong> 1.13.50 put the record and the frame's fields on the score read and in the schema list, and the frame is a STREAM artifact: it arrives on the same channel as the <code>score</code> frame it retracts. <code>GET /ws-token</code> now carries the frame itself, the reason it exists (the feed publishes every accepted state and runs no trust deference, while the score read runs the legality gate and can serve an older state), what each <code>kind</code> asks you to do, and the three reads that recover a judgement you were offline for. No spec surface changed.</li>
</ul></article>
<article id="v1.13.50"><h2>1.13.50 <time datetime="2026-09-25">2026-09-25</time></h2>
<h3>Added</h3>
<ul>
<li><strong>A withdrawn score state is now recoverable, and <code>GET /matches/{matchId}/withdrawals</code> serves the record.</strong> The <code>verdict</code> object has told a stream consumer since 2026-09-23 that a state it holds was taken back, but it is recomputed on every read over the newest state, so it returned null again as soon as the next state was accepted. In a live match that is seconds. A consumer that disconnected and missed the <code>score_withdrawn</code> frame had no way back to it: the publisher's duplicate ledgers live inside the publishing process, and nothing else recorded the judgement. Every withdrawal is now written down when the judgement is made, before the frame goes out. Three reads use it. <code>GET /matches/{matchId}/withdrawals</code> (ULTRA, the tier that receives the frame) returns every refused <code>sequence</code> on a match, oldest first, each with <code>kind</code>, <code>reason</code>, <code>withdrawn_at</code>, <code>published_at</code>, the <code>sequence</code> served in its place, and the frame exactly as the feed sent it. <code>GET /matches?withdrawn_since=<instant></code> lists the match ids with a withdrawal since then, with a count per match, so a consumer coming back after a gap knows which matches to ask about. And <code>?sequence=N</code> on <code>GET /matches/{matchId}/score</code> makes <code>verdict</code> answer for the one sequence you hold, even after later states have landed. Records are kept 90 days from the withdrawal.</li>
<li><strong>The <code>score_withdrawn</code> frame's own fields are published for the first time.</strong> <code>type</code>, <code>match_id</code>, <code>kind</code>, <code>superseded_sequence</code>, <code>reason</code>, <code>safe_to_resume</code>, <code>ts</code> and <code>published_at</code>. It carries NO replacement score and no new sequence, which is the question a customer had to ask us: the state to resume from is the one on the next <code>score</code> frame, or on a fresh <code>GET /matches/{matchId}/score</code>, and the sequence served in the refused state's place is on the withdrawal record rather than on the frame. <code>ts</code> is when the judgement was made, <code>published_at</code> when the frame was handed to the feed.</li>
</ul>
<h3>Changed</h3>
<ul>
<li><strong>Only a discarded withdrawal is re-asserted by <code>?sequence=</code>.</strong> A <code>deferred</code> state may since have been corroborated and a <code>withheld</code> read described that moment's scan, so neither answers from the record; a verdict the read produces itself always wins, because it describes the state actually being served. <code>?sequence=</code> changes <code>verdict</code> and nothing else on the object. A non-integer is <code>400 bad_sequence</code> rather than an accepted-and-ignored filter, because a silently dropped lookup would read as "not withdrawn". <code>?withdrawn_since=</code> likewise takes only <code>limit</code> and <code>offset</code> beside it: any match filter, <code>status</code> or <code>updated_since</code> alongside it is <code>400 bad_withdrawn_since</code>.</li>
</ul></article>
<article id="v1.13.49"><h2>1.13.49 <time datetime="2026-09-24">2026-09-24</time></h2>
<h3>Fixed</h3>
<ul>
<li><strong>The tournament catalogue takes <code>?tier=</code>, which it had been accepting and ignoring.</strong> <code>GET /tournaments</code> publishes each row's <code>tier</code> and <code>tier_source</code>, and the two match listings have filtered on the same vocabulary since 1.13.28. The catalogue did not: <code>?tier=atp_500</code>, <code>?tier=laver_cup</code> and an outright invalid value each returned the entire 10,302-row catalogue under a 200, byte-identical to no filter at all, because the level was attached to a page after it had already been selected. It now selects. The one difference from the match listings is the season, and it is stated on the parameter: a catalogue row has no season of its own, so <code>?tier=</code> here reads the CURRENT season, the same season the row's own <code>tier</code> field reports, while on <code>/matches</code> and <code>/history/matches</code> it reads each match's own season. A tournament with no row for the current season matches no value. An invalid value is now a 400 <code>bad_tier</code> carrying the vocabulary, as it always was elsewhere.</li>
</ul></article>
<article id="v1.13.48"><h2>1.13.48 <time datetime="2026-09-24">2026-09-24</time></h2>
<h3>Fixed</h3>
<ul>
<li><strong><code>tiebreak_final</code> on the history tape row is documented for the first time, and the page-first rule the reference stated is no longer true.</strong> A PRO customer wired a bot to the closing score and reported that match 195134 served a <code>tiebreaks</code> array of <code>{p1: 2, p2: 7}</code> while no tape row carried <code>tiebreak_final</code>, and that it stayed that way on every later read. Two things were wrong here. The field had never appeared in this reference on <code>HistoryTapeRow</code> at all, though it is served on <code>GET /history/matches/{matchId}</code> and a customer had been pointed at it in writing; it is now described beside <code>is_tiebreak</code>. And the <code>MatchPoints</code> text said a page that *starts* on a roll-up row "has no previous row to read and uses the recorded finals alone", which was true when written and is not true now: since 2026-09-24 such a page reads the one row before it, so the value does not depend on where the page was cut. Both endpoints resolve the score the same way and agree with the response's own <code>tiebreaks</code> array.</li>
<li><strong>The row that closes a tiebreak set carries the score whichever row the set counter moves on.</strong> The tape's set counter lags the games array by a row: the row that took 195134's first set to 6-7 still read <code>sets</code> <code>[0, 0]</code>, and the counter reached <code>[0, 1]</code> on the row after it. The stamp only fired when the counter grew on that same row, so a set whose counter lagged was never stamped, and nothing revisits a tape. The games on the row now decide it, judged by the tournament's own set test so the Next Gen first-to-4 format is read by its own rule. Measured on the live endpoint before the change: of 9 live matches whose <code>tiebreaks</code> array stated a final, 2 carried it on no row, both first-set breakers.</li>
</ul></article>
<article id="v1.13.47"><h2>1.13.47 <time datetime="2026-09-24">2026-09-24</time></h2>
<h3>Changed</h3>
<ul>
<li><strong>A set completion that does not end the match is assigned a <code>sequence</code> only once confirmed.</strong> Live since 2026-09-24 and stated here for the first time. A live scoring display can render the set-ending frame ON set point, before the point is decided, and step back when the point is not won; a customer measured exactly that twice. A rise in <code>sets</code> that does not end the match, arriving while the state we hold is set point for the side that would gain the set, is now held until the same source keeps presenting it for 10 s or more, a source at least as trusted presents it, or the same source shows play continuing inside the new set. The unconfirmed frame is never stored, so no <code>sequence</code> ever carries it and nothing is published as provisional — a <code>sequence</code> that carries a set completion is one that met the rule. Until then <code>GET /matches/{matchId}/score</code>, the push feed and the WebSocket all keep serving the set-point state; a frame nobody confirms within 120 s is discarded. A set completion that <strong>ends</strong> the match is exempt and follows the completion rules instead. Measured over the 30 days to 2026-09-24: a median 10 s added between the point and the published set-end, and on a feed that does not re-present states the set-end lands with the first point of the next set (90th percentile 133 s). It does not cover a set genuinely won and then corrected on court, which can still be published and rewound — read <code>corroborated</code> beside it, and <code>verdict</code> says so when a state is taken back.</li>
</ul></article>
<article id="v1.13.46"><h2>1.13.46 <time datetime="2026-09-24">2026-09-24</time></h2>
<h3>Added</h3>
<ul>
<li><strong><code>corroborated</code> and <code>corroborated_age_seconds</code> on the Score: has an independent source vouched for THIS EXACT state?</strong> Raised by a customer who acted on a completed set that was taken back seconds later. <code>corroborated</code> is <code>true</code> when a source other than the one that wrote the state has since presented us the same state, <code>false</code> when none has — including a brand-new state nobody has had a chance to agree with yet — and <code>null</code> on the archive fallback of a retention-trimmed completed match, where <code>age_seconds</code> and <code>sources_count</code> are null too. <code>corroborated_age_seconds</code> is the seconds since that vouch, null when there is none. <strong>This is the field to gate a set-end on.</strong> <code>sources_count</code> and <code>changing_sources_count</code> are window measures over the match and say nothing about the state in front of you, so a set completion that only one source has announced can sit beside a perfectly healthy count — and a set-end is the one state whose correction changes a result rather than a number. It is evidence, not proof: a vouched state can still be corrected, so compare <code>corroborated_age_seconds</code> with <code>age_seconds</code> and treat a vouch younger than the state it vouches for as the weaker signal it is. When a state is taken back the <code>sequence</code> goes down and <code>verdict</code> says so. Present wherever <code>sources_count</code> is; a yes/no, never a name.</li>
<li><strong><code>changing_sources_count</code> on the Score is documented.</strong> Live since 2026-09-24 and absent from this reference. How many distinct sources CHANGED the score in the last 300 seconds, counted on the content clock alone, so a source re-asserting an unchanged state is not counted — the difference from <code>sources_count</code>, which a feed stuck re-sending its last state holds at 1 for as long as the echo lasts while this reads 0. The window is wider on purpose: a point lands every 30-60 s and a changeover is about 90 s. A cross-confirmation is agreement, not a change, so this can read below <code>sources_count</code> and never above it.</li>
</ul></article>
<article id="v1.13.45"><h2>1.13.45 <time datetime="2026-09-24">2026-09-24</time></h2>
<h3>Added</h3>
<ul>
<li><strong><code>GET /history/incidents</code> and <code>GET /history/incidents/{incidentId}/matches</code> are documented for the first time.</strong> Both have been live and neither appeared anywhere in this reference, so the register a customer is meant to reconcile against could not be found from the documentation. <code>/history/incidents</code> returns the published data-quality incidents, oldest window first, each with its <code>window_utc</code> (start, end and the <code>basis</code> it was read from), <code>rule_before</code>, <code>rule_after</code>, <code>defect</code>, <code>affected_surfaces</code>, <code>how_to_tell</code>, <code>corrections</code> and <code>matches</code>. The register is part of a release and is not inferred at read time, so a record does not change between releases. <code>/history/incidents/{incidentId}/matches</code> streams one row per affected match as JSONL, or CSV with <code>?format=csv</code>, with the columns <code>incident_id</code>, <code>match_id</code>, <code>tournament</code>, <code>tour</code>, <code>round</code>, <code>draw_stage</code>, <code>scheduled_time</code>, <code>affected_field</code>, <code>served</code> and <code>expected</code>. A match whose derivation would be a guess is left out and the incident's <code>matches.note</code> says which, so the export is exact rather than complete. Measured on the live API 2026-09-24: five incidents across the kinds <code>pricing_rule</code>, <code>version_stamp</code>, <code>field_orientation</code> and <code>field_repair</code>.</li>
<li><strong>The <code>verdict</code> object on the Score is documented.</strong> Live since 2026-09-23 and absent from this reference. It is null on an ordinary read. When the legality gate refuses a state a stream has already published, it carries <code>kind</code> (<code>withdrawn</code>, <code>deferred</code> or <code>withheld</code>), <code>superseded_sequence</code> (the <code>sequence</code> a stream consumer is holding), <code>reason</code> and <code>safe_to_resume</code>, which is true exactly when <code>kind</code> is <code>withdrawn</code>. The push feed, the WebSocket and webhooks announce the same judgement as a <code>score_withdrawn</code> frame naming the same sequence, so a stream consumer learns a state was taken back without polling. <code>GET /matches/{matchId}/score</code> now names the field and points at the incident register.</li>
</ul>
<p>No field, endpoint or behaviour changed by this release; both were already served.</p></article>
<article id="v1.13.44"><h2>1.13.44 <time datetime="2026-09-23">2026-09-23</time></h2>
<ul>
<li><strong><code>GET /matches/{matchId}/points</code>: the row that closes a tiebreak set now carries <code>tiebreak_final</code>, and the reference did not say so.</strong> Raised by a Pro customer running trading bots, who reported that the tape stops one point short of every tiebreak and that a field filling once a day was no use to a decision taken while the match is live. A tiebreak's last point is carried, like every set-winning point, by the set roll-up row (<code>tiebreak</code> false, the set banked 7-6, <code>score</code> 0-0), and that row never stated the breaker's score at closure. It now carries <code>tiebreak_final: [p1, p2]</code> in our player order, e.g. <code>[7, 5]</code>. Exact when the previous row is the decided score or one point from it, so it is derived from the stream itself and is present as soon as the set closes; otherwise taken from the match's recorded tiebreak finals, the same finals <code>GET /history/matches/{matchId}</code> publishes as <code>tiebreaks</code>; <code>null</code> when neither can state it, never guessed. Absent on every other row, including the breaker rows. A page that *starts* on a roll-up row has no previous row to read and uses the recorded finals alone. Measured at the endpoint over the 30 hours to 2026-09-23 20:30Z: 60 completed matches held 67 tiebreak sets and 36 of them served a closing score, only 7 of those matches having a recorded final at the time. Documentation of an already-served field; no behaviour changed by this release.</li>
</ul></article>
<article id="v1.13.43"><h2>1.13.43 <time datetime="2026-09-23">2026-09-23</time></h2>
<h3>Fixed</h3>
<ul>
<li><strong>The reference said a FREE key is refused the history endpoints outright. It is not, and has not been since 2026-08-07: a FREE key is served 20 history calls per calendar month.</strong> Three separate statements carried the wrong rule — the FREE tier summary ("No historical results"), the <code>/matches</code> description ("requires BASIC ... and returns <code>403 upgrade_required</code> on a FREE key"), and the <code>status</code> parameter. Measured on the live API 2026-09-23 with a fresh FREE key: <code>GET /history/matches</code> answered <code>200</code> and kept answering until the monthly allowance was spent, after which it answered <code>403 upgrade_required</code> carrying <code>free_history_taste: "used"</code> and the detail "your 20 free history calls this month are used". A reader planning against the old text would have concluded the endpoints were closed to them and either bought a tier they did not yet need or abandoned the evaluation; a reader who tried anyway got a <code>200</code> the reference said was impossible and could not tell an allowance from a private grant. <code>/history/matches</code>, <code>/history/coverage</code>, <code>status=completed</code> on <code>/matches</code> and the FREE tier summary now all state the allowance and the refusal that follows it.</li>
</ul>
<p>No field, endpoint or behaviour changed; every correction is to a description.</p></article>
<article id="v1.13.42"><h2>1.13.42 <time datetime="2026-09-23">2026-09-23</time></h2>
<h3>Fixed</h3>
<ul>
<li><strong>Every per-point coverage count in this reference was measured in our own tables and published as though it described the API. It did not, and the gap is large.</strong> <code>GET /matches/{id}/points</code> serves a COMPLETED match's stored per-point stream only while that stream passes our quality bar. Where it does not, the endpoint serves a measured-complete RECONSTRUCTION instead, and a reconstruction carries no clock and no <code>serve</code> / <code>outcome</code> tags at all. So the share of matches whose tags a reader can fetch is lower, sometimes far lower, than the share of matches a source tagged. Re-measured 2026-09-23 on what the endpoint publishes, beside what the previous text claimed: <strong>Davis Cup World Group <code>serve</code> 2 of 22 (published as 22 of 22), World Group I 1 of 42 (published as 41 of 42)</strong>; WTA qualifying <code>serve</code> <strong>18 of 48</strong> (published as 48 of 48); ATP Challenger qualifying <code>serve</code> <strong>137 of 197</strong> (195 of 197) and <code>outcome</code> <strong>134 of 197</strong> (191 of 197); WTA 125 qualifying <code>serve</code> <strong>47 of 63</strong> (61 of 63) and <code>outcome</code> <strong>30 of 63</strong> (24 of 63); ITF qualifying <code>outcome</code> <strong>181 of 215</strong> men's and <strong>142 of 176</strong> women's (189 of 209, 143 of 168); WTA main-tour qualifying <code>outcome</code> <strong>5 of 48</strong>, against 47 of 88 in the same events' main draws (published as 3 of 48 against 82 of 88). Both descriptions now state which layer they count and name <code>basis</code> as the per-response answer.</li>
<li><code>enrichment</code> per match was correct throughout, on every one of these matches, and <code>basis</code> has always named which sequence you were given. Only the prose was wrong.</li>
</ul>
<p>No field, endpoint or behaviour changed; every correction is to a description.</p></article>
<article id="v1.13.41"><h2>1.13.41 <time datetime="2026-09-22">2026-09-22</time></h2>
<h3>Fixed</h3>
<ul>
<li><strong>The <code>outcome</code> description named the wrong tour as the qualifying gap, and it was wrong in both directions.</strong> It read "Qualifying draws follow their own tour, with one gap: ... Challenger (men) qualifying states no outcome at all." Measured 2026-09-22 over completed matches carrying a per-point stream since 2026-09-12, <strong>ATP Challenger qualifying states <code>outcome</code> on 191 of 197 matches, with the full five-value vocabulary</strong> (6,881 unforced errors, 4,275 winners, 4,267 forced errors, 1,026 aces, 774 double faults) — a reader who segregated a corpus on that sentence discarded the single richest qualifying population in the product. The gap is on the <strong>WTA main tour</strong>, which the old sentence implicitly cleared: WTA qualifying states <code>outcome</code> on <strong>3 of 48</strong>, against 82 of 88 in the same events' main draws. ITF qualifying is unchanged in direction (189 of 209 men's, 143 of 168 women's) and WTA 125 qualifying is partial (24 of 63). <code>enrichment</code> per match was correct on every one of these matches throughout — only the prose was wrong, and the prose is what a corpus is planned from.</li>
<li><strong><code>tour: challenger</code> pools two populations with different <code>outcome</code> coverage, and the reference described only one of them.</strong> It read "Challenger: all five". That is true of ATP Challenger (<code>tier: challenger_50</code> … <code>challenger_175</code>): 252 of 255 completed main-draw matches with a stream carry the full five. It is not true of the WTA 125 events, which are also served as <code>tour: challenger</code> (<code>tier: wta_125</code>) and state <strong>ace and double fault only</strong> — 137 of 141 main-draw matches carry <code>outcome</code>, none of them a winner, forced error or unforced error. Read <code>tier</code>, not <code>tour</code>, to tell the two apart. Both descriptions now say so.</li>
<li><strong>The <code>serve</code> qualifying denominators are re-measured</strong> (WTA qualifying 48 of 48, ATP Challenger qualifying 195 of 197, WTA 125 qualifying 61 of 63) and name the populations by <code>tier</code> rather than by "Challenger (men)" / "Challenger (women)", which the served fields do not distinguish.</li>
</ul>
<p>No field, endpoint or behaviour changed; every correction is to a description.</p></article>
<article id="v1.13.40"><h2>1.13.40 <time datetime="2026-09-22">2026-09-22</time></h2>
<h3>Added</h3>
<ul>
<li><strong><code>tier</code> and <code>tier_source</code> on every match and tournament object, with <code>?tier=</code> on <code>GET /matches</code> and <code>GET /history/matches</code>.</strong> <code>tier</code> is the level the tournament was played at in the season of the match — the official category as the tour publishes it — and answers the question <code>category</code> cannot: is this a WTA 125, an ITF W35 or a Challenger 75? The vocabulary is closed: <code>grand_slam</code>; the ATP levels (<code>atp_finals</code>, <code>atp_1000</code>, <code>atp_500</code>, <code>atp_250</code>, <code>next_gen_finals</code>); the WTA levels (<code>wta_finals</code>, <code>wta_elite_trophy</code>, <code>wta_1000</code>, <code>wta_500</code>, <code>wta_250</code>, <code>wta_125</code>); <code>challenger_175</code> … <code>challenger_50</code>; the ITF World Tennis Tour categories (<code>itf_m15</code>, <code>itf_m25</code>, <code>itf_w15</code> … <code>itf_w100</code>, with the 2023 women's categories kept as printed that season); the team events (<code>united_cup</code>, <code>davis_cup</code>, <code>bjk_cup</code>, <code>laver_cup</code>, <code>olympics</code>); <code>juniors</code>; <code>utr</code>; <code>exhibition</code>; or <code>null</code> — nothing we hold names the level, never guessed. The tier is per season and it moves under one <code>tournament_id</code>: Dallas was <code>atp_250</code> in 2023 and 2024 and <code>atp_500</code> from 2025, so a 2024 Dallas match reads <code>atp_250</code> and a 2025 one <code>atp_500</code> under the same id. A match's tier is resolved by the calendar year of its <code>scheduled_time</code>, never copied from the tournament row; <code>/tournaments</code> rows carry the current season's, null when this season's calendar does not list the event. <code>tier_source</code> says how the level was established — <code>calendar</code> (the official per-season tour calendar), <code>name</code> (an unambiguous name rule: the ITF category is in the event's official name, team and UTR events are named as such), <code>wikipedia</code> (the season's schedule page, only where the official calendar could not state the level for that season), <code>resolver</code> (resolved after the seed dataset by the same rules) — and is null exactly when <code>tier</code> is null. The 2023–2026 seasons were verified event by event against the official calendars (99.1% of tournament-seasons resolved; the rest deliberately null). <code>?tier=</code> takes comma-separated exact values, read from the same per-season table so filter and field cannot disagree; a null tier matches no value; an unknown value is a <code>400 bad_tier</code> with the offending values in <code>bad</code> and the full vocabulary in <code>allowed</code>. <code>category</code> is unchanged and remains the coarse class.</li>
<li><strong>A correction to a published result is signalled, never a silent edit: <code>basis: restatement</code> on the status history, <code>result_restated_at</code> / <code>result_version</code> on every match object, and <code>?restated_since=</code> on <code>GET /history/matches</code>.</strong> Raised by a licensed results customer settling off our results. Once a match has been published as <code>completed</code> (or as a cancelled walkover with a winner), any later change to <code>status</code>, <code>event_status</code>, <code>winner</code> or the final score is appended to <code>GET /matches/{matchId}/status-history</code> as a new row with <code>basis: restatement</code>, at the instant we made the change: <code>status</code> / <code>event_status</code> carry before and after as usual (the same value twice when only the winner or the score moved) and <code>score</code> is the result after the correction. This holds for every path that can change a result — a source's late final, a second authority's correction, an operator's repair, a completion reopened and re-closed — because it is enforced where the result is written, not by each path remembering to say so; a re-assertion of the same result, or a change to points alone, writes nothing. <code>result_restated_at</code> is the instant of the newest such row (null while the result stands as first published); <code>result_version</code> is <code>1</code> plus the number of corrections — record it with the result you settle on, and a higher number on re-read means the one you hold was superseded. <code>?restated_since=<ISO instant></code> keeps only the matches corrected after that instant — the poll to run after each settlement pass; a <code>Z</code> or an offset, a naive value read as UTC, and a bare date refused as <code>400 bad_restated_since</code>, because a day is not an instant. Rows with this basis exist from 2026-09-22; earlier corrections were not signalled and nothing is reconstructed for them.</li>
<li><strong><code>winner</code> on every row of every history tape: who won the point that produced the row.</strong> Raised by a modelling customer initialising serve states from the tape — <code>server</code> and <code>serve</code> were stated per row, and the third fact a model needs was only implicit in the score step. <code>winner</code> (<code>1</code> | <code>2</code> | null) is judged from the score step between the row and the previous served row (the previous raw row on the raw sequence, the previous clean row under <code>?sequence=clean</code>), by the same rule the completeness ledger counts a legal transition with — and from nothing else: never the serve, the server, the outcome tag or the pattern of play. Null on the first row and wherever the step is not one attributable point: a re-sent row, a set opener, a backward correction, a multi-game jump. A step whose games total rises by exactly one attributes to the side whose count rose — the game point was theirs — whatever in-game points the step skipped, a missed poll or a game withheld whole (<code>meta.points.games_withheld</code>). Present on the raw and clean sequences, on <code>?points=complete</code> reads and on the pre-2023 archive tape; equal to <code>point_winner</code> wherever that older key is present, which is kept unchanged for existing readers. Derived once per response, never stored.</li>
<li><strong><code>schema_version</code> on the packages listing, and bulk tape rows carry the enriched tape.</strong> Under <code>schema_version</code> 2 a <code>kind=tape</code> JSONL line is exactly what <code>GET /history/matches/{matchId}</code> returns for that match on its default basis, produced by the same code — the same rows with <code>origin</code>, <code>serve</code>, <code>outcome</code> and <code>winner</code> beside the score columns, and the full <code>meta</code> (<code>enrichment</code>, <code>reconstructed_at</code>, <code>observed_span</code>, <code>meta.points</code> where served) — so a model reads its serve states from the bulk file just as it would from the endpoint; the CSV appends <code>origin</code>, <code>serve</code>, <code>outcome</code>, <code>winner</code> after <code>danger</code>, in that order, and its column order grows only at the end. <code>1</code> is the shape every month built before 22 September 2026 carries: score columns only, no <code>meta.enrichment</code>, and a CSV that ends at <code>danger</code>. Months built before that date keep shape <code>1</code> until they are rebuilt — newest month first, then backwards, <code>built_at</code> and <code>sha256</code> moving as each flips — so check the manifest rather than assuming; a consumer that needs the enriched fields should require <code>schema_version >= 2</code> and, for a month still at <code>1</code>, read the per-match endpoint for the matches it needs. <code>null</code> on the non-tape kinds.</li>
</ul>
<h3>Changed</h3>
<ul>
<li><strong>A completed match on the tours whose own point-by-point console we read is re-joined after the match against the console's complete sequence.</strong> ATP 250/500/1000, ATP Challenger and WTA 1000/500/250/125: the live join paired <code>serve</code> / <code>outcome</code> onto points as they were captured, so a point the console published after our capture went untagged. A completed match is now re-joined against the complete console sequence by the same exact-state rule, and its serve/outcome coverage exceeds what was captured live. The late tags arrive as <code>point_update</code> frames and <code>tagged_at</code> revisions like any other — collect them with <code>?changed_since=</code>. Nothing is inferred; a point the console never states stays null.</li>
<li><strong>The pre-2023 archive tape no longer claims <code>point_winner</code> is null throughout a per-game tape.</strong> On a per-game tape (556 matches, 555 of them 2013) consecutive rows differ by a whole game, and the reference said no point was attributable there. Each row in fact reads the side whose game count rose — the game point was theirs — and says nothing about the game's other points; <code>winner</code> reads the same. No field changed shape or value.</li>
</ul></article>
<article id="v1.13.39"><h2>1.13.39 <time datetime="2026-09-22">2026-09-22</time></h2>
<h3>Changed</h3>
<ul>
<li><strong><code>is_qualifying</code> states the one case where a round label DOES decide the draw.</strong> The field said it is "never inferred from the round label", which is the right instinct and slightly too strong: main-draw vocabulary (<code>Semi-finals</code>, <code>Final</code>) is indeed never read that way, but a round the feed itself spells <code>Qualification Round 1</code> is the source STATING the draw, and it reads <code>true</code> — a <code>false</code> beside such a round is a payload contradicting itself rather than asserting main draw. Three matches (Chengdu, 22 Sep) were briefly served <code>is_qualifying: false</code> with <code>round_code: "Q1"</code> beside them and were repaired the same morning. Readers segregating a corpus by draw need to know which way that conflict resolves. No field changed shape or type.</li>
</ul></article>
<article id="v1.13.38"><h2>1.13.38 <time datetime="2026-09-22">2026-09-22</time></h2>
<h3>Changed</h3>
<ul>
<li><strong><code>GET /markets</code> states that the scope is MATCH-WINNER ONLY, and returns at most one market.</strong> A prospect asked whether the odds endpoints carry game spreads / handicaps (<code>A -3.5 @ 1.90</code>) in addition to match-winner prices. Every description in this reference already said "match-winner market", but nowhere said what that EXCLUDES, and the endpoint's own summary said "market(s)". Both are now explicit: no handicaps or game spreads, no totals, no set-winner books, no per-game or per-set derivative; <code>data</code> holds at most one object and <code>meta.count</code> is 0 when nothing is mapped. Venues do list tennis derivatives beside the match-winner book and this API publishes none of them — a derivative is refused at the mapping step. No field changed shape or value.</li>
</ul></article>
<article id="v1.13.37"><h2>1.13.37 <time datetime="2026-09-21">2026-09-21</time></h2>
<h3>Added</h3>
<ul>
<li><strong><code>is_qualifying</code> is documented on the match object.</strong> The field has been served on <code>GET /matches</code>, <code>GET /matches/{matchId}</code> and <code>GET /history/matches</code> for some time and was absent from this reference entirely, which left the one field that separates a qualifying draw from the main draw invisible to anyone reading the docs. Three-valued: <code>true</code>/<code>false</code> are the source's own assertion, <code>null</code> means no source has ever stated it, and <code>null</code> is not <code>false</code>.</li>
</ul>
<h3>Changed</h3>
<ul>
<li><strong><code>round</code> and <code>round_code</code> now state that they are DRAW-RELATIVE.</strong> Both describe the round *within* the draw the match belongs to, and the draw is named by <code>is_qualifying</code> — not by the round. A qualifying semi-final carries <code>round: "... - Semi-finals"</code> and <code>round_code: SF</code>, exactly as a main-draw semi-final does; <code>Q</code>/<code>Q1</code>..<code>Q4</code> appear only where the feed itself names the round as qualifying, which most feeds do not. <code>round_code</code> previously said "this is the field to branch on" without that limit, and two customers independently read a qualifying match as a main-draw one and reported it as a data fault. The data was correct in every case; the documentation was not. No field changed shape or value.</li>
</ul></article>
<article id="v1.13.36"><h2>1.13.36 <time datetime="2026-09-21">2026-09-21</time></h2>
<h3>Changed</h3>
<ul>
<li><strong><code>GET /charting/players</code> states a sample of 11,803 charted matches, not 11,646.</strong> The charted corpus was refreshed on 2026-09-19 and 184 matches — 133 of them played after 2026-05-24, including the US Open women's draw to the quarter-finals — were loaded into the product on 2026-09-21. <code>matches_charted</code> on every response has always been the true per-player denominator and was never affected; the curated-coverage note beside it was quoting an older total and understating the sample. <code>GET /rally/matches</code> grew in the same load: 11,822 charted matches and 1,875,132 shot-by-shot points, with the newest women's chart now 2026-09-09. The men's corpus is unchanged because nothing has been charted upstream since 2026-05-21. No field changed shape.</li>
</ul></article>
<article id="v1.13.35"><h2>1.13.35 <time datetime="2026-09-21">2026-09-21</time></h2>
<h3>Added</h3>
<ul>
<li><strong><code>meta.points.games_short</code> on <code>GET /history/matches/{matchId}</code> counts the completed games the tape holds too few points for.</strong> Raised by a prospect evaluating per-point histories. A game the tape skips whole is a legal <code>0-0 → 0-0</code> boundary with the games counter up by one, and the transition test alone cannot see it — so a tape could read <code>complete: true</code> with a whole game missing. The measurement now walks the sequence by game boundary and compares what is held for each completed game with the fewest points that game can have contained given the last state it shows (from 0-0 at least 4; from 15-30 at least 5; from 40-40 at least 8; a tiebreak at least 7) — a lower bound, never an estimate. A game the tape skips whole counts; so does a game that ended with no further rows after its last stored point. <code>> 0</code> forces <code>complete</code> to <code>false</code>; nothing is inferred or repaired, and the game's boundary rows are still served exactly as stored. <code>null</code> = nothing measured (an empty sequence).</li>
<li><strong><code>basis_reason</code> on <code>GET /matches/{matchId}/points</code> says why a reconstruction was served instead of the stream.</strong> Present on the <code>reconstruction</code> basis only: <code>stream_absent</code> (no stored stream rows), <code>stream_incomplete</code> (the stream is legal but does not measure complete — it joined mid-match or stopped short — and carries no tags) or <code>stream_illegal</code> (at least one transition is not attributable to one point: a gap or a torn row). A stream that is complete, or tagged and legal end to end, is always served on the <code>live</code> basis and the key is absent. Additive; nothing else in the response changes shape.</li>
</ul>
<h3>Changed</h3>
<ul>
<li><strong><code>meta.points.complete</code> now means the served sequence holds at least every point the score requires — not merely that consecutive rows are consistent with each other.</strong> True only when the sequence opens at 0-0, every transition is a legal single-point step, it carries at least as many point transitions as the final scoreline implies the match contained, every completed game holds at least as many point transitions as it must have contained (<code>games_short</code> is <code>0</code>), it reaches a finished final scoreline at love (or the match ended early — retirement/walkover), it is not known-truncated, and no game was withheld (<code>games_withheld</code>). <code>ends_at_final</code> on the same object now also requires that the last row shows no game in progress — points at love or null, not a tiebreak in progress: a row carrying a finished spine with a game still underway (<code>[[6,6],[3,4]]</code> at 15-0) reads <code>false</code>, where before the spine alone decided. Some tapes that read <code>complete: true</code> until now will read <code>false</code>; no row is changed.</li>
<li><strong>A recurring score state on the history tape pairs its <code>serve</code>/<code>outcome</code> by nearest clock, instead of reading null.</strong> Tags on <code>GET /history/matches/{matchId}</code> rows are joined from the match's per-point stream by exact score state, and a state the stream asserts more than once (a deuce cycle revisiting deuce) cannot be told apart by the score, so until now it read <code>null</code> outright. Where a state recurs the join now pairs by nearest clock within 90 seconds — the tape row's observed clock against the stream point's, each stream point used at most once — and reads <code>null</code> otherwise (no candidate within the window, or two at the same distance). Every tag is an observed pairing, state and where needed clock, never an inference from the pattern of play. Reconstructed rows and rows no stream point matches still read <code>null</code>.</li>
</ul>
<h3>Fixed</h3>
<ul>
<li><strong>A stored live stream that is complete, or tagged and legal end to end, is served on a completed match; a reconstruction is projected only when the stream falls short.</strong> Until now a measured-complete recorded sequence displaced the stream unconditionally, so a completed match could lose its <code>serve</code>/<code>outcome</code> tags and every per-point clock the moment a reconstruction landed — a projection carries neither, so a complete tagged stream is strictly more information than any reconstruction of the same match. <code>GET /matches/{matchId}/points</code> now keeps the <code>live</code> basis whenever the stream is itself measured complete (match-closing point included) or carries tags and is legal end to end (every transition one attributable point, judged in playing order), and serves the projection only when the stream has no rows, is incomplete and untagged, or holds a transition nobody can attribute — <code>basis_reason</code> says which. When the reconstruction serves it still serves wholesale; the two sequences are never interleaved. If a completed match reads <code>reconstruction</code>, re-read from <code>after_seq=0</code> rather than resuming a live cursor into it.</li>
<li><strong>A double fault stated on a first serve is a contradiction; the point is served untagged.</strong> A source point whose serve number and outcome contradict each other — a double fault on serve 1 — was carried through as stated. <code>serve</code> and <code>outcome</code> on such a point now both read <code>null</code> on <code>GET /matches/{matchId}/points</code> and on the WebSocket frames rather than tagged wrongly; nothing is corrected to a second serve. Stored tags that carried the contradiction have been cleared.</li>
</ul></article>
<article id="v1.13.34"><h2>1.13.34 <time datetime="2026-09-21">2026-09-21</time></h2>
<h3>Changed</h3>
<ul>
<li><strong>The reference said Davis Cup and the Grand Slams "read null throughout" for the per-point tags; <code>serve</code> is in fact stated on the top Davis Cup tiers.</strong> <code>serve</code> and <code>outcome</code> are joined from *different* outside sources, and the old sentence excluded both on the strength of one of them: the tour's own per-point console does not carry team ties or Slams, so <code>outcome</code> is genuinely <code>null</code> there — but the source that states the serve number does carry them. Measured over completed matches with a per-point stream since 2026-09-12: Davis Cup World Group states <code>serve</code> on 22 of 22 matches and World Group I on 41 of 42, while World Group II states it on 0 of 38; <code>outcome</code> is null on all of them, on every one of the 14,132 rows. The machine-readable authority was right throughout and is unchanged — a World Group I match answers <code>enrichment: {"serve": "stated", "outcome": "none"}</code> and a World Group II match answers <code>{"serve": "none", "outcome": "none"}</code> — so this corrects the prose to match what the API already returns, in the direction of more coverage, not less. The <code>serve</code> description now names the Davis Cup tiers in its coverage list with the measurement; the <code>outcome</code> description now separates the two sources instead of excluding a competition from both. No field, endpoint or behaviour change.</li>
</ul></article>
<article id="v1.13.33"><h2>1.13.33 <time datetime="2026-09-21">2026-09-21</time></h2>
<h3>Changed</h3>
<ul>
<li><strong><code>serve</code> and <code>outcome</code> said nothing about qualifying or doubles draws, and the internal reference called both blanket-<code>null</code>.</strong> The per-point tags (added 2026-09-12) were documented tour by tour for main draws only. Measured over completed matches carrying a per-point stream since the source went live on 2026-09-12: WTA qualifying states <code>serve</code> on 48 of 48, Challenger (men) qualifying on 153 of 155, Challenger (women) on 49 of 51; ITF qualifying states <code>outcome</code> (aces and double faults) on 95 of 107 men's and 62 of 74 women's. Qualifying draws are covered like their own main draw, with exactly one real gap — <code>outcome</code> on Challenger (men) qualifying, which no source states at all. The doubles half of the old claim is confirmed and now carries its denominator: across 454 completed doubles matches in the same window, not one row carries either field. Both descriptions now state where each field is stated, including the qualifying and doubles positions; <code>enrichment</code> per match remains the authority and none of it is a promise about a match not yet played. Found by the every-sweep tag-coverage check, which reads 85 of 86 Challenger qualifying matches carrying <code>serve</code> against a documented <code>null</code>. No field, endpoint or behaviour change.</li>
</ul></article>
<article id="v1.13.32"><h2>1.13.32 <time datetime="2026-09-21">2026-09-21</time></h2>
<h3>Changed</h3>
<ul>
<li><strong>A match that ends in a tiebreak closes with the set roll-up row after the decisive tiebreak score.</strong> The match-closing row on <code>GET /matches/{matchId}/points</code> was described as if the last point were always a game point one point short of the final, and a match decided in a tiebreak did not fit that shape. The docs now say what the stream actually holds: when a completed match ends in a tiebreak, the stream's last stored row is the decisive tiebreak score itself (7-3, or 8-6) and the closing row is the set roll-up after it — the next game number, <code>number</code> 0, <code>tiebreak</code> false, <code>sets</code> incremented for the tiebreak winner, the set banked 7-6 in <code>games</code>, <code>score</code> 0-0, <code>server</code> null, <code>winner</code> the tiebreak winner — the same row the stream stores after every other set-ending tiebreak, so <code>ends_at_final</code> reads <code>true</code> there. A 10-point match tiebreak still cannot be stated as one point and is refused as before (<code>ends_at_final: false</code>), as is a tape that never reached the final.</li>
</ul></article>
<article id="v1.13.31"><h2>1.13.31 <time datetime="2026-09-20">2026-09-20</time></h2>
<h3>Added</h3>
<ul>
<li><strong><code>GET /matches/{matchId}/points</code> now closes a completed match with the match-closing point, and <code>ends_at_final</code> says whether it did.</strong> Raised by a prospect evaluating per-point tapes, who asked where the point that wins the match is. Every row is the state *after* a point, so a game-winning point is carried by the next game's <code>number: 0</code> opener — and the match-winning point had no next row to be carried by: the live stream never held it, and a serve statistic built off the stream was missing every match's last point. On a completed match served on the <code>live</code> basis the page now ends with one terminal row: <code>seq</code> = last + 1, <code>number</code> 0, <code>sets</code> / <code>games</code> the final score, <code>score</code> 0-0, <code>tiebreak</code> false, <code>server</code> null (nobody serves next), <code>winner</code> the match winner, <code>ts</code> the instant the final score was observed. It is built at read time from the stream's last row and the observed final score, and only when the two are one point apart; nothing is fabricated otherwise. <code>ends_at_final</code> (boolean) on the response says whether the sequence served ends on that row: <code>false</code> on a completed match whose stream stops short of it (a retirement or walkover, a capture that stopped two or more points short, a closer from deuce or from a match tiebreak that cannot be stated as one point), always <code>false</code> on a live match, and on the <code>reconstruction</code> basis judged from the projected sequence's last frame. <code>serve</code> / <code>outcome</code> on the terminal row are null: no source's tag for a match's last point is stored yet. WebSocket and push frames are unchanged.</li>
<li><strong><code>tagged_at</code> on every point row, and <code>?changed_since=</code> on the same endpoint, so late serve/outcome tags are collectable without a socket.</strong> The same prospect asked how to pick up tags that land after a row was read. <code>after_seq</code> is a cursor by <code>seq</code>, so it can never return a row already held — and a <code>serve</code> / <code>outcome</code> tag that lands late lands on exactly such a row; until now the only way to see it was the <code>point_update</code> frame on a WebSocket. Every row now carries <code>tagged_at</code>, the UTC instant its tags landed (null while none has, and on every <code>reconstruction</code>-basis frame), and <code>?changed_since=<ISO-8601 instant></code> (<code>2026-09-20T00:35:18Z</code>; <code>Z</code> or an offset, a naive value is read as UTC, a date alone is refused) returns only the rows whose <code>ts</code> <strong>or</strong> <code>tagged_at</code> is later than that instant, in <code>seq</code> order, paged as usual and composable with <code>after_seq</code>. The post-match recipe: read the match, keep the instant, re-read with <code>changed_since=<that instant></code> and replace held rows by <code>seq</code>. Anything that is not an ISO-8601 timestamp is a <code>400 bad_changed_since</code>. On the <code>reconstruction</code> basis no row carries a clock or a tag, so a <code>changed_since</code> read of it is an empty page: that sequence is final at first read.</li>
</ul></article>
<article id="v1.13.30"><h2>1.13.30 <time datetime="2026-09-20">2026-09-20</time></h2>
<h3>Changed</h3>
<ul>
<li><strong>The ATP per-point outcome row said "full" as a projection from the feed's content, not a measurement.</strong> In the 30 days since the tour's own per-point console went live (2026-09-12), no ATP main-tour event was actually played — the only tour tennis in that window was a Grand Slam and Davis Cup, neither of which is on that feed — so every ATP main-tour match carried no outcome tags at all, while the docs asserted <code>full</code> (ace, double fault, winner, forced error, unforced error) unconditionally. The row now names the tours the feed covers (ATP 250 / 500 / 1000), states plainly that Grand Slams and Davis Cup are not on it and read null throughout (check <code>enrichment</code> per match), and gives the first date the claim was actually measured against a played tour event: the week of 2026-09-22.</li>
</ul></article>
<article id="v1.13.29"><h2>1.13.29 <time datetime="2026-09-20">2026-09-20</time></h2>
<h3>Changed</h3>
<ul>
<li><strong>The <code>GET /history/matches</code> listing's point-completeness ledger now judges the same raw sequence the per-match read serves, re-sent rows dropped — not the <code>clean</code> collapse.</strong> The ledger's previous default basis, <code>clean</code>, collapses the tape to one row per distinct score state; that collapse can delete a real deuce point — two rows sitting at 40-40 with no advantage row between them is a legitimate rally, not a duplicate — and call the resulting, shorter tape point-complete when the tape actually served is not. The ledger now judges the raw sequence with provable re-sends dropped, the exact judgement the per-match read makes and publishes as <code>meta.points.complete</code>, so <code>tape.points_complete_default</code> on the listing and <code>meta.points.complete</code> on a fetched match agree by construction instead of disagreeing on a sequence the read never served. Measured on 7,717 completed matches scored since 1 Sep: the <code>clean</code> collapse called 70 of them point-complete whose served (raw) tape was not, and missed 810 whose served tape was. <code>clean</code> remains available only as a request option (<code>?sequence=clean</code>); rows scored before 2026-09-20 keep <code>points_basis: clean</code> until the nightly re-score reaches them. <code>tape.points_basis</code>, <code>tape.points_complete_default</code> and <code>tape.points_complete</code> descriptions updated to match.</li>
</ul>
<h3>Fixed</h3>
<ul>
<li><strong><code>tape.computed_at</code> on the <code>GET /history/matches</code> listing carried the serving host's UTC offset (<code>+03:00</code> on prod) instead of a trailing <code>Z</code>.</strong> Same instant, valid ISO-8601, but every other timestamp in the payload — <code>meta.generated_at</code>, each row's <code>timestamp</code> — ends in <code>Z</code>, so a consumer diffing <code>computed_at</code> against either had to normalise it by hand first. Now projected to UTC with a trailing <code>Z</code> like the rest of the surface.</li>
</ul></article>
<article id="v1.13.28"><h2>1.13.28 <time datetime="2026-09-20">2026-09-20</time></h2>
<h3>Added</h3>
<ul>
<li><strong><code>meta.points.games_withheld</code> on <code>GET /history/matches/{matchId}</code>.</strong> Present whenever reconstructed rows are in the response: the count of games whose vendor point record was internally inconsistent — a recorded score state that repeats or moves backwards partway through the game — and whose in-game points are therefore withheld rather than published. The game's opening <code>0-0</code> row is still served, so the games spine stays intact; nothing is repaired, reordered or inferred — a game is served exactly as recorded, or not at all. <code>> 0</code> forces <code>meta.points.complete</code> to <code>false</code>. <code>null</code> means not measured: either the reconstruction predates this check, or it was built from the multi-vendor union rather than a single vendor's point record.</li>
<li><strong><code>tape.points_complete_default</code>, <code>tape.points_complete_recon</code> and <code>tape.points_basis</code> on the <code>GET /history/matches</code> listing.</strong> <code>points_complete</code> on that listing is a best-basis OR: the ledger's default basis is the <code>clean</code> collapse of an observed tape, while the per-match read serves and measures the <code>raw</code> sequence, so <code>points_complete: true</code> can sit next to <code>meta.points.complete: false</code> on the same match with both honest about their own sequence. These three fields say which measurement produced the <code>true</code> and on which sequence — <code>points_complete_recon: true</code> means "fetch it with <code>?points=complete</code>". All null when not yet measured.</li>
<li><strong><code>meta.points.resent_rows</code> on <code>GET /history/matches/{matchId}</code>.</strong> A row identical to the row before it is the same state re-sent by a source on its timer, which the raw tape carries by design — no point separates the two rows, so they no longer count as a non-point transition. <code>transitions_total</code> is now <code>rows − 1 − resent_rows</code>. <code>resent_rows</code> is <code>0</code> on a <code>clean</code> or <code>recon</code> basis: the collapse already removes re-sends, and in a reconstruction one row is one point, so a repeat there is a vendor tear and is never dropped.</li>
</ul>
<h3>Fixed</h3>
<ul>
<li><strong>A vendor's finished-match point record that moved backwards inside a game was being reconstructed as if it were legal.</strong> Since July 2026 one vendor's point-by-point feed has not been monotone within a game (a real example: 40-30, 15-15, 15-30, 30-30, 15-30, 30-30, 30-30, 40-30 — 547 of 586 August ATP/WTA matches carried at least one such game). The tape builder kept the longest forward-moving chain through the inconsistency and published the result as an ordinary reconstructed row, so a state like 30-30 repeating three times in a row was served as fact. Those games are now withheld whole instead — counted in the new <code>games_withheld</code> field — rather than fitted into a plausible-looking sequence.</li>
</ul></article>
<article id="v1.13.27"><h2>1.13.27 <time datetime="2026-09-20">2026-09-20</time></h2>
<h3>Added</h3>
<ul>
<li><strong><code>GET /history/matches/{matchId}</code> tape rows now carry <code>origin</code>, and observed rows carry <code>serve</code> / <code>outcome</code>.</strong> Raised by a prospect evaluating reconstructed tapes as evidence for an independent model, who asked how to tell observed from reconstructed rows explicitly, when a reconstruction landed, and whether the per-point serve/outcome tags reach the history tape at all. <code>origin</code> (<code>observed</code> | <code>reconstructed</code>) is read from the row's stored provenance — the same fact that nulls the clock on a reconstructed row, never the null clock itself — so a caller no longer has to treat <code>timestamp: null</code> as an inferred marker. <code>serve</code> (<code>1</code> | <code>2</code> | null) and <code>outcome</code> (<code>ace</code> | <code>double_fault</code> | <code>winner</code> | <code>forced_error</code> | <code>unforced_error</code> | null) are joined onto <strong>observed rows only</strong> from the per-point stream by EXACT score state: set count, every set's games, in-game points and the tiebreak flag all equal. A tape row stores no point ordinal, so a score state the stream asserts more than once (a deuce cycle revisiting deuce) is ambiguous and reads null, as does a row no stream point matches and every <code>reconstructed</code> row — nothing is inferred from the score. All additive on <code>GET /history/matches/{matchId}</code>; the pre-2023 <code>ArchiveTape</code> (every row reconstructed) does not carry <code>origin</code>, <code>serve</code> or <code>outcome</code>.</li>
<li><strong><code>meta.reconstructed_at</code>, <code>meta.observed_span</code> and <code>meta.enrichment</code></strong> on the same endpoint. <code>reconstructed_at</code> is the write time (UTC) of the newest reconstructed row actually served, null when none is; <code>observed_span</code> is <code>{"first", "last"}</code> — the <code>timestamp</code> of the first and last observed rows served, in served order, null when none is; <code>enrichment</code> is <code>{"serve": "stated" | "none", "outcome": "full" | "ace_double_fault" | "none"}</code>, the vocabulary that actually landed on this response's rows (<code>none</code>/<code>none</code> on a wholly reconstructed tape). All three are measured on the rows returned, after any <code>sequence=clean</code> collapse.</li>
</ul>
<h3>Changed</h3>
<ul>
<li><strong><code>meta.points.available_complete</code> is now read-through instead of nightly-only.</strong> It still answers from the nightly ledger when the ledger has an entry. When the ledger has none yet — a reconstruction that landed after the last nightly run — and this read served a reconstruction, it now answers from the live verdict just measured on that reconstruction instead of <code>null</code>. It reads <code>null</code> only when there is no ledger entry and nothing was reconstructed on this read to measure. No field or endpoint added; behaviour change on an existing field.</li>
</ul></article>
<article id="v1.13.26"><h2>1.13.26 <time datetime="2026-09-20">2026-09-20</time></h2>
<h3>Fixed</h3>
<ul>
<li><strong>Team-competition matches now carry the surface of their tie.</strong> Davis Cup, Billie Jean King Cup and Laver Cup matches published <code>surface: null</code> on the match and on <code>tournament.surface</code> — 268 of 268 team-event matches in the 60 days to 2026-09-20. 1.13.25 documented that null as structural; it was only structural for the sources we had. Every surface source was per tournament, and the vendor's team "tournament" (<code>ATP Davis Cup - World Group I</code>) is a tier whose surface field carries the draw stage, not a surface; a tie is played on whatever its host nation chose. The ITF's own draw feed states the surface, venue and indoor/outdoor flag per tie, so the API now holds one row per tie and resolves each match to its tie by competition, date window and the nations on court — answering only when a single tie remains, never from a nation, a name or a previous tie. Existing team-event matches (last 60 days and upcoming) were backfilled with one audit row per write; new ties are picked up as they are drawn. Ties whose venue the ITF has not announced stay null. <code>tournament.surface</code> for a team competition remains null by design: the surface belongs to the tie, and it is published on the match. Raised by a public report that Davis Cup match 191867 returned <code>surface: null</code>; that match now serves <code>hard</code>. Payload shape unchanged.</li>
</ul>
<h3>Changed</h3>
<ul>
<li><strong><code>Match.surface</code> and <code>Tournament.surface</code> descriptions</strong> now state the per-tie rule for team competitions instead of describing the null as permanent.</li>
</ul></article>
<article id="v1.13.25"><h2>1.13.25 <time datetime="2026-09-20">2026-09-20</time></h2>
<h3>Fixed</h3>
<ul>
<li><strong>A match that had been given a <code>tournament_id</code> was never given the surface that id serves.</strong> <code>matches.tournament_key</code> was backfilled on 2026-09-04 for matches discovered through the secondary source, which had been publishing <code>tournament_id: null</code>; the pass gave those rows an id and stopped there. The catalogue-surface fallback runs only in the ingest upsert, and every repaired row was already terminal, so it never ran again: 2,533 matches (2026-06-21..2026-09-04, all ITF) published <code>surface: null</code> while <code>GET /tournaments/{id}</code> — for the id that same row hands you — answered <code>"surface": "hard"</code>. Two surfaces for one question, each written by a different writer. Measured and wire-confirmed before the repair on match <code>186637</code> (<code>surface: null</code>, <code>tournament_id: "12158"</code>) against <code>/tournaments/12158</code> (<code>"surface": "hard"</code>), and after it on the same row. All 2,533 now carry the surface their own tournament states; <code>indoor</code> was filled from the same catalogue row where the match held none. Nothing after 2026-09-04 was affected — the same commit's ingest hook sets the key before the fallback reads it, so the forward path has been correct throughout and the weekly count is flat zero since. The repair resolves surface through the same chain the ingest uses (tournament-name map first, catalogue second), so a later upsert cannot answer one of these rows differently. Match-level <code>surface</code> null over the previous 90 days: 19.8% before, 11.8% after. No field or endpoint added.</li>
</ul>
<h3>Changed</h3>
<ul>
<li><strong><code>Match.surface</code> and <code>Tournament.surface</code> now say when they are null and why.</strong> Both were documented as nullable with no account of the population, while the <code>tournament_id</code> field beside them carries a measured one. The remaining match-level nulls are two structural groups and an 8-row residue: UTR events, which are not in the tournament catalogue at all (and so publish <code>tournament_id: null</code> too), and TEAM COMPETITIONS — Davis Cup, Billie Jean King Cup, Laver Cup — where the surface is the host nation's choice per tie, so neither the season-long tournament row nor the match has one to state. Raised by a public report that Davis Cup matches return <code>surface: null</code>. For those 35 catalogue rows the upstream surface field carries the draw tier ("- Preliminary", "- Play Offs", "- Promotion") rather than a surface, and a value outside hard/clay/grass is rejected rather than published. No behaviour change.</li>
</ul></article>
<article id="v1.13.24"><h2>1.13.24 <time datetime="2026-09-20">2026-09-20</time></h2>
<h3>Changed</h3>
<ul>
<li><code>GET /matches/{matchId}/score</code> no longer answers 404 for a settled match whose live-score rows were retired by the 90-day retention sweep, or that only ever had an archived final. When there is no live row at all, a match with a non-null <code>outcome</code> (completed, retired, walkover, default, abandoned, unresolved) is answered from its archived final — the same read <code>GET /matches?status=completed</code> already embeds, through the same serializer — so the listing and the single-match read can no longer disagree about whether a score exists. Unchanged: a live match always reads its live tape (the archive never outranks it); an upcoming match with no row, a cancelled match that was never played, and a settled match with nothing recorded anywhere still answer 404. An archived final carries <code>age_seconds</code>, <code>observed_age_seconds</code>, <code>sources_count</code> and <code>accepted_at</code> as null — no clock is claimed for a state nobody watched — and <code>timestamp</code> null where the archived row has none; the Score object still carries no data-source label, <code>GET /history/matches/{matchId}</code> <code>meta.point_source</code> says whether the final was observed or reconstructed. Behaviour change on the API side 2026-09-20; measured at the change: 13,437 completed matches from the previous 180 days, 157,170 all time, now answer <code>/score</code> instead of 404. No field or endpoint added.</li>
</ul></article>
<article id="v1.13.23"><h2>1.13.23 <time datetime="2026-09-20">2026-09-20</time></h2>
<h3>Fixed</h3>
<ul>
<li><strong><code>GET /rankings</code> accepted <code>?surface=</code>, <code>?min_matches=</code>, <code>?activity_weeks=</code> and a contradicting <code>?tour=</code> on the official systems, and silently ignored all four.</strong> Measured on production before the fix: <code>?system=atp&surface=clay</code>, <code>?system=atp&min_matches=200</code>, <code>?system=atp&activity_weeks=4</code> and <code>?system=atp&tour=wta</code> each returned <code>200</code> and the plain overall ATP table, byte-identical to the same call with no parameter at all; <code>?system=atp&tour=zzz</code> was accepted too. All four are documented "Elo only" / "Elo listing" and are read nowhere else, so a caller asking for a clay ATP leaderboard received the overall one with nothing in the response to say the filter had not run. The <code>elo</code> branch was correct throughout and is the control that makes this a defect rather than an empty population - <code>?system=elo&tour=atp&surface=clay</code> returns a genuinely different top 10 from <code>surface=hard</code>, and <code>surface=zzz</code> is refused with the valid list. Unlike the <code>has_market</code> hole fixed in 1.13.21, this one cannot be closed by making the filter act: there is no clay ATP ranking and no WTA row in an ATP table. <code>surface</code>, <code>min_matches</code> and <code>activity_weeks</code> are now a <code>400</code> when no requested system is <code>elo</code> (a mixed <code>?system=atp&system=elo&surface=clay</code> still passes); <code>tour</code> is a <code>400</code> only when it contradicts a system the caller NAMED, or when that system is not an ATP/WTA walk. <code>?system=atp&tour=atp</code> is redundant rather than wrong and still answers, and the implicit default system set is unchanged.</li>
</ul></article>
<article id="v1.13.22"><h2>1.13.22 <time datetime="2026-09-20">2026-09-20</time></h2>
<h3>Added</h3>
<ul>
<li><strong><code>GET /history/archive/players</code> now takes <code>?id=</code> (alias <code>?player_id=</code>), so the corpus person id we publish can finally be looked up.</strong> Archive match rows have published that id as <code>winner.player_id</code> / <code>loser.player_id</code> since 2026-08-03, and this endpoint has published it as <code>id</code> and described it in those words — but the only surface accepting the value was <code>/rankings?archive_player=</code> — ULTRA, <code>system=elo</code> only, and it answers with RATINGS, not the person. So the PERSON could not be reached by id at all: a reader holding a corpus id could only go back to the NAME. It is a filter on the list rather than a <code>/history/archive/players/{id}</code> detail route on purpose: the natural key is <code>(tour, source_pid)</code>, the two tours number independently, and 14,627 corpus person ids are live in BOTH (measured 2026-09-20) — a detail route would have to pick a tour and would return the wrong human for those ids without saying so. The list returns every person wearing the id; add <code>tour</code> to narrow it to exactly one. Sending <code>id</code> and <code>player_id</code> with different values is a <code>400</code>.</li>
</ul>
<h3>Fixed</h3>
<ul>
<li><strong>A corpus person id sent to <code>GET /players/{playerId}</code> returned a bare <code>404</code> that explained nothing.</strong> The roster (<code>/players</code>, ids 1-37033) and the results-archive person registry (100001-270580) are separate, deliberately disjoint id spaces - 0 of 137,483 corpus ids resolve on <code>/players/{playerId}</code> - and the only id-shaped route with <code>player</code> in its name was the roster one. The <code>404</code> now carries <code>detail</code> and a <code>see</code> pointer at <code>/history/archive/players?id=...</code> when the id resolves in the archive registry; an id from neither space keeps the plain body. The same signpost is on <code>/players/{playerId}/stoppages</code> and <code>/players/{playerId}/injuries</code>. The status is deliberately still <code>404</code> and not <code>410</code>: <code>410</code> asserts the id once existed HERE, and it never did. The person is never named in the <code>404</code> - the archive is History-gated and <code>/players/{playerId}</code> is not.</li>
<li><strong><code>GET /players/{playerId}</code> was missing its <code>410</code> in this specification.</strong> The route has answered <code>410 PlayerMerged</code> with a forwarding address for a merged or retired player id for as long as its two stoppage aliases have, and both aliases documented it while the main detail endpoint did not. Now documented, along with a <code>PlayerNotFound</code> schema that states exactly when <code>detail</code> and <code>see</code> are present.</li>
</ul></article>
<article id="v1.13.21"><h2>1.13.21 <time datetime="2026-09-20">2026-09-20</time></h2>
<h3>Fixed</h3>
<ul>
<li><strong><code>GET /matches</code> accepted <code>?has_market=</code> and silently ignored it, and <code>status=cancelled</code> ignored <code>?has_analysis=</code> as well.</strong> The filter was implemented in the shared query builder on 2026-09-15 and exposed on <code>/history/matches</code> the same day, but it was never wired to <code>/matches</code>: every row published <code>has_market</code>, the query string was accepted, and the unfiltered page came back under a <code>200</code>. Measured on production before the fix — <code>status=live</code> returned the same 14 rows (2 with a market) for <code>has_market=true</code>, <code>has_market=false</code> and no filter at all; <code>status=completed</code> the same 100 rows (38 with a market); <code>/history/matches</code> was correct throughout (40/40 and 0/40). Five of the eight status x filter combinations returned a wrong answer under a success code. Both filters now apply on every status, and an unparseable value is a <code>400 bad_has_market</code> / <code>bad_has_analysis</code> as documented.</li>
</ul>
<h3>Added</h3>
<ul>
<li><code>has_analysis</code> and <code>has_market</code> are now DOCUMENTED as query parameters on <code>GET /matches</code>. <code>has_analysis</code> has worked on <code>live</code>, <code>upcoming</code> and <code>completed</code> since 2026-09-14 and <code>has_market</code> works everywhere from today, but neither appeared in this specification, so the only filter a reader could find was <code>has_market</code> on <code>/history/matches</code>. The prose telling callers to "filter the slate first" was therefore advice with no documented instrument behind it.</li>
</ul></article>
<article id="v1.13.20"><h2>1.13.20 <time datetime="2026-09-19">2026-09-19</time></h2>
<h3>Fixed</h3>
<ul>
<li><strong>The two win-probability regime boundaries published in 1.13.19 were three hours late, and are corrected here: 2026-08-18T08:29:28Z and 2026-09-16T07:59:54Z.</strong> Both switch instants were read off <code>config.updated_at</code>, a column this application stores in <strong>naive local wall time (UTC+3)</strong>, and were published as if they were UTC. Verified six independent ways on 2026-09-19: six config rows whose own value is an epoch each read exactly +3 h against their <code>updated_at</code> (e.g. <code>monitoring.worker_heartbeat_ts</code> = 2026-09-19T20:22:46.465Z stored as <code>23:22:46.467</code>). The corrected instants are the config switches themselves — <code>win_probability.match_tiebreak_detection_enabled</code> at 2026-08-18 08:29:28.93Z and <code>win_probability.itf_pricing_draw_rule_enabled</code> at 2026-09-16 07:59:54.76Z — which is the earliest instant from which a state can carry the new rule, and therefore the safe place to cut.</li>
<li>With it, the measured lag of the version string: the <code>+itfdraw-2026-09-16</code> suffix first appears on rows generated 2026-09-17T05:04:25Z, which is <strong>21 h 04 m</strong> after the rule landed, not 18 h 02 m. That figure had been derived from the same three-hour-late instant. <code>2026-09-17T05:04:25Z</code> and <code>2026-09-12T12:08:17Z</code> were both taken from <code>win_probability_meta.generated_at</code>, a timezone-aware column, and are unchanged and correct.</li>
<li>Anyone who segregated a corpus on 2026-08-18T11:29:28Z or 2026-09-16T11:02:00Z should re-cut: the three hours before each corrected instant are on the wrong side of the split.</li>
</ul></article>
<article id="v1.13.19"><h2>1.13.19 <time datetime="2026-09-19">2026-09-19</time></h2>
<h3>Changed</h3>
<ul>
<li><code>Score.win_probability_meta</code>: the two win-probability <strong>regime boundaries</strong> are published here for the first time, so a customer can segregate a recorded corpus without asking us. Two changes moved published live probabilities before <code>model_version</code> reflected them — 2026-08-18T11:29:28Z, when the over-inclusive deciding-set rule for lower-tier singles began, and 2026-09-16T11:02:00Z, when the draw-based rule replaced it (BOTH INSTANTS WERE THREE HOURS LATE AND ARE CORRECTED IN 1.13.20 — use 08:29:28Z and 07:59:54Z) (up to 0.20 on affected deciding-set states) — so the cut is on <code>generated_at</code>/<code>timestamp</code>, never on the version string. Published in the application's internal reference on 2026-09-17 and not ported here until now; docs.livetennisapi.com, which is the artifact a customer opens, carried none of it.</li>
<li>The same description states, newly measured, that <code>model_version</code> is <strong>not</strong> a safe discriminator across the second boundary: the <code>+itfdraw-2026-09-16</code> suffix first appears on rows generated 2026-09-17T05:04:25Z, 18 h 02 m after the rule landed (corrected to 21 h 04 m in 1.13.20). Measured against production on 2026-09-19: in that gap 33,510 states across 351 matches — 9,719 states over 113 matches at the ITF M15/W15/W35 levels the rule governs — were computed under the corrected rule while still carrying <code>markov-population-2026-08-23</code>. A cut on a whole-day boundary mislabels exactly those rows; a cut at the 11:02:00Z instant does not. The previous internal wording ("timestamp only up to 2026-09-17") was right to the day and vague by eighteen hours.</li>
<li><code>Score.win_probability_meta.model_version</code> now carries a description of its own, and the pre-stamp caveat is stated with its instant: no row generated before 2026-09-12T12:08:17Z carries <code>model_version</code> or <code>generated_at</code> at all.</li>
<li><code>HistoryTapeRow.win_probability_p1</code>: documented that the tape carries no model-regime stamp — no <code>model_version</code> or <code>generated_at</code> column here or in the bulk <code>tape</code> packages — so a recorded tape corpus is segregated by the row's own <code>timestamp</code> against those two instants.</li>
</ul>
<p>Documentation only. No field, endpoint or wire value changed.</p></article>
<article id="v1.13.18"><h2>1.13.18 <time datetime="2026-09-19">2026-09-19</time></h2>
<h3>Fixed</h3>
<ul>
<li><code>LivePoint.number</code> (<code>GET /matches/{matchId}/points</code>, the <code>point</code> frame, the webhook <code>point</code> event): the field is documented as <strong>null when we joined the game already in progress</strong>, and the API now writes null there instead of <code>0</code>. <code>0</code> asserts a game's opening state, and on a game we picked up mid-way that is false — how many points it already holds is not derivable from the score, because the deuce zone maps many ordinals onto one score, and it is never guessed. Measured on 2026-09-19, 1,341 rows over three days carried <code>number: 0</code> at a non-love score. A second defect in the same computation is fixed with it: a late-arriving row for a *different* game committed between two points of the game in progress reset the ordinal, so on match 191801 <code>seq</code> 65 and 67 were both <code>(set 2, game 1, number 0)</code> — at 15-15 and 15-40 — with <code>seq</code> 66, a point of set 1 game 9, between them. The ordinal now continues within its own game regardless of what arrived in between. The schema already permitted null; only the description and the written values change.</li>
</ul>
<h3>Changed</h3>
<ul>
<li>Documented, in all three places the replay guidance appears, that <code>(set, game, number)</code> <strong>orders</strong> points and does not <strong>identify</strong> them: the tuple may repeat, and <code>number</code> may be null. A repeat on the live basis is a re-statement of a game by a second source, not a correction — there is no revision id, superseded-<code>seq</code>, version or correction flag, because rows are append-only and never rewritten. <code>after_seq</code> therefore never needs a full refetch, and the page-level <code>quality</code> field already reads <code>revised</code> when a page contains such a re-statement. <code>seq</code> remains the only unique, stable per-row key.</li>
</ul></article>
<article id="v1.13.17"><h2>1.13.17 <time datetime="2026-09-18">2026-09-18</time></h2>
<h3>Added</h3>
<ul>
<li>Native WebSocket <strong>close codes</strong>, documented for the first time. Every refusal sends its <code>error</code> frame and then closes with a code that says what to do next — <code>1013</code> Try Again Later for transient refusals (<code>connection_limit:per_key</code>, <code>connection_limit:server</code>, <code>service_unavailable</code>), <code>1008</code> Policy Violation for anything a retry cannot fix (<code>unauthorized</code>, <code>upgrade_required</code>, <code>email_unverified</code>, <code>client_blocked</code>, <code>bad_json</code>, <code>no_topics</code>, and any mid-stream loss of access), <code>1012</code> on a restart, <code>1000</code> on a normal close. The close reason repeats the frame's <code>error</code> string, so <code>(code, reason)</code> is a complete diagnosis even if the frame was missed. Behaviour change the same day: refusals raised during the *handshake* previously closed <code>1000</code> with an empty reason, indistinguishable from an orderly shutdown, so a client awaiting its <code>subscribed</code> ack saw only a normal close. The error frame was, and still is, delivered before the close; only the close code and reason changed.</li>
</ul>
<h3>Changed</h3>
<ul>
<li><code>GET /matches/{matchId}/points</code>, the <code>point</code> frame and the <code>points</code> signal: <strong><code>seq</code> is arrival order, not match order, on the live basis.</strong> It is assigned in commit order, and a live match is fed by more than one upstream at different speeds, so a point from a set that has just ended can be committed after points from the set that follows it and carry the higher <code>seq</code>. Each row is self-consistent; reading the tape in <code>seq</code> order can show the set or game counter step backwards. Measured over a recent seven-day window this affected a minority of live matches, and never the <code>reconstruction</code> basis. <code>seq</code> remains the right key for paging, dedup and resume, and is the wrong key for chronology — sort by <code>(set, game, number)</code> to replay in playing order. The spec previously said "ordered per match by <code>seq</code>", which reads as chronological. Documentation only; nothing on the wire changed.</li>
</ul></article>
<article id="v1.13.16"><h2>1.13.16 <time datetime="2026-09-18">2026-09-18</time></h2>
<h3>Changed</h3>
<ul>
<li><code>GET /usage</code> → <code>today.remaining_day</code>: documented as the day allowance less <strong>SERVED</strong> calls (<code>calls - errors</code>), and the API now computes it that way. Refused requests have never spent the daily allowance — the <code>429</code> guidance in this spec already said so — but the endpoint subtracted gross calls, so it under-reported what a key could still send by exactly its error count. Measured on 2026-09-18, one ULTRA key was shown 14,360 fewer requests remaining than the API would have served it. <code>today.calls</code> and <code>today.errors</code> are unchanged: raw counters of everything the key sent.</li>
</ul></article>
<article id="v1.13.15"><h2>1.13.15 <time datetime="2026-09-18">2026-09-18</time></h2>
<h3>Fixed</h3>
<ul>
<li><code>event_status</code> (match object): the enum omitted <strong><code>Finished</code></strong> and <strong><code>Unresolved</code></strong>. <code>Finished</code> is the value the field carries on a normally-completed match and is by far its most common — 144,266 of the 150,678 rows carrying one, measured on 2026-09-18 — so a client generated from this spec with strict enum validation rejected the majority of completed matches. Both values are listed now. The API has always published them; only the spec was wrong, so nothing on the wire changed.</li>
</ul>
<h3>Changed</h3>
<ul>
<li><code>event_status</code> description: "NULL means the match completed normally" was wrong in the direction that matters — null means the feed never stated anything for the match, which covers matches that ran their course and matches nothing was ever said about alike. Branch on <code>outcome</code>, not on the absence of a badge.</li>
<li><code>event_status</code> description: <code>event_status: Finished</code> while <code>status</code> is still <code>live</code> is documented as the pending-final state (one source has called the match over, the final is not confirmed). The score stands still through it — <code>stale</code> true, <code>age_seconds</code> climbing. Measured over the seven days to 2026-09-18, across 649 matches, that gap closed in 111 s at the median and 911 s at the ninetieth percentile; 85 ran past ten minutes.</li>
</ul></article>
<article id="v1.13.14"><h2>1.13.14 <time datetime="2026-09-17">2026-09-17</time></h2>
<h3>Changed</h3>
<ul>
<li>Plan end: when a paid plan's period ends (or a renewal goes unpaid) the same key now drops to the FREE tier automatically instead of being switched off — nothing is re-issued, free endpoints keep answering at the free limits, paid endpoints answer <code>403 upgrade_required</code> from that moment, and subscribing again lifts the same key. Behaviour change on the billing side effective 2026-09-17 19:59 UTC; keys of plans that ended in the previous 30 days were moved to FREE the same evening. No field or endpoint changed.</li>
</ul></article>
<article id="v1.13.13"><h2>1.13.13 <time datetime="2026-09-17">2026-09-17</time></h2>
<h3>Changed</h3>
<ul>
<li><code>GET /history/matches/{matchId}</code> <code>tiebreaks</code> / <code>meta.tiebreaks_source</code>: the timing was stated wrongly. The <code>summary</code> and <code>reconstruction</code> kinds were described as "recorded at completion"; they are written by a finals pass that runs once a day, so a match that finished earlier the same day commonly reads null for its 7-6 sets and carries them from the next pass onward. Only the <code>tape</code> kind is available the instant a match ends. Measured 2026-09-17: 72-100% of 7-6 sets populated on matches completed over the five previous days, 8% on matches completed the same day. Documentation only — no behaviour or field changed.</li>
</ul></article>
<article id="v1.13.12"><h2>1.13.12 <time datetime="2026-09-16">2026-09-16</time></h2>
<h3>Changed</h3>
<ul>
<li><code>GET /history/matches/{matchId}</code> <code>tiebreaks</code>: per-set breaker finals are now filled from the point-by-point reconstruction and from the sources' set summaries when the live tape stopped one point short (measured before this change: null on about 99% of 7-6 sets — 297 measured, 3 populated). New <code>meta.tiebreaks_source</code> says, per set, whether the final came from <code>tape</code>, <code>summary</code> or <code>reconstruction</code>. The response shape is unchanged.</li>
</ul></article>
<article id="v1.13.11"><h2>1.13.11 <time datetime="2026-09-16">2026-09-16</time></h2>
<h3>Added</h3>
<ul>
<li><code>GET /players/{playerId}/stoppages</code> (PRO) and its alias <code>GET /players/{playerId}/injuries</code>: one player's in-match stoppages (medical timeouts, trainer calls; toilet breaks, pauses and other stoppages on request) plus the matches the player retired from or gave a walkover, newest first, over a window that defaults to the last 180 days. <code>meta.latest_medical_timeout</code> / <code>previous_medical_timeout</code> answer "the latest and the one before"; <code>meta.record_starts</code> states where each record begins (stoppage events from 2026-09-12; outcomes from 2026-08-18). In-match stoppages and match outcomes only; there is no off-court injury record.</li>
</ul></article>
<article id="v1.13.10"><h2>1.13.10 <time datetime="2026-09-15">2026-09-15</time></h2>
<h3>Changed</h3>
<ul>
<li><code>Score.sequence</code>: a backwards move on <code>GET /matches/{matchId}/score</code> has two documented causes, not one — a withdrawn state, or the read deferring to a strictly higher-trust source's fresh state (about 1% of live reads). On the push feed and the native WebSocket the sequence only ever rises.</li>
</ul></article>
<article id="v1.13.9"><h2>1.13.9 <time datetime="2026-09-15">2026-09-15</time></h2>
<h3>Added</h3>
<ul>
<li><code>Score.accepted_at</code> (string|null, live score reads and push frames): the instant we accepted this state, stamped once and never refreshed — the clock to difference a latency study against.</li>
</ul>
<h3>Changed</h3>
<ul>
<li><code>Score.timestamp</code> documented for what it is: our clock, stamped at acceptance and then refreshed (at most every 8 s) while the owning source keeps re-asserting an unchanged state, so on a live read it is usually the last-assertion instant. It was described as "when this state was true"; it never was. Never an upstream observation time.</li>
<li>The <code>Score</code> schema on this site now carries the full field set and descriptions: <code>sequence</code>, <code>age_seconds</code>, <code>stale</code>, <code>observed_age_seconds</code>, <code>sources_count</code> and <code>detail</code> were missing from the published table, and <code>sets</code>/<code>server</code>/<code>is_tiebreak</code>/<code>win_probability_p1</code>/<code>danger</code>/<code>timestamp</code> had no description.</li>
</ul></article>
<article id="v1.13.8"><h2>1.13.8 <time datetime="2026-09-15">2026-09-15</time></h2>
<h3>Changed</h3>
<ul>
<li>Wording correction to 1.13.7: the pre-match hold rates enter the engine <strong>snapped to a 0.01 grid</strong>, not "rounded to three decimals". The mechanism and every field are unchanged; only the stated granularity was wrong.</li>
</ul></article>
<article id="v1.13.7"><h2>1.13.7 <time datetime="2026-09-15">2026-09-15</time></h2>
<h3>Changed</h3>
<ul>
<li><code>win_probability_meta.market_anchored</code> is documented for what it always was: the market-prior anchor <strong>applies to this match</strong> (a two-sided pre-match price and a pre-play first score). It was described as "whether the anchor moved <code>win_probability_p1</code>", which is a per-row claim the flag never made — a small anchor shift can solve to a probability identical to <code>win_probability_p1_model</code>. Recorded values keep their meaning.</li>
</ul>
<h3>Added</h3>
<ul>
<li><code>win_probability_meta.anchor_effective</code> (boolean|null): this row's <code>win_probability_p1</code> differs from <code>win_probability_p1_model</code>. The per-row test <code>win_probability_p1 != win_probability_p1_model</code> works on frames recorded before this field existed.</li>
</ul></article>
<article id="v1.13.6"><h2>1.13.6 <time datetime="2026-09-15">2026-09-15</time></h2>
<h3>Added</h3>
<ul>
<li><code>GET /history/matches/{id}/prices</code> (PRO): the per-point price history — one row per played point with the score state the BASIC tape publishes and, per neutral side, the match-winner quote in force when the point was captured (<code>bid</code>, <code>ask</code>, <code>mid</code>, <code>spread</code>), with <code>lag_seconds</code> and an honest <code>resolution</code> label (<code>tick</code>, <code>minute</code>, <code>coarse</code>). Works on a live match too; 404 <code>no_market</code> when no market is mapped.</li>
<li><code>GET /history/matches?has_market=true|false</code> and a <code>has_market</code> flag on every history row, so a match with a price tape can be enumerated before it is pulled.</li>
</ul>
<h3>Changed</h3>
<ul>
<li>Price retention: from 2026-09-15 the in-play ticks of a match with a mapped market are kept at full resolution and are not deleted; pre-match and idle ticks keep the existing tiers. Earlier ticks were already thinned.</li>
<li><code>GET /matches/{id}/prices</code> answers 404 <code>no_market</code> for a known match without a market (it said <code>not_found</code>).</li>
</ul></article>
<article id="v1.13.5"><h2>1.13.5 <time datetime="2026-09-14">2026-09-14</time></h2>
<h3>Changed</h3>
<ul>
<li><code>pbp_coverage: "point"</code> now means a per-point stream has <strong>delivered</strong> for the match — at least one played point past the <code>seq</code> 1 opener. Until then it reads <code>"game"</code>, including for a listed match whose stream holds only its opener because play has not started. Previously any point row, including the opener written before the first ball, was enough for <code>"point"</code>, so a client told to gate on the field could subscribe to a match that never advanced. The admission gate we recommend is <code>sequence > 1</code> together with <code>stale: false</code>. On the server, a match flagged live whose entire tape is still opener rows is demoted back to upcoming after twenty minutes, so such matches no longer linger in the live list.</li>
</ul></article>
<article id="v1.13.4"><h2>1.13.4 <time datetime="2026-09-14">2026-09-14</time></h2>
<h3>Added</h3>
<ul>
<li>ITF World Tennis Tour <strong>qualifying rounds</strong> (singles) are now listed and live-scored from the ITF's own live scoring: matches appear in <code>GET /matches</code> with <code>round</code>, <code>tournament</code> and <code>scheduled_time</code>, receive live scores while in play and complete on the feed's result. Sundays and Mondays, the qualifying days, were previously almost empty because the schedule feed carries main draws only. <code>GET /fixtures</code> still mirrors the schedule feed and does not list qualifying.</li>
</ul></article>
<article id="v1.13.3"><h2>1.13.3 <time datetime="2026-09-13">2026-09-13</time></h2>
<h3>Changed</h3>
<ul>
<li>WTA / WTA 125 stoppages now arrive in two layers. The console's match state is read within about 20 seconds and yields <code>trainer_called</code>, <code>toilet_break_*</code> and <code>stoppage_*</code> rows (player null). The console's event feed, which the tour publishes with a variable delay — measured on 2026-09-13 from about one minute to an hour behind play — adds <code>medical_timeout_start/end</code> with the player and the exact instants. The 1.13.1 note implied the event feed was live; it is not.</li>
</ul></article>
<article id="v1.13.2"><h2>1.13.2 <time datetime="2026-09-13">2026-09-13</time></h2>
<h3>Added</h3>
<ul>
<li>ATP main-tour stoppages: a public live-score service's match stage is now watched for its medical-timeout and interruption stages. Those stages exist in the vocabulary but have not yet been observed on a tennis match, so ATP rows are documented as mapped, not yet proven; they carry <code>player: null</code> and an <code>at</code> equal to the instant the stage was observed.</li>
</ul>
<h3>Changed</h3>
<ul>
<li>ITF coverage note: the first medical timeout was observed and published on 2026-09-13.</li>
</ul></article>
<article id="v1.13.1"><h2>1.13.1 <time datetime="2026-09-13">2026-09-13</time></h2>
<h3>Added</h3>
<ul>
<li>Scorer-stated stoppages for <strong>WTA and WTA 125</strong> singles: <code>medical_timeout_*</code>, <code>trainer_called*</code>, <code>toilet_break_*</code> and <code>stoppage_*</code> rows now come from the chair umpire's console for those tours (measured over 138 finished matches: 27 physio calls, 65 treatment records, 22 suspensions). Same fields, same endpoints, same WebSocket signal. <code>stoppage_start.reason</code> gains <code>heat</code>, <code>darkness</code> and <code>injury</code>; every <code>stoppage_end</code> carries <code>reason: "resumed"</code>.</li>
</ul>
<ul>
<li>Scorer-stated stoppages for <strong>ITF World Tennis Tour</strong> singles from the court's live-scoring state: <code>toilet_break_*</code>, <code>medical_timeout_*</code>, <code>trainer_called*</code> and <code>stoppage_*</code> rows on entering and leaving the state, with <code>duration_seconds</code>; <code>player</code> is null on these rows because the feed names the state, not the player.</li>
</ul>
<h3>Changed</h3>
<ul>
<li>Coverage note: WTA, WTA 125, Challenger, UTR and ITF singles are covered; ATP main tour is still being sourced and is stated as not covered.</li>
</ul></article>
<article id="v1.13.0"><h2>1.13.0 <time datetime="2026-09-13">2026-09-13</time></h2>
<h3>Added</h3>
<ul>
<li><code>GET /events</code> (PRO): the <strong>slate-wide events feed</strong> — the rows of <code>GET /matches/{id}/events</code> for every match in one call, oldest first, cursor by <code>after_id</code> (<code>meta.next_cursor</code>, null = caught up), <code>since</code> as the first-call UTC lower bound, <code>type</code> as a comma-separated list of event types or the family name <code>stoppages</code>. Rows carry <code>id</code> and <code>match_id</code>. One request per tick covers the whole live slate: medical timeouts across every live match on PRO by polling.</li>
<li><code>SlateEvent</code> schema; <code>Event.type</code> documents the full vocabulary.</li>
</ul>
<h3>Changed</h3>
<ul>
<li>Stoppage coverage, corrected: scorer-stated <code>medical_timeout_*</code>, <code>trainer_called*</code> and <code>toilet_break_*</code> rows exist for <strong>Challenger and UTR singles</strong>. The 1.11.0/1.12.0 text said ATP, WTA and WTA 125 as well; measured over 11 days those tours' scorer feed carries none (0 of 70 finished matches each, against 22 of 70 for Challenger). Main-tour and ITF medical timeouts are being sourced; the note changes the day they are live.</li>
</ul></article>
<article id="v1.12.0"><h2>1.12.0 <time datetime="2026-09-12">2026-09-12</time></h2>
<h3>Added</h3>
<ul>
<li><strong><code>serve</code> and <code>outcome</code> on live point rows</strong> (<code>GET /matches/{matchId}/points</code>, the <code>point</code> frames): the serve the point was played on (1 | 2) and how it ended (<code>ace</code> | <code>double_fault</code> | <code>winner</code> | <code>forced_error</code> | <code>unforced_error</code>), as an outside source states them, joined onto our rows by exact state; <code>null</code> when not stated. Coverage per match in the new <code>enrichment</code> object; per tour in the reference.</li>
<li><strong><code>point_update</code> frame</strong> on the point opt-in — a point's <code>serve</code>/<code>outcome</code> landing after its <code>point</code> frame; apply by <code>seq</code>. Asked by an ULTRA customer trading on serve outcome.</li>
<li><strong><code>published_at</code></strong> on every data frame of the native WebSocket and the push feed: the UTC instant (ms) the frame left our process — the third clock next to the state's <code>timestamp</code> and a point's <code>ts</code>, so processing and transport latency can be separated. Asked by a prospect running a latency benchmark.</li>
</ul></article>
<article id="v1.11.0"><h2>1.11.0 <time datetime="2026-09-12">2026-09-12</time></h2>
<h3>Added</h3>
<ul>
<li><strong>Medical timeouts, trainer calls and toilet breaks as events</strong> on <code>GET /matches/{matchId}/events</code>: <code>medical_timeout_start/end</code>, <code>trainer_called/_end</code>, <code>toilet_break_start/end</code>, each with <code>player</code> (the player concerned), <code>at</code> (UTC), the <code>score</code> at that instant, <code>reason</code> and <code>duration_seconds</code> on the end row — as the match scorer states them (ATP, WTA, Challenger, WTA 125, UTR singles). <code>stoppage_start/end</code> now carry a stated <code>reason</code> (<code>weather</code> | <code>other</code>) when one exists. Asked by a prospect building on medical timeouts.</li>
<li><strong><code>pause_start</code> / <code>pause_end</code></strong> (<code>basis: inferred</code>): interruptions of play measured from our own point clocks, on every live match, never labelled medical.</li>
<li><strong><code>signals:["stoppages"]</code></strong> on the native WebSocket and the stoppage family on the <code>signal:*</code> push channels — every row above pushed the moment it is recorded.</li>
</ul></article>
<article id="v1.10.1"><h2>1.10.1 <time datetime="2026-09-12">2026-09-12</time></h2>
<h3>Clarified</h3>
<ul>
<li>A ranking tie is two rows with the same <code>rank</code> (doubles partners always tie).</li>
</ul></article>
<article id="v1.10.0"><h2>1.10.0 <time datetime="2026-09-12">2026-09-12</time></h2>
<h3>Added</h3>
<ul>
<li><strong><code>win_probability_p1_model</code></strong> and <strong><code>win_probability_meta</code></strong> on ULTRA live score objects: the same model read computed without the market-prior anchor (a probability no market price touched), plus <code>model_version</code>, <code>generated_at</code> and <code>market_anchored</code> for the pair. <code>win_probability_p1</code> is unchanged. Asked by an ULTRA customer who needed to know whether the live probability is independent of market prices — on anchored matches it is not, and now the row says so.</li>
<li><strong><code>basis</code></strong> on status-ledger rows (<code>observed</code> | <code>backfill</code>); the ledger now reaches back to the per-match stamps held before it existed (completions from 2026-08-21, promotions to live from 2026-09-05), one reconstructed row per stamp, labelled.</li>
</ul>
<ul>
<li><strong><code>system=atp_doubles</code> / <code>system=wta_doubles</code></strong> on <code>/rankings</code>: the official weekly doubles tables (individual players), in both modes at the same gates as <code>atp</code>/<code>wta</code>, never included implicitly. Loaded from 2023 forward.</li>
</ul>
<h3>Clarified</h3>
<ul>
<li>Status-ledger rows are ordered by their instant (<code>at</code>), not insertion order.</li>
</ul></article>
<article id="v1.9.9"><h2>1.9.9 <time datetime="2026-09-12">2026-09-12</time></h2>
<h3>Clarified</h3>
<ul>
<li>The status ledger records from <strong>2026-09-11T22:45:48Z</strong> (its first row), not the calendar date of the deploy. Reported by an ULTRA customer reading match 188711.</li>
</ul></article>
<article id="v1.9.8"><h2>1.9.8 <time datetime="2026-09-12">2026-09-12</time></h2>
<h3>Added</h3>
<ul>
<li><strong><code>GET /matches/{matchId}/status-history</code></strong> — the per-match status ledger: every <code>status</code> / <code>event_status</code> transition with the UTC instant we published it, the value before and after, the derived <code>outcome</code> and the score at that instant. Append-only, oldest first; rows exist from 2026-09-12. History capability (BASIC and the Historical Data plans). Asked for by a researcher reconciling corrections against the moment they were published.</li>
<li><strong><code>stoppage_start</code> / <code>stoppage_end</code> events</strong> on <code>GET /matches/{matchId}/events</code>: an in-play suspension with the score at the moment, <code>reason</code> (<code>unknown</code> until a source states one — never inferred) and <code>duration_seconds</code> on the end row. Asked for by a product builder wanting medical timeouts as an event.</li>
</ul></article>
<article id="v1.9.7"><h2>1.9.7 <time datetime="2026-09-10">2026-09-10</time></h2>
<h3>Added</h3>
<ul>
<li><strong><code>Match.outcome</code></strong> is now documented on the Match schema (it has been on every match since 2026-08-18): `completed | retired | walkover | default | abandoned | unresolved | null<code>, derived from </code>status<code> + </code>event_status`.</li>
<li><strong><code>outcome: unresolved</code></strong> — a match every source lost before a result is closed unfinished and says so: <code>score</code> is the last state observed, <code>winner</code> is null, no result is asserted; it flips to <code>completed</code> with the proven final when an authority confirms the result. Until 2026-09-10 such closes were published as <code>completed</code> with a mid-match score, which an ULTRA customer read as a wrong result.</li>
</ul></article>
<article id="v1.9.6"><h2>1.9.6 <time datetime="2026-09-10">2026-09-10</time></h2>
<h3>Added</h3>
<ul>
<li><strong><code>stats.ratings_as_of</code></strong> on <code>GET /players/{id}</code> — the date (UTC) the <code>ratings</code> block was last refreshed. The current Elo is now refreshed every week for every rated player from the published rating tables (Tuesdays); before this the rating carried no date and a May capture read as this week's. Reported by a PRO customer building pre-match research.</li>
<li><strong><code>GET /history/packages/{period}?format=corrections</code></strong> — a CSV keyed by <code>match_id</code> (`match_id, field, before, after, tournament_key, source, corrected_at`) on every package whose stored data was repaired after publication, listed in the manifest as <code>format: corrections</code>; 404 when a package has none. First use: <code>surface</code> corrections on the 2023-02 → 2024-12 tape packages (19,439 Challenger matches whose court surface a tournament-name rule had set to grass; repaired and rebuilt 2026-09-10). Reported by a History Pro customer.</li>
</ul>
<h3>Fixed</h3>
<ul>
<li>Merged player ids: a merge now repoints the weekly ranking tables too, so <code>/rankings</code> rows carry the same <code>player_id</code> the match records carry, and the retired id answers 410 with <code>merged_into</code> (see Merged and retired player ids).</li>
</ul></article>
<article id="v1.9.5"><h2>1.9.5 <time datetime="2026-09-09">2026-09-09</time></h2>
<h3>Added</h3>
<ul>
<li><strong><code>GET /matches/{matchId}/prices?cursor=</code></strong> — keyset paging past 500 ticks: while <code>meta.has_more</code> is true, pass <code>meta.next_cursor</code> back as <code>?cursor=</code> for the next (older) page; pages never overlap or skip a tick; anything else is <code>400 bad_cursor</code>. Mirrors tennis 3123b27f (live 2026-09-09 06:01Z).</li>
<li><strong><code>Match.live_at</code></strong> — the instant our feed last reported the match in play (UTC), the closest thing to an actual start time. Null for matches that went live before 2026-09-05 or were never observed live. Same commit.</li>
<li><strong><code>410 Gone</code> with a <code>MatchMerged</code> body</strong> on the ten per-match routes when a match id was merged into another record: <code>error: merged</code>, <code>merged_into</code> (the end of the chain, or null), <code>merged_at</code>, <code>detail</code>. Live since 2026-09-05 (tennis d131a12f).</li>
<li><strong>Tape point <code>is_unreturned</code> / <code>unreturned_kind</code></strong> (rally tapes, tennis 6e7b82f6, 2026-09-05).</li>
</ul>
<h3>Clarified</h3>
<ul>
<li>Price ticks are kept for <strong>30 days</strong> and then deleted: an older match answers an empty <code>data</code> / <code>prices</code> array while its market stays mapped — retention, not a fault. Stated on both prices endpoints.</li>
</ul></article>
<article id="v1.9.4"><h2>1.9.4 <time datetime="2026-09-09">2026-09-09</time></h2>
<h3>Fixed</h3>
<ul>
<li><strong><code>Price.side</code></strong> (per-match and per-market prices endpoints, and the match embed) now follows <code>players.p1</code> / <code>players.p2</code> through our name-verified match-market mapping. Until 2026-09-09 it followed the venue's display order, which lists roughly half of all pairings the other way round, so on those markets <code>side: 1</code> was in fact <code>players.p2</code>. The mapping is stored, so re-reading any tick - historical included - returns the correct side. Reported by a PRO customer cross-checking pre-match prices against rankings; mirrors tennis cbd3f647 (live 2026-09-09 04:59Z).</li>
</ul>
<h3>Clarified</h3>
<ul>
<li><code>Price.mid</code> is the venue's observed midpoint, never a model estimate; <code>synthetic</code> describes only <code>bid</code>/<code>ask</code> (<code>mid</code> +/- 0.005 when true); pre-match ticks are <code>synthetic: true</code> by design because the live book attaches at match start; <code>timestamp</code> is our observation clock (UTC), not a venue publication time.</li>
</ul>
<p>## [1.9.3] — 2026-09-04</p>
<h3>Clarified</h3>
<ul>
<li><strong><code>Match.tournament_id</code> null cases</strong> restated with the real causes and a measured rate: not in the catalogue (UTR), or a secondary-source match whose name matched no single catalogue edition. The API now resolves the latter at ingest by name + event type + date against the vendor's own fixtures, and the 2026 backlog was backfilled the same day (2,547 rows); ITF null rate went from about 25% to about 2.5%. No wire change.</li>
</ul>
<p>## [1.9.2] — 2026-09-04</p>
<h3>Added (spec catch-up — every one of these has been live on the API; the document lagged)</h3>
<ul>
<li><strong><code>GET /matches?status=cancelled</code></strong> is now in the <code>status</code> enum, with what it covers (feed-cancelled, walkover with no stated winner, postponed and never played), its tier (BASIC or any History plan, like <code>completed</code>), and the one restriction: it pages with <code>limit</code>/<code>offset</code> and refuses <code>updated_since</code>.</li>
<li><strong><code>tournament_id=</code></strong> filter on <code>/matches</code> (every status) and <code>/history/matches</code> — the stable id each match row carries and <code>/tournaments</code> publishes. The description states there is no separate edition/occurrence id: one edition is <code>tournament_id</code> plus a <code>from</code>/<code>to</code> window, and matches with a null <code>tournament_id</code> (tournament not yet catalogued) fall outside the filter.</li>
<li><strong>Change feed on <code>/matches</code></strong>: <code>updated_since=</code> / <code>cursor=</code> parameters and the <code>meta.next_cursor</code> / <code>meta.watermark</code> fields, with the at-least-once and no-<code>from</code>/<code>to</code> rules.</li>
</ul>
<h3>Clarified</h3>
<ul>
<li><code>meta.has_more</code> says how to enumerate a filtered set completely (page <code>offset</code> by <code>limit</code> until false) and that <code>total</code> is null on the terminal listings, so <code>has_more</code> is the only end-of-data signal there. Asked by a Basic customer on Discord; no wire change.</li>
</ul>
<p>## [1.9.1] — 2026-09-02</p>
<h3>Clarified</h3>
<ul>
<li><strong><code>Player.ranking</code> / <code>Player.ranking_points</code></strong> now say what they are: the official singles ranking POSITION (the ordinal), ATP table for men and WTA table for women chosen by the player, refreshed ahead of each match the player has with us, <code>null</code> when no ranking is held, and always the CURRENT record even on historical matches (<code>/rankings?as_of=</code> is the as-of surface). Asked by a Basic customer; no wire change.</li>
</ul>
<p>## [1.9.0] — 2026-09-02</p>
<h3>Added</h3>
<ul>
<li><strong><code>has_analysis</code> and <code>has_market</code> on <code>Match</code></strong> — two booleans on every row of <code>GET /matches</code> and on the detail, every tier. They carry the same fact the per-match endpoints answer 404 about, so a slate is filtered in one call instead of one 404 per match. Shipped to production 2026-09-02.</li>
<li>**Distinguishable absence on <code>GET /matches/{matchId}/analysis</code> and <code>GET /markets/{matchId}/prices</code>.** The status stays <code>404</code> (unchanged, and shipped clients branch on it), but the body now says which absence it is: <code>{"error":"not_found"}</code> for an id that does not exist, and <code>{"error":"no_analysis"|"no_market","match_id":…,"coverage":"none","detail":…}</code> for a real match we hold nothing for — the same <code>coverage: "none"</code> vocabulary <code>/matches/{matchId}/statistics</code> already uses in its <code>200</code>. New <code>error</code> codes <code>no_analysis</code>, <code>no_market</code> documented on the <code>Error</code> schema.</li>
</ul>
<p>## [1.8.0] — 2026-09-01</p>
<h3>Added</h3>
<ul>
<li><strong><code>GET /history/archive/matches/{archiveId}/tape</code></strong> (<code>getArchiveTape</code>) — the RECONSTRUCTED 2013–2022 point-by-point tape for one archive result: the score sequence behind the published result, rebuilt from the public record after the fact. Shipped to production 2026-09-01; the spec was the last place it was missing. Tier: core ULTRA, **or any active History plan including Starter** (which opens it on a FREE core key). The archive RESULT stays on BASIC, so core BASIC and core PRO read the result and are refused the tape — <code>403 upgrade_required</code> carrying <code>capability: archive_tape</code>.</li>
<li><strong><code>ArchiveTape</code> schema.</strong> Same envelope as <code>HistoryTape</code> so one parser reads both halves of the tape product, with the differences that are true: <code>match</code> is the winner/loser-shaped archive row (rows are WINNER-FIRST, not p1/p2), <code>profiles</code> is always <code>[]</code>, and <code>meta</code> carries <code>archive_match_id</code> rather than <code>match_id</code> — an archive id is not a match id, and passing one to the other's routes resolves a different, real record without erroring. Rows are <code>HistoryTapeRow</code>, reused unchanged.</li>
<li>**<code>kind=archive_tape</code> on <code>/history/packages</code> and <code>/history/packages/{period}</code>, and on <code>HistoryPackage.kind</code>.** Ten per-year bulk files, <code>period</code> 2013 through 2022, JSONL + CSV, all <code>ready</code>. The JSONL record is byte-for-byte what the per-match endpoint returns; the CSV is the flat per-row view keyed on <code>archive_match_id</code> and deliberately carries no <code>timestamp</code>, <code>win_probability_p1</code> or <code>danger</code> column. A SEPARATE gate from the per-match tape: ULTRA, a History Pro/Business subscription, or an active one-off package window — a Starter grant reads tapes one at a time and does not download years of them. <strong>Core PRO carries neither gate.</strong></li>
</ul>
<h3>Changed</h3>
<ul>
<li>**<code>info.description</code> states the provenance and the coverage, thin spots included.** Nobody watched these matches: <code>timestamp</code>, <code>win_probability_p1</code> and <code>danger</code> are null on EVERY row and cannot be filled in later — the production table has no timestamp column at all, so this is a structural fact and not a convention. That is the opposite of the 2023→now tape, which is our own recording, where the rows we actually watched carry a real clock and most of them a model probability. The corpus is 97,901 matches / 14,340,663 rows, seasons <strong>2013–2022 only</strong>: 977,903 archive results from 1968–2012 have no tape and never will. Coverage of the era is 19.3% overall and 44.9% of tour-level play — main-draw tour buckets 91.6–98.7%, ATP Challenger main 55.3% and Challenger qualifying 33.6%, slam QUALIFYING 16.0% (ATP) / 18.1% (WTA), ITF and futures effectively zero (25 of 116,575 ATP futures). Stated together, because a strong number published without its thin counterpart sells a corpus nobody has.</li>
<li>**<code>ArchiveTape.meta.coverage</code> names the measured split rather than glossing the label.** <code>reconstructed_partial</code> (3,594 matches) has TWO causes and does not say which: 3,038 are point-granular tapes of matches that genuinely stopped early (3,027 retirements, 11 defaults) — the larger cause — and the other 556 carry the label only because their tape is per-GAME. Read <code>granularity</code> and the match's own score, not the label, when the question is whether the whole match is there. <code>granularity</code> is <code>point</code> on 99.4% of the corpus; the 556 <code>game</code> tapes are 555 in 2013 and one in 2014.</li>
<li><strong><code>Coverage</code> says where its vocabulary stops.</strong> It describes the 2023+ tape; the archive tape reuses two of its five values and derives <code>reconstructed_partial</code> differently.</li>
<li><strong><code>ArchiveMatch</code> points at the tape</strong>, and <code>GET /history/archive/matches/{archiveId}</code> says the RESULT stays on BASIC either way.</li>
<li>The plain-HTML reference gains an FAQ entry — "Is there point-by-point data before 2023?" — and the history FAQ, <code>llms.txt</code> digest and README plan summary carry the same numbers, so an answer engine reading any one of them gets the corpus, the null clock and the coverage floor together.</li>
<li><code>info.version</code> is now <code>1.8.0</code>.</li>
</ul>
<p>## [1.7.2] — 2026-08-23</p>
<h3>Changed</h3>
<ul>
<li><strong><code>status</code> on Match now documents the lifecycle rule.</strong> <code>completed</code> is asserted only for a match we observed being played or whose match-winner market settled decisively; a closed market alone never finishes a match. <code>cancelled</code> with <code>event_status: null</code> means positive evidence the match was not played as scheduled (the market settled void) and no vendor word for why — <code>outcome</code> / fixture <code>reason</code> stay null rather than guessed, and the row upgrades to a completed walkover if a <code>Walk Over</code> with a stated winner lands later. Before 2026-08-23 a void market closure completed the match and stamped <code>Finished</code>; those rows were corrected. No field added or changed type — prose only.</li>
<li><code>info.version</code> is now <code>1.7.2</code>.</li>
</ul>
<p>## [1.7.1] — 2026-08-19</p>
<h3>Added</h3>
<ul>
<li><strong><code>event_status_updated_at</code> on Match.</strong> Nullable ISO-8601 UTC (<code>Z</code>) timestamp, right after <code>event_status</code>: when <code>event_status</code> last CHANGED — the instant WE recorded the walkover / retirement / cancellation / postponement / suspension (or its clearing), not when the tournament desk or the feed did. It 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. Inherited by every schema built on Match (<code>MatchDetail</code>, <code>HistoryMatch</code>). Additive only — no existing field moved or changed type.</li>
</ul>
<h3>Changed</h3>
<ul>
<li><code>info.version</code> is now <code>1.7.1</code>.</li>
</ul>
<p>## [1.7.0] — 2026-08-18</p>
<h3>Added</h3>
<ul>
<li><strong><code>basis</code> on <code>GET /matches/{matchId}/points</code> responses.</strong> <code>live</code> | <code>reconstruction</code> — which base served the page. Live capture is inherently partial: the stream serves what arrived while the match ran, and the match-closing point never streams live. For a COMPLETED match where a measured-complete recorded point sequence exists, the endpoint now serves that complete sequence instead, projected into the same point-frame shape — love-love opener through the match-closing point, <code>seq</code> contiguous <code>1..N</code>, <code>quality</code> <code>clean</code> — and says so with <code>basis: "reconstruction"</code>. Every other case (every live match, and any completed match without a measured-complete recorded sequence) stays <code>basis: "live"</code> — the persisted stream rows, byte-for-byte what was served before. Completeness beats the partial live capture WHOLESALE — the two sequences are never interleaved (they share no key, so any merge would fabricate an order), the same rule <code>?points=complete</code> follows on the history read. On projected frames <code>ts</code> is null on every row: the recorded sequence carries no per-point clock and none is fabricated (<code>LivePoint.ts</code> now documents this). <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. Additive only — no existing field moved or changed type.</li>
</ul>
<h3>Changed</h3>
<ul>
<li><code>info.version</code> is now <code>1.7.0</code>.</li>
</ul>
<p>## [1.6.0] — 2026-08-18</p>
<h3>Added</h3>
<ul>
<li><strong>The three-valued <code>draw</code> field on Match.</strong> <code>singles</code> | <code>doubles</code> | null — same vocabulary as the new <code>?draw=</code> filter, decided by one shared definition, so filter and field cannot disagree. Evidence order: a doubles-team participant proves doubles over any event type; otherwise the feed's event type decides. Null means neither says anything — no stated event type, or 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. <code>is_doubles</code> stays for compatibility and is now documented as LOSSY, with its evidence order: false also covers "unknown", which is not a claim of singles — prefer <code>draw</code>, whose null says so honestly.</li>
<li><strong><code>?draw=singles|doubles</code> on four listings</strong> — <code>GET /matches</code>, <code>/history/matches</code>, <code>/tournaments</code> and <code>/fixtures</code>: the axis the <code>tour</code> filter deliberately collapses; the two compose (<code>?tour=itf&draw=doubles</code> is the ITF doubles slice). A row whose draw is null matches NEITHER value — null is an answer, not a wildcard. An unknown value is a 400 <code>bad_draw</code> with the allowed values. Two honesty notes carried into the spec: on <code>/tournaments</code> the answer comes from the event type alone (a tournament row has no participants to supply the doubles-team evidence matches have), and <code>?draw=doubles</code> alone also returns mixed and exhibition doubles that no <code>?tour=</code> value reaches.</li>
<li><strong><code>GET /history/coverage</code></strong> (BASIC, or any Historical Data API plan) — the measured completeness rollup per tour × draw bucket: 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; <code>503 coverage_unavailable</code> before the first nightly build. 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); a bucket with zero completed matches is OMITTED, never emitted as zeros. Each bucket carries the five verifiable numbers (<code>completed</code>, <code>any_tape</code>, <code>point_complete</code>, <code>complete_on_default_read</code>, <code>share</code>), and <code>method</code> states the full measurement rule so every number carries its own definition. As of 2026-08-18: 174,393 completed matches; 91,318 point-complete on the best basis (52.4%) against 81,196 on the default read alone (46.6%); ITF singles 51.1% against ITF doubles 3.5% — do not extrapolate a completeness rate across a tour group.</li>
<li>**<code>tape.starts_at_love</code> and <code>tape.computed_at</code> on <code>/history/matches</code> items** (both nullable, present where enabled). <code>starts_at_love</code> follows the SAME best-basis rule as <code>points_complete</code> — true when either measured basis opens at the 0-0 state, so if any on-disk sequence opens at love you can obtain one that does. <code>computed_at</code> is when the ledger last measured the match: every field in the tape's measured block is a nightly-reconverged cache, and this is the as-of to quote with any of them. Null on either means the match has not been measured — never a guess.</li>
<li><strong>The complete-basis addendum package files.</strong> An affected month's manifest may also list <code>tennis_history_points_complete_<period>.jsonl.gz</code> / <code>.csv.gz</code> (marked <code>compression: gzip</code>; <code>bytes</code>/<code>sha256</code> cover the compressed bytes — exactly the download): for exactly the matches whose complete point sequence exists only as the on-disk reconstruction, the same tape <code>?points=complete</code> serves (reconstruction contract: null timestamps and null model fields). The base files keep carrying every match's DEFAULT read — already the complete tape for most point-complete matches — and are never rewritten by the addendum; their <code>bytes</code> and <code>sha256</code> values do not move.</li>
</ul>
<h3>Changed</h3>
<ul>
<li><code>info.version</code> is now <code>1.6.0</code>.</li>
</ul>
<p>## [1.5.0] — 2026-08-17</p>
<h3>Added</h3>
<ul>
<li><strong>The as-of Elo tape.</strong> <code>GET /rankings?system=elo</code> (ULTRA, both modes) — our own computed Elo as point-in-time records: per-player as-of (also via <code>archive_player</code> for the ~62,000 rated people outside the roster, <code>tour</code> required there) and a leaderboard (<code>tour</code> required — the ATP and WTA walks are disjoint; exactly one <code>surface</code>, default <code>overall</code>; <code>min_matches</code> default 20 and <code>activity_weeks</code> default 52 / max 104, both echoed in <code>meta.coverage.qualified</code>). Four independent ladders (overall/hard/clay/grass), ATP from 1877, WTA from 1968, main tours plus challengers plus the futures tier. <code>rating</code> always; <code>rank</code> leaderboard-only; <code>points</code> always null; <code>matches</code> is the ladder-scoped count. <code>meta.coverage</code> gains <code>newest_available</code>, <code>players_rated</code> / <code>players_linked</code>, <code>qualified</code> and the <code>model</code> block (<code>publication_lag_days: 14</code> — a week's results become effective 14 days after the week begins, so the failure direction is staleness, never look-ahead). The corpus head is frozen at 2026-05-25 and does not advance; <code>elo</code> is never included implicitly. The free current Elo on <code>GET /players/{id}</code> is a DIFFERENT scale (~150 Elo of per-player standard deviation apart) — never present a rating from one scale against a rating from the other. <code>kind=elo</code> yearly bulk packages join <code>/history/packages</code>.</li>
<li><strong><code>covers_from_start</code> on <code>GET /matches/{matchId}/points</code></strong> (bool|null): whether the persisted stream opens at the match's 0-0 opener (seq 1 is the love-love state) — strictly affirmative; null only when the match has no rows at all.</li>
<li><strong>The point channel family enters the <code>/ws-token</code> vocabulary.</strong> <code>point:match:{match_id}</code> / <code>point:slate</code> are documented as <code>point_match</code> / <code>point_slate</code> in the channel vocabulary, listed only when the point feed is enabled server-side and the key's plan carries the point surface. A channel named in that response is a promise; a missing one will not deliver.</li>
</ul>
<h3>Changed</h3>
<ul>
<li><strong>Measured statistics stop rounding the truth.</strong> <code>MatchStatisticsMeasured</code> now states: match totals only (no per-set measured statistics); absent = not measured while a present 0 is a real measured zero; the three coverage tiers with their hard zeros — the winners/unforced/forced-errors family historically on ~43% of ATP singles, ~24% of WTA singles and ~47% of tour doubles, and NONE of Challenger, ITF or juniors (24,552 payloads, 2026-07-31) — and that the upstream feed has not delivered that family since 2026-07-12 (0 of 4,513 August payloads carry it, measured 2026-08-17).</li>
<li><strong><code>errors_total</code> is documented as the total of FORCED errors</strong>, with the evidence (3,766 payload sides, June–July 2026: equals the per-stroke error sum in 96.2%; smaller than <code>unforced_errors_total</code> in 11.7% of sides — impossible for a superset; per-match points accounting closes only under the forced reading, median residual 0 over 367 matches). Total errors = <code>errors_total</code> + <code>unforced_errors_total</code>; the <code>*_errors</code> shot family is the forced-error breakdown, and <code>groundstroke_errors</code> = <code>forehand_errors</code> + <code>backhand_errors</code>, a rollup rather than an addition.</li>
<li><strong>UTR honesty.</strong> <code>system=utr</code> is documented as observed from UTR's public search: withheld ratings are ABSENT, never 0; per-player as-of only — no listing by design (a table of only the players we happen to track would be a fake leaderboard); history since 2026-07-29; and the sweep's deliberate rankless / no-Elo (ITF-skewed) bias with its measured coverage (2026-08-17, players active in the last 60 days): ITF 931 of 5,606 (16.6%), Challenger 197 of 1,903 (10.4%), WTA 43 of 573 (7.5%), ATP 15 of 525 (2.9%).</li>
<li>*(recorded retroactively)* On 2026-08-16 the <code>/ws-token</code> description was expanded in place to teach the push-feed protocol (Centrifugo v2 connect/subscribe/heartbeat, token-per-reconnect, the SDK <code>PushStream</code> pointer) without a version bump; this entry is the changelog record of that change.</li>
<li><code>info.version</code> is now <code>1.5.0</code>.</li>
</ul>
<p>## [1.4.0] — 2026-08-16</p>
<h3>Added</h3>
<ul>
<li><strong>Live per-point events.</strong> <code>GET /matches/{matchId}/points</code> (ULTRA) — the live per-point event stream of one match in <code>seq</code> order, paged with <code>?after_seq=</code> (the resume cursor; up to 500 rows per page, continue on <code>last_seq</code> while <code>has_more</code>). It is the REST catch-up for the WebSocket <code>point</code> frames, which are best-effort with no replay. Per-match <code>pbp_coverage</code> states honestly whether the match has a true per-point stream (<code>point</code>) or only the snapshot score path (<code>game</code> — <code>points</code> empty, an answer rather than an error); per-point coverage is never promised slate-wide. New schemas <code>MatchPoints</code>, <code>LivePoint</code> and <code>PointFrame</code>; new error codes <code>bad_after_seq</code> and <code>points_disabled</code> (the surface switched off server-side).</li>
<li><strong>WebSocket <code>points</code> signal.</strong> The native <code>/ws</code> feed's <code>signals</code> array may now name <code>points</code> to opt into one <code>point</code> frame (schema <code>PointFrame</code>) per persisted point of the subscribed matches. Config-gated, off by default — the <code>subscribed</code> ack echoes the signals actually active. The push feed carries the same frames on their own channel family (<code>point:match:{match_id}</code>, <code>point:slate</code>), deliberately separate from the score channels; webhooks gain the matching <code>point</code> event (one POST per live point, <code>X-LTAPI-Event: point</code>). Point frames are events, not states — a missed one does not self-correct; recovery is the REST catch-up read.</li>
<li><strong>Point-complete tape reads.</strong> <code>?points=default|complete</code> on <code>GET /history/matches/{matchId}</code>: <code>complete</code> opts out of observed-rows-first precedence and serves a whole-match reconstruction WHOLE, in point order, where one exists (<code>point_winner</code> on every row, null timestamps/model fields per the reconstruction contract); where none exists the response is the default read plus <code>meta.points</code> — no error. Cannot combine with <code>sequence=clean</code> (400 <code>bad_combination</code>); 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. The tape's <code>meta.points</code> block (schema <code>PointsMeta</code>) reports the measured point-completeness of exactly the sequence returned — computed at read time, per match, never a stored blanket claim.</li>
<li><strong>Point-completeness on the listing.</strong> <code>HistoryMatch.tape</code> gains <code>points_complete</code> (bool|null — the nightly ledger's best-basis verdict; null means not yet measured, never a guess) and <code>completeness</code> (0..1|null), and <code>GET /history/matches</code> gains the <code>?points_complete=true|false</code> filter (applied after the page is cut, exactly like <code>?coverage=</code>; anything but true/false is a 400 <code>bad_points_complete</code>).</li>
</ul>
<h3>Changed</h3>
<ul>
<li><strong>Model win-probability copy tells the truth about density.</strong> The plan and FAQ copy no longer claims the tape carries the model win-probability "at every point" / "per point": the model stamp is best-effort and rides the rows where the model ran — <code>meta.model_rows</code> is the count, and null is the honest value elsewhere. The current-score snapshot is described as overwritten on every score commit, not "on every point".</li>
<li><code>HistoryTapeRow.point_winner</code> is documented on <code>?points=complete</code> rows as well as <code>?sequence=clean</code> (there the served order IS point order).</li>
<li><code>info.version</code> is now <code>1.4.0</code>.</li>
</ul>
<p>## [1.3.1] — 2026-08-07</p>
<h3>Added</h3>
<ul>
<li><strong><code>kind=rally</code> bulk packages documented.</strong> The <code>/history/packages</code> <code>kind</code> enum gains <code>rally</code> — the charted rally corpus (shot-by-shot) as YEARLY exports, ULTRA, <code>period</code> = <code>YYYY</code> like <code>archive</code>. The kind has been live in the API (it shipped alongside the per-match rally endpoints); the spec simply did not list it. Additive only: the enum, the <code>HistoryPackage.kind</code> field, and the package-shape prose now match the served surface — <code>kind</code> accepts <code>tape</code> (default), <code>rankings</code> (ULTRA), <code>rally</code> (ULTRA) and <code>archive</code>.</li>
</ul>
<p>## [1.3.0] — 2026-08-07</p>
<h3>Added</h3>
<ul>
<li><strong>Shot-level charting.</strong> <code>GET /charting/players</code> (ULTRA) — career serve/return profile 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, rally-length and shot-direction tendencies, summed over the player's charted matches (<code>name</code> keyed, <code>gender=men|women</code> disambiguates, ambiguous fragments refused with candidates). <code>GET /charting/matches/{chartingMatchId}</code> (ULTRA) — every stat family for one charted match, both players, per-set split. Coverage is curated: 11,646 charted matches back to the 1960s, concentrated on the majors, not full-slate.</li>
<li><strong>Push-feed token.</strong> <code>GET /ws-token</code> (ULTRA): mints a short-lived signed token plus the push WebSocket URL and channel vocabulary — <code>match:{match_id}</code> per-match streams and <code>slate:all</code> for every live score frame. A separate high-fan-out surface from the native <code>/ws</code> feed.</li>
<li><strong>H2H stat splits.</strong> On ULTRA, <code>GET /h2h</code> adds a per-player <code>stats</code> block: 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>.</li>
<li><strong>Abuse throttle documented.</strong> The 429 family now documents all three body shapes: the per-minute limit (<code>rate_limited</code> with <code>upgrade_url</code>/<code>tier</code>/ <code>price</code>), the per-day quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code> and <code>resets_at</code> — an absolute ISO instant), and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — a 24-hour block for clients hammering far past their cap, which a well-behaved retry loop never sees.</li>
</ul>
<h3>Changed</h3>
<ul>
<li><strong>2026-08-06 quota grid re-set (recorded here retroactively).</strong> On 2026-08-06 the daily quotas were cut, with no grandfathering: FREE 100/day (was 1,000), BASIC 1,000/day (was 10,000), PRO 10,000/day (was 100,000); ULTRA unchanged at 500,000/day; per-minute limits unchanged (30/60/300/600). The shipped 1.2.0 spec text was edited in place on that date without a version bump — this entry is the changelog record of that change. Older entries below quote the pre-cut grid as it stood then.</li>
<li><strong>Tours.</strong> Coverage phrasing is now the five tours everywhere — ATP, WTA, Challenger, ITF and juniors — matching the <code>tour</code> filter enum (<code>atp, wta, challenger, itf, juniors</code>).</li>
<li><strong>WebSocket copy.</strong> The subscribe frame is documented as <code>{"topics":["live-scores"]}</code> (+ optional <code>signals</code>) — the previously shown <code>action</code> key is not read by the server. Score frames are documented as carrying the ULTRA model fields (<code>win_probability_p1</code>, <code>danger</code>) live — a null means the model had no output for that point. The 2-connections-per- key limit is stated.</li>
<li><code>info.version</code> is now <code>1.3.0</code>.</li>
</ul>
<p>## [1.2.0] — 2026-08-03</p>
<h3>Added</h3>
<ul>
<li><strong>Results archive (1968–2022).</strong> The history product now runs in two continuous, non-overlapping halves: the point-by-point tape (2023→now) and the results archive (1968–2022). Four new endpoints, all BASIC (or any Historical Data API plan): <code>GET /history/archive/matches</code> (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; filters <code>tour</code>, <code>name</code>, <code>from</code>/<code>to</code>, <code>round</code>, <code>level</code>; its own id space; <code>event_date</code> is the TOURNAMENT START date; ends 2022-12-31, exactly where the tape begins), <code>GET /history/archive/matches/{archiveId}</code> (one result, with per-match serve statistics where the era recorded them — null before 1991 mostly, never synthesised), <code>GET /history/archive/players</code> (bios + career-high rank and the week it was first reached), and <code>GET /history/archive/career</code> (career aggregates — sums and ratios of sums only; <code>serve.matches_with_stats</code> states the serve-stat coverage). New schemas <code>ArchiveMatch</code>, <code>ArchivePlayer</code>, <code>ArchivePlayerBio</code>, <code>ArchiveCareer</code>.</li>
<li><strong>Head-to-head.</strong> <code>GET /h2h?p1=&p2=</code> (BASIC, or any History plan): the record between two players across both halves of the product. Name-keyed; an ambiguous fragment is refused with the candidate list (<code>400 ambiguous_name</code>); totals count meetings with a known winner and <code>undecided</code> counts the rest; every meeting carries <code>outcome</code> so walkovers and retirements can be excluded. New schema <code>HeadToHead</code>.</li>
<li><strong>Rally construction.</strong> <code>GET /rally/matches</code>, <code>GET /rally/matches/{rallyMatchId}</code> and <code>GET /history/matches/{matchId}/rally</code> (all ULTRA): shot-by-shot charted data — serve direction, every stroke with wing/direction/depth, rally length, how the point ended. Its own id space (<code>rally_match_id</code>); <code>404 not_charted</code> distinguishes "we hold the match but nobody charted it" from "no such match". New schemas <code>RallyMatch</code>, <code>RallyPoint</code>, <code>RallyShot</code>.</li>
<li><strong>Tournament catalogue.</strong> <code>GET /tournaments</code> and <code>GET /tournaments/{tournamentId}</code> (FREE): the stable id space <code>Match.tournament_id</code> joins, with <code>city</code>/<code>country</code> from a curated table and <code>category</code> only where the catalogues agree unambiguously — never derived from the name. New schema <code>Tournament</code>.</li>
<li><strong>Rankings listing mode.</strong> <code>GET /rankings</code> without <code>player</code> (PRO) returns the FULL published table in rank order for exactly one <code>system</code> — rows carry <code>player_name</code> as published and a null <code>player_id</code> outside our roster, so a top-N has no silent holes; <code>meta.coverage.effective_date</code> names the week served; <code>utr</code> has no listing. Per-player as-of records stay ULTRA. <code>RankingRecord</code> gains <code>player_name</code>, <code>previous_rank</code> (ATP/WTA) and <code>rank_movement</code> (ITF).</li>
<li><strong>List filters.</strong> <code>/matches</code> gains <code>player</code> (repeatable, max 50, either-participant), <code>country</code> (IOC-style lowercase 3-letter codes — NOT ISO-3166), <code>from</code>/<code>to</code> (UTC day boundaries, every status) and keeps <code>tour</code>; <code>/history/matches</code> gains <code>tour</code>, <code>player</code> and <code>country</code> alongside its existing <code>from</code>/<code>to</code>/<code>coverage</code>.</li>
<li><strong>Match fields.</strong> <code>Match</code> gains <code>tour</code> (the same vocabulary as the filter, null never guessed), <code>tournament_id</code>, <code>round_code</code> (controlled vocabulary <code>F</code>…<code>ER</code>, null when unrecognised), a documented <code>event_status</code> enum (<code>Retired</code> | <code>Cancelled</code> | <code>Walk Over</code> | <code>Postponed</code> | <code>Interrupted</code>, with the null-ambiguity caveat) and <code>withdrew</code> (1|2 on <code>Retired</code>/<code>Walk Over</code>, withdrawer = loser by rule); <code>winner</code> is now served for the full archive age.</li>
<li><strong>Fixture fields.</strong> <code>Fixture</code> gains <code>start_time</code> (null until the order of play assigns a time), <code>player1_id</code>/<code>player2_id</code> (exact-key roster resolution, never a name match) and <code>round_code</code>.</li>
<li><strong>Tape additions.</strong> Clean-sequence tape rows gain <code>point_winner</code> (derived from single-point transitions, never guessed; absent on raw); the tape detail gains a top-level <code>tiebreaks</code> array (observed terminal tiebreak scores per 7-6 set) and <code>meta.model_rows</code>; the history listing's <code>tape</code> object gains <code>model_rows</code>.</li>
<li><strong>Archive bulk packages.</strong> <code>/history/packages</code> and <code>/history/packages/{period}</code> gain <code>kind=archive</code> — the results archive (1968–2022) as YEARLY exports (<code>period</code> = <code>YYYY</code>), gzipped, alongside the monthly tape packages; the file manifest documents <code>compression</code>.</li>
<li><strong>Statistics <code>final</code>.</strong> The in-play statistics coverage vocabulary gains <code>final</code> — the closing figures of a completed match; a finished match cannot be "stale", so <code>age_seconds</code> is null there.</li>
</ul>
<h3>Changed</h3>
<ul>
<li><code>info.version</code> is now <code>1.2.0</code>; <code>info.description</code> states the two history halves and the updated tier deltas (tournaments on FREE, the archive family and <code>/h2h</code> on BASIC/History plans, the rankings listing on PRO, rally and per-player as-of rankings on ULTRA).</li>
</ul>
<p>## [1.1.0] — 2026-08-02</p>
<h3>Added</h3>
<ul>
<li><strong>Usage endpoint.</strong> <code>GET /usage</code> (FREE — any tier): your own durable daily usage vs quota — tier, limits, today's calls and a 30-day history. Calls to it are quota-exempt. New schema <code>Usage</code>.</li>
<li><strong>As-of rankings.</strong> <code>GET /rankings</code> (ULTRA): ranking records as they stood on a date — <code>player</code> (required, repeatable, max 50), <code>as_of</code>, and <code>system</code> (<code>atp</code>, <code>wta</code>, <code>itf_jt</code>, <code>itf_mt</code>, <code>itf_wt</code>, <code>utr</code>). Systems are never collapsed into a single "rank"; UTR carries a rating with null rank/points. <code>meta.coverage.oldest_available</code> gives the earliest date each system can answer for. New schemas <code>RankingRecord</code>, <code>RankingListMeta</code>.</li>
<li><strong>In-play match statistics.</strong> <code>GET /matches/{matchId}/statistics</code> (ULTRA): two families, deliberately never merged — DERIVED (rebuilt from the point-by-point record: hold/break %, break points, service & return points) and MEASURED (counted upstream: aces, double faults, the serve split, winners/unforced errors). Each family carries its own <code>coverage</code>, <code>as_of</code> and <code>age_seconds</code>; absent measured fields are omitted, never zero-filled; <code>coverage: none</code> on both families is a 200 with null <code>players</code>, not a 404; a divergence guard withholds measured values when the families disagree. New schemas <code>MatchStatistics</code>, <code>MatchStatisticsSide</code>, <code>MatchStatisticsMeasured</code>, <code>MatchStatisticsFreshness</code>, <code>MatchStatisticsFamily</code>.</li>
<li><strong>Webhooks.</strong> <code>POST /webhooks</code>, <code>GET /webhooks</code>, <code>DELETE /webhooks/{webhookId}</code> (ULTRA, direct keys only): we POST the same frames the WebSocket sends to your HTTPS endpoint. Deliveries carry <code>X-LTAPI-Signature</code> (<code>sha256=<hex></code> HMAC-SHA256 over the raw body), <code>X-LTAPI-Timestamp</code> and <code>X-LTAPI-Event</code>; up to 3 webhooks per key (<code>409 webhook_limit</code>); auto-disable after 25 consecutive failures. The signing secret is returned exactly once, on the 201. New schema <code>Webhook</code>.</li>
<li><strong>Bare price ticks.</strong> <code>GET /matches/{matchId}/prices</code> (PRO): recent ticks of the mapped match-winner market without the market wrapper — <code>limit</code> caps at 500, <code>minutes</code> bounds the lookback, 404 when the match has no mapped market.</li>
<li><strong>Tape coverage and sequence.</strong> <code>/history/matches</code> gains <code>?coverage=</code> (items are now <code>HistoryMatch</code> — <code>Match</code> plus a <code>tape</code> coverage object); <code>/history/matches/{matchId}</code> gains <code>?sequence=raw|clean</code>, documents that it works on a LIVE match, and its <code>meta</code> now carries <code>coverage</code>, <code>point_source</code>, <code>raw_rows</code>, <code>unique_states</code> and <code>sequence</code>. Tape rows are now the explicit <code>HistoryTapeRow</code> (null <code>timestamp</code> marks a reconstructed row). New schemas <code>Coverage</code>, <code>HistoryMatch</code>, <code>HistoryTapeRow</code>.</li>
<li><strong>Rankings packages.</strong> <code>/history/packages</code> and <code>/history/packages/{period}</code> gain <code>?kind=tape|rankings</code> (default <code>tape</code>) and the listing gains <code>?year=YYYY</code> for the year-archive view; <code>HistoryPackage</code> documents the <code>kind</code> field and that JSONL is one line per match while CSV is one row per point.</li>
<li><strong>List meta.</strong> <code>ListMeta</code> now declares <code>total</code> (nullable — null when the set cannot be counted cheaply) and <code>has_more</code> (page on this, not on <code>count</code>).</li>
<li><strong>Error hints.</strong> <code>Error</code> now declares <code>detail</code> (human-readable explanation) and <code>allowed</code> (accepted values on a rejected enumerated parameter).</li>
<li><strong>Price provenance.</strong> <code>Price</code> gains <code>price_source</code> and <code>synthetic</code>, so a quote synthesised from mid is never mistaken for a live order book.</li>
<li><strong>Profile provenance.</strong> The <code>Analysis</code> profile (and <code>ModelProfile</code>) gains <code>stage</code> (<code>pregame</code> | <code>live</code> | null = unknown), <code>model_version</code>, and — on <code>Analysis</code> — <code>input_state</code>, the score an in-play forecast actually saw.</li>
<li><strong>CORS documented.</strong> <code>info.description</code> now states that the REST surface sends <code>Access-Control-Allow-Origin: *</code> (GET/OPTIONS, no credentials mode) and that a FREE key in browser code is acceptable while a paid key belongs server-side.</li>
<li><strong>WebSocket subscribe frame.</strong> <code>info.description</code> now shows the full subscribe frame keys — <code>action</code>, <code>topics</code> and the optional <code>signals</code> list.</li>
</ul>
<h3>Changed</h3>
<ul>
<li><code>info.version</code> is now <code>1.1.0</code>.</li>
<li><code>GET /matches/{matchId}/score</code> documents that it is a point-in-time snapshot, pointing at <code>/history/matches/{matchId}?sequence=clean</code> for the sequence of states and <code>/matches/{matchId}/statistics</code> for in-play statistics.</li>
</ul>
<p>The entries below were previously listed as Unreleased and ship in this release.</p>
<h3>Added</h3>
<ul>
<li><strong>History endpoints documented.</strong> <code>/history/matches/{matchId}</code> (the full point-by-point tape with the per-point model win-probability — BASIC, or Historical Data API Starter+), <code>/history/packages</code> and <code>/history/packages/{period}</code> (pre-built monthly bulk downloads, manifest + <code>?format=jsonl|csv</code> — PRO, Historical Data API Pro+, or a one-off package pass), plus the <code>from</code>/<code>to</code> date-range filter and the 400/403 responses on <code>/history/matches</code>. New schemas <code>HistoryTape</code>, <code>ModelProfile</code>, <code>HistoryPackage</code>. Purely additive.</li>
<li><strong>Concrete plan deltas.</strong> <code>info.description</code> now states exactly what each tier adds over the one below it, with its rate limits (FREE 30/min · 1,000/day; BASIC 60/min · 10,000/day; PRO 300/min · 100,000/day; ULTRA 600/min · 500,000/day), and documents the standalone Historical Data API plans (Starter / Pro / Business / one-off passes). <code>/matches</code> documents that <code>status=completed</code> requires BASIC — the gate was always enforced, just not written down.</li>
<li><strong>WebSocket break-point signals.</strong> The <code>/ws</code> subscribe frame now documents an optional <code>signals</code> array; naming <code>break_point</code> opts the connection into two new frames — <code>break_point</code> (schema <code>BreakPoint</code>) the instant a break point arises and <code>break_point_result</code> (schema <code>BreakPointResult</code>) when it resolves. Both are ULTRA-only and purely additive: an existing subscriber that sends no <code>signals</code> sees exactly the frames it saw before. Documented in <code>info.description</code>, the two new component schemas, and the rendered reference.</li>
<li><strong>FREE tier.</strong> Self-serve with no card at <<a href="https://livetennisapi.com/subscribe/free">https://livetennisapi.com/subscribe/free</a>> (30 req/min, 1,000 req/day). Covers live and upcoming matches, scores, players and fixtures — the six endpoints now tagged <code>(FREE)</code> in their summary. Purely additive: no endpoint, field, or type changed, and every paid tier keeps exactly the access it had. <code>/history/matches</code> remains BASIC; market prices stay PRO; analysis, live model fields and the WebSocket feed stay ULTRA.</li>
<li><code>operationId</code> on all 12 operations, so generated clients get stable method names.</li>
<li><code>info.contact</code>, <code>info.license</code> (MIT) and <code>info.termsOfService</code>.</li>
<li>Redocly lint + a structural contract check in CI.</li>
<li>Rendered reference published to <<a href="https://docs.livetennisapi.com">https://docs.livetennisapi.com</a>>.</li>
</ul>
<h3>Changed</h3>
<ul>
<li><strong>Rendered reference: correct plan labels + plans/FAQ pages.</strong> The tier parser labelled <code>GET /matches/{matchId}</code> as ULTRA (highest-tier-wins scan over "(FREE; +market PRO, +analysis ULTRA)") and did not recognise FREE at all, so every FREE endpoint showed "Plan required: —". It now takes the first tier named. The Plans section gained the FREE row, per-tier daily caps, the standalone Historical Data API plans (Starter / Pro / Business / one-off passes), the Break-point Alerts plans (Free vs Pro), and a FAQ ("How much data can I access on each plan?", "How far back does history go?", "What's in the point-by-point tape?"). Operation descriptions are now rendered, and <code>llms.txt</code> mirrors all of it.</li>
<li><code>info.title</code> is now <code>Live Tennis API</code>, matching the product name.</li>
</ul></article>
</main>
<script>
(function(){try{navigator.sendBeacon('https://livetennisapi.com/collect',JSON.stringify({kind:'docs_view',path:location.pathname,host:location.host}))}catch(e){}})();
</script>
<script>
(function(){var E={};"AT BE BG HR CY CZ DK EE FI FR DE GR HU IE IT LV LT LU MT NL PL PT RO SK SI ES SE IS LI NO".split(" ").forEach(function(c){E[c]=1});fetch("https://livetennisapi.com/cdn-cgi/trace").then(function(r){return r.text()}).then(function(t){var m=t.match(/loc=(\w+)/);if(!m||E[m[1]])return;(function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y)})(window,document,"clarity","script","xotc2pctsp");try{clarity("set","user_state","docs")}catch(e){}}).catch(function(){})})();
</script>
</body>
</html>