Repository navigation
Expand file tree
/
Copy pathopenapi.json
More file actions
9081 lines (9081 loc) · 361 KB
/
Copy pathopenapi.json
File metadata and controls
9081 lines (9081 loc) · 361 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
{
"openapi": "3.1.0",
"info": {
"title": "Live Tennis API",
"version": "1.13.60",
"contact": {
"name": "Live Tennis API",
"url": "https://livetennisapi.com"
},
"license": {
"name": "MIT",
"url": "https://github.com/livetennisapi/openapi/blob/main/LICENSE"
},
"termsOfService": "https://livetennisapi.com/terms",
"description": "Real-time tennis scores, player data, match-winner market prices, and\nmodel-driven match analysis. Read-only. Coverage spans ATP, WTA,\nChallenger, ITF and the junior Grand Slam draws — depth differs by tour\nand surface; `GET /history/coverage` states the measured numbers.\n\nAccess is tiered (FREE / BASIC / PRO / ULTRA). Each tier includes\neverything in the tiers below it; the concrete deltas are:\n\n`FREE` — self-serve, no card (https://livetennisapi.com/subscribe/free).\nLive and upcoming matches, current scores, players, fixtures, the\ntournament catalogue (`/tournaments`), and your own usage stats.\n30 requests/minute, 100/day. No market prices, no model fields, no\nWebSocket. Historical results are not part of the tier, but a FREE key\nmay spend 20 calls per calendar month on the history endpoints as a\ntaste of the product — served exactly like an entitled call. Past that\nthey answer `403 upgrade_required` carrying\n`free_history_taste: \"used\"`.\n\n`BASIC` — adds historical data: the completed-match listing\n(`/history/matches`, and `status=completed` on `/matches`), the\nper-match point-by-point tape with the model win-probability on the\nrows where the model ran\n(`/history/matches/{matchId}`), the measured completeness rollup\n(`/history/coverage`), and the results archive (1968–2022) —\ndeep results (`/history/archive/matches`), archive player bios\n(`/history/archive/players`), career aggregates\n(`/history/archive/career`) and head-to-head (`/h2h`).\n60 requests/minute, 1,000/day.\n\n`PRO` — adds match events (`/matches/{matchId}/events`), market prices\n(`/markets`, `/markets/{matchId}/prices`, `/matches/{matchId}/prices`),\nthe pre-built monthly bulk history packages (`/history/packages`) and the\nrank-ordered rankings listing (`/rankings?system=`).\n300 requests/minute, 10,000/day.\n\n`ULTRA` — adds model analysis (`/matches/{matchId}/analysis`), the live\nmodel fields (`win_probability_p1`, `danger`) on every score object,\nin-play match statistics (`/matches/{matchId}/statistics`), per-player\nas-of ranking records (`/rankings?player=`), the as-of Elo tape\n(`/rankings?system=elo` — both modes, plus `kind=elo` bulk packages),\nrally construction\n(`/rally/matches`, shot-by-shot charted data), career and per-match\ncharting stats (`/charting/players`, `/charting/matches/{chartingMatchId}`),\nthe reconstructed 2013–2022 archive tape\n(`/history/archive/matches/{archiveId}/tape` — also opened by ANY active\nHistory plan, Starter included), the WebSocket live feed at `/ws` and the\nhigh-fan-out push feed (`/ws-token`), and outbound webhooks (direct keys).\n600 requests/minute, 500,000/day.\n\nHistory runs in two continuous halves, deliberately non-overlapping: the\npoint-by-point tape (2023→now) covers January 2023 to now, match by\nmatch, point by point; the results archive (1968–2022) covers 1968\nthrough 2022 as winner/loser-shaped RESULTS (final score, seeds, ranks at\nthe time — no point-by-point). The archive ends exactly where the tape\nbegins, so no match is ever served from two datasets.\n\nArchive results played **2013–2022** additionally carry a RECONSTRUCTED\npoint-by-point tape at `/history/archive/matches/{archiveId}/tape` — the\nscore sequence behind the published result, rebuilt from the public record\nafter the fact. 97,901 matches / 14,340,663 rows, seasons **2013–2022\nONLY**: the archive holds a further 977,903 results from 1968–2012 and NOT\nONE of them has a tape, because there is no public point-by-point record of\nthose years to rebuild and we do not manufacture one. Write the range as\n2013–2022, never as \"pre-2023\" — the second phrasing reads as 1968 onward\nand is wrong by 45 seasons.\n\nNobody watched those matches, and the data says so: `timestamp`,\n`win_probability_p1` and `danger` are null on EVERY row and cannot be\nfilled in later — the production table has no timestamp column at all, and\nthe promotion script refuses to run if one ever appears.\nContrast the 2023→now tape, which is our own recording: the rows we\nactually watched carry a real clock, and most of them a model probability.\nCoverage of the era is real but partial — 19.3% of archive matches played\n2013–2022 and 44.9% of tour-level play; main-draw tour buckets run\n91.6–98.7%, ATP Challenger main draws 55.3% and Challenger qualifying\n33.6%, slam QUALIFYING only 16.0% (ATP) / 18.1% (WTA), and ITF/futures\neffectively nothing (25 of 116,575 ATP futures matches). It is not a\ncomplete record of the era and is not sold as one.\n\nTwo different gates, on purpose: the per-match tape needs core ULTRA **or\nany active History plan, Starter included**; the per-year bulk files\n(`/history/packages?kind=archive_tape`, 2013–2022, JSONL + CSV) need core\nULTRA **or** a History Pro/Business subscription (an active one-off package\nwindow counts). Core PRO carries NEITHER — it reads the archive RESULT and\nis refused the tape.\n\nA call above your tier returns `403 {\"error\":\"upgrade_required\"}` — never\na silent empty result.\n\nCORS is enabled across the REST surface: every response carries\n`Access-Control-Allow-Origin: *` (GET/OPTIONS, no credentials mode — there\nis no cookie or session, and a wildcard origin is incompatible with\ncredentials by design). Putting a FREE key in browser code is acceptable —\nit is capped and revocable; a paid key belongs server-side only.\n\nThe `/history/*` endpoints are also sold standalone as the **Historical\nData API** (no live-API subscription required): **Starter** — single-match\npoint-by-point tape reads via the API (tape plus the model win-probability\nper point), all tours (ATP/WTA/Challenger/ITF/juniors), one match per\nrequest, no bulk downloads; **Pro** — everything in Starter plus bulk\nmonthly package\ndownloads and higher rate limits; **Business** — everything in Pro plus\nyear-scale archive exports, top rate limits and priority support. One-off\n1-month and 1-year access passes are available without a subscription.\nThe results archive (1968–2022) endpoints (`/history/archive/*`, `/h2h`)\nride with the same entitlement — any active History plan, Starter\nincluded, opens them alongside the tape endpoints, and that includes the\nreconstructed 2013–2022 archive tape. The per-year `archive_tape` bulk\nfiles do not: those need Pro, Business or an active one-off package pass,\nbecause a Starter grant reads tapes one at a time and does not download\nyears of them.\nPlans and prices: https://livetennisapi.com/historical-tennis-data-api\n\nAll timestamps are UTC ISO 8601 with a `Z` suffix. List endpoints return\n`{data, meta}`; single resources return the object directly. Ignore\nunknown fields — additive changes land within v1.\n\nA native WebSocket live feed (ULTRA) exists at `/ws` under the same base\nURL. Subscribe with one JSON frame whose keys are `topics` and\n(optionally) `signals`: `{\"topics\":[\"live-scores\"]}` — `topics` may also\nname `\"match:<id>\"`. The server acks with a `subscribed` frame, then\npushes `score` frames on every change plus a `ping` heartbeat roughly\nevery 15s. Score frames carry the ULTRA model fields\n(`win_probability_p1`, `danger`) live; a null there means the model had\nno output for that point, not that the field is REST-only. Opt into extra\nsignals with `{\"topics\":[\"live-scores\"],\"signals\":[\"break_point\"]}` to\nalso receive `break_point` and `break_point_result` frames — and\n`signals:[\"stoppages\"]` (2026-09-12) for the stoppage family: medical\ntimeouts, trainer calls, toilet breaks, whole-match stops and clock-inferred\npauses, each an Event object plus `match_id` — (schemas\n`BreakPoint` / `BreakPointResult`). Without `signals`, score frames only.\n\n`signals` may also name `points` — the live per-point event stream: one\n`point` frame (schema `PointFrame`) per persisted point of your\nsubscribed matches. On the live basis `seq` is ARRIVAL order, not match\norder: use it to page, dedup and resume, and sort by\n`(set, game, number)` to replay in playing order — a tuple that may\nrepeat or carry a null `number`, so it orders points without identifying\nthem (see `GET /matches/{matchId}/points`). The signal is\nconfig-gated and ships OFF by default; the `subscribed` ack echoes the\nsignals actually active, so `points` present in the ack means point\nframes will flow and missing means they will not. Frames arrive only for\nmatches with `pbp_coverage: \"point\"` — a `game`-coverage match sends\nnone, honestly. Best-effort with NO replay: on reconnect (or to join\nmid-match) catch up via `GET /matches/{matchId}/points?after_seq=` and\ndedup by `seq`.\n\nMax 2 concurrent connections per key. For high fan-out, `GET /ws-token`\nmints a token for the separate push feed.\n\nCLOSE CODES. Every refusal sends its `error` frame **and then closes with a\ncode that says what to do next**, so a reconnect loop or a supervisor keyed\non the close code alone behaves correctly without parsing the frame. The\nclose *reason* repeats the frame's `error` string, so `(code, reason)` is a\ncomplete diagnosis even if the frame was missed.\n`1013` Try Again Later — transient, the request was fine: `connection_limit`\n(reason `connection_limit:per_key` or `connection_limit:server`) and\n`service_unavailable`. Back off and retry; for `per_key` release a\nconnection first, or move to the push feed, which has no shared ceiling.\n`1008` Policy Violation — the request as sent will never be accepted:\n`unauthorized`, `upgrade_required`, `email_unverified`, `client_blocked`,\n`bad_json`, `no_topics`, and any mid-stream loss of access. Fix the request\nor the credentials; do not retry unchanged.\n`1012` Service Restart — reconnect with backoff and re-subscribe.\n`1000` Normal Closure — you closed it, or the stream ended normally.\nChanged 2026-09-18: refusals raised during the *handshake* previously closed\n`1000` with an empty reason, indistinguishable from an orderly shutdown, so\na client awaiting its `subscribed` ack saw only a normal close. The error\nframe was, and still is, delivered before the close; only the close code and\nreason changed.\n\nGetting a match id: it is the `id` field on any match object returned by\n`GET /matches`, `GET /fixtures` or `GET /history/matches`, and the same value\nworks on every route that takes `matchId`.\n"
},
"servers": [
{
"url": "https://api.livetennisapi.com/api/public/v1"
}
],
"security": [
{
"bearerAuth": []
},
{
"apiKeyHeader": []
}
],
"paths": {
"/health": {
"get": {
"summary": "Liveness probe (no auth)",
"operationId": "healthCheck",
"security": [],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string",
"const": "ok"
},
"version": {
"type": "string",
"const": "v1"
}
}
}
}
}
}
}
}
},
"/matches": {
"get": {
"summary": "List matches by lifecycle status (FREE)",
"description": "`status=live` and `status=upcoming` are the FREE current-state picture. `status=completed` pages historical results and is part of the paid History product — it requires BASIC (the same rule as `/history/matches`). A FREE key may spend its 20 free history calls per calendar month here; past that the answer is `403 upgrade_required` carrying `free_history_taste: \"used\"`. The `player`, `country`, `from`/`to`, `tour`, `draw`, `has_analysis` and `has_market` filters are optional, AND-composed, applied inside the query (before pagination), and work on every status — omitting them returns exactly what the endpoint returned before they existed. `has_analysis` and `has_market` are the two availability flags every row already carries: filter the slate with them and call `/matches/{matchId}/analysis` and `/matches/{matchId}/prices` only for the ids that have something, rather than probing per match for a 404.",
"operationId": "listMatches",
"parameters": [
{
"name": "status",
"in": "query",
"description": "`live` (default) and `upcoming` are the FREE current-state picture. `completed` and `cancelled` are terminal LISTINGS, part of the history product (BASIC, or any History plan on a free key; a FREE key's 20 free history calls each month are served here too). `cancelled` covers feed-cancelled, walkover-with-no-stated-winner and postponed-never-played matches; a walkover that named its winner is `completed`. `cancelled` pages with `limit`/`offset` (optionally `from`/`to`) and does NOT accept `updated_since` (400 `bad_request`). Any other value is a 400 `bad_status` carrying the accepted list in `allowed`.",
"schema": {
"type": "string",
"enum": [
"live",
"upcoming",
"completed",
"cancelled"
],
"default": "live"
}
},
{
"$ref": "#/components/parameters/tour"
},
{
"$ref": "#/components/parameters/draw"
},
{
"$ref": "#/components/parameters/player"
},
{
"$ref": "#/components/parameters/country"
},
{
"$ref": "#/components/parameters/tournamentId"
},
{
"$ref": "#/components/parameters/tier"
},
{
"$ref": "#/components/parameters/hasAnalysis"
},
{
"$ref": "#/components/parameters/hasMarket"
},
{
"$ref": "#/components/parameters/isQualifying"
},
{
"$ref": "#/components/parameters/gender"
},
{
"$ref": "#/components/parameters/playedFrom"
},
{
"$ref": "#/components/parameters/playedTo"
},
{
"$ref": "#/components/parameters/updatedSince"
},
{
"$ref": "#/components/parameters/withdrawnSince"
},
{
"$ref": "#/components/parameters/cursor"
},
{
"$ref": "#/components/parameters/limit"
},
{
"$ref": "#/components/parameters/offset"
}
],
"responses": {
"200": {
"description": "Matches with latest score",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Match"
}
},
"meta": {
"$ref": "#/components/schemas/ListMeta"
}
}
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/UpgradeRequired"
},
"429": {
"$ref": "#/components/responses/RateLimited"
}
}
}
},
"/matches/{matchId}": {
"get": {
"summary": "Full match detail (FREE; +market PRO, +analysis ULTRA)",
"operationId": "getMatch",
"parameters": [
{
"$ref": "#/components/parameters/matchId"
}
],
"responses": {
"200": {
"description": "Match with score; `market` embed at PRO+, `analysis` embed at ULTRA",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MatchDetail"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/UpgradeRequired"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"410": {
"$ref": "#/components/responses/MatchGone"
},
"429": {
"$ref": "#/components/responses/RateLimited"
}
}
}
},
"/matches/{matchId}/score": {
"get": {
"summary": "Current score only — lowest-latency REST read (FREE)",
"description": "This is a POINT-IN-TIME SNAPSHOT: the single current state, overwritten on every score commit. It carries no history and no accumulated statistics. For the SEQUENCE of states — who served each game, hold/break, every score state in forward order — use `/history/matches/{matchId}?sequence=clean`, which works on a LIVE match, not only a completed one. For in-play statistics use `/matches/{matchId}/statistics` (ULTRA); they are deliberately not on this object, because they can be further behind the match than the score and must carry their own `as_of`. ARCHIVED FINALS (since 2026-09-20). The live-score rows behind this read are retired by a 90-day retention sweep, and a match recovered from an official day list may never have had one. When there is no live row at all, a SETTLED match — `outcome` non-null on the match object: completed, retired, walkover, default, abandoned, unresolved — is answered from its archived final: the same read `GET /matches?status=completed` already embeds, through the same serializer, so the listing and this endpoint can never disagree about whether a score exists. A live tape always outranks the archive — the fallback is reached only when no publishable live row exists, so a live match reads exactly what it did before. An archived final carries `age_seconds: null`, `observed_age_seconds: null`, `sources_count: null` and `accepted_at: null` — no clock is claimed for a state nobody watched — and `timestamp` is null where the archived row has none (a reconstructed final). The Score object carries no data-source label; whether that final was observed or reconstructed is what `GET /history/matches/{matchId}` reports once, in `meta.point_source`. 404 is kept for: an upcoming match with no row (nothing to serve yet), a cancelled match that was never played (nothing settled), and a settled match with nothing recorded anywhere — the fallback serves a final that exists, it never invents one. WITHDRAWN STATES (since 2026-09-23). When the legality gate behind this read refuses a state a stream has already published, the `verdict` object on the score says so: `kind`, `superseded_sequence`, `reason` and `safe_to_resume`. It is null on an ordinary read. The push feed, the WebSocket and webhooks carry the same judgement as a `score_withdrawn` frame naming the same sequence, so a stream consumer learns a state was taken back without polling. `GET /history/incidents` is the published register of data-quality incidents. THE RECOVERY PATH (since 2026-09-25). A consumer that missed the frame cannot get the live `verdict` back, because it is recomputed over the newest state and returns null as soon as the next state is accepted. Every withdrawal is now recorded durably when the judgement is made: pass `?sequence=N` here to have `verdict` answer for the sequence you hold, and read `GET /matches/{matchId}/withdrawals` for the whole record on a match, including the frame exactly as the feed sent it. Before 2026-09-20 every settled match older than the retention window answered 404 here (13,437 completed matches from the previous 180 days, 157,170 all time, measured at the change) while the completed listing served its score.",
"operationId": "getMatchScore",
"parameters": [
{
"$ref": "#/components/parameters/matchId"
},
{
"$ref": "#/components/parameters/sequence"
}
],
"responses": {
"200": {
"description": "Current score (ULTRA adds win_probability_p1 + danger). On a settled match with no live row, the archived final (since 2026-09-20) — `age_seconds`, `observed_age_seconds`, `sources_count` and `accepted_at` null.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Score"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"404": {
"description": "No such match; an upcoming match with no score yet; a cancelled match that was never played; or a settled match with nothing recorded anywhere — no live row and no archived final. Since 2026-09-20 a settled match whose live rows were retired by the 90-day sweep is NOT a 404: it answers 200 with its archived final (`age_seconds: null`), the same score `GET /matches?status=completed` embeds for it.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"410": {
"$ref": "#/components/responses/MatchGone"
},
"429": {
"$ref": "#/components/responses/RateLimited"
}
}
}
},
"/matches/{matchId}/events": {
"get": {
"summary": "Match events, newest first (PRO)",
"operationId": "listMatchEvents",
"parameters": [
{
"$ref": "#/components/parameters/matchId"
},
{
"$ref": "#/components/parameters/limit"
},
{
"$ref": "#/components/parameters/offset"
}
],
"responses": {
"200": {
"description": "Events",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Event"
}
},
"meta": {
"$ref": "#/components/schemas/ListMeta"
}
}
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/UpgradeRequired"
},
"410": {
"$ref": "#/components/responses/MatchGone"
},
"429": {
"$ref": "#/components/responses/RateLimited"
}
}
}
},
"/events": {
"get": {
"summary": "Slate-wide events feed — every match's events in one call, oldest first, cursor by id (PRO)",
"operationId": "listSlateEvents",
"description": "Added 2026-09-13. The rows of GET /matches/{matchId}/events for EVERY match in one request, so a poller watching the whole live slate spends one request per tick rather than one per match. `after_id` returns rows with id greater than the one passed, ascending, and `meta.next_cursor` names the last id served (null on a short page = caught up); `since` (UTC instant) is the first-call lower bound; with neither the newest page is served, still ascending. `type` narrows to a comma-separated list of event types or the family name `stoppages` (stoppage_*, pause_*, medical_timeout_*, trainer_called*, toilet_break_*). Rows carry `id` and `match_id` next to the per-match fields. Measured 2026-09-13: a scorer-stated stoppage reaches the feed a median 8 s (p90 13 s) after the scorer's own instant; the WebSocket `stoppages` signal pushes the same row as it is written.",
"parameters": [
{
"name": "type",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Comma-separated event types (see Event.type), or `stoppages` for the whole stoppage family.",
"example": "medical_timeout_start"
},
{
"name": "after_id",
"in": "query",
"required": false,
"schema": {
"type": "integer"
},
"description": "Serve rows with `id` greater than this, ascending. Take it from `meta.next_cursor` or the last row's `id`."
},
{
"name": "since",
"in": "query",
"required": false,
"schema": {
"type": "string",
"format": "date-time"
},
"description": "First-call lower bound, a UTC instant. Rows stamped after it, ascending.",
"example": "2026-09-13T09:00:00Z"
},
{
"$ref": "#/components/parameters/limit"
}
],
"responses": {
"200": {
"description": "Events across the slate, ascending id",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SlateEvent"
}
},
"meta": {
"$ref": "#/components/schemas/ListMeta"
}
}
}
}
}
},
"400": {
"description": "bad_type, bad_after_id or bad_since"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/UpgradeRequired"
},
"429": {
"$ref": "#/components/responses/RateLimited"
}
}
}
},
"/players/{playerId}/stoppages": {
"get": {
"summary": "One player's in-match stoppages and did-not-finish outcomes, newest first over a window (PRO)",
"description": "Added 2026-09-16. Medical timeouts and trainer calls on the player's matches (from the stoppage family of GET /matches/{matchId}/events), plus retirements and walkovers where THIS player is the non-winner, merged and sorted by `at` descending. Window: `since`/`until` (ISO date or UTC instant; default the last 180 days; at most 366 days, else 400 window_too_long). `kind` filters the seven row kinds (default medical_timeout,trainer_called,retirement,walkover); `before` pages by `meta.next_cursor`. `meta.latest_medical_timeout` and `previous_medical_timeout` are the two newest medical timeouts in the window whatever the page or the kind filter. `meta.record_starts` states how far back each family goes: stoppage rows exist from 2026-09-12 only; outcome rows from the oldest match with a stated winner. In-match stoppages and match outcomes only — no off-court injury record exists here. Same PRO capability as /events. `GET /players/{playerId}/injuries` serves the identical response.",
"operationId": "listPlayerStoppages",
"parameters": [
{
"name": "playerId",
"in": "path",
"required": true,
"schema": {
"type": "integer"
}
},
{
"name": "since",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Window start — an ISO date (start of that day) or a UTC instant. Default `until` minus 180 days.",
"example": "2026-03-19"
},
{
"name": "until",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Window end — an ISO date (the whole of that day) or a UTC instant. Default now.",
"example": "2026-09-15T00:00:00Z"
},
{
"name": "kind",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Comma-separated row kinds from medical_timeout, trainer_called, toilet_break, pause, stoppage, retirement, walkover. Default `medical_timeout,trainer_called,retirement,walkover`.",
"example": "medical_timeout,retirement"
},
{
"name": "before",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "The `meta.next_cursor` of the previous page (opaque `<kind>:<id>`), valid for the same window and kinds."
},
{
"$ref": "#/components/parameters/limit"
}
],
"responses": {
"200": {
"description": "The player's stoppage and outcome rows, newest first",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PlayerStoppage"
}
},
"meta": {
"$ref": "#/components/schemas/PlayerStoppagesMeta"
}
}
}
}
}
},
"400": {
"description": "window_too_long, bad_window, bad_since, bad_until, bad_kind or bad_cursor"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/UpgradeRequired"
},
"404": {
"description": "No roster player holds this id. Carries the archive signpost (`detail` + `see`) when the id is a corpus person id.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PlayerNotFound"
}
}
}
},
"410": {
"$ref": "#/components/responses/PlayerGone"
},
"429": {
"$ref": "#/components/responses/RateLimited"
}
}
}
},
"/players/{playerId}/injuries": {
"get": {
"summary": "Alias of /players/{playerId}/stoppages — the identical response (PRO)",
"description": "Added 2026-09-16. The same handler, parameters, rows and meta as /players/{playerId}/stoppages, under the word the request used. The honest name is `stoppages`: nothing here is a diagnosis, only what the scorer, umpire or result stated.",
"operationId": "listPlayerInjuries",
"parameters": [
{
"name": "playerId",
"in": "path",
"required": true,
"schema": {
"type": "integer"
}
},
{
"name": "since",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "until",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "kind",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "before",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"$ref": "#/components/parameters/limit"
}
],
"responses": {
"200": {
"description": "Identical to /players/{playerId}/stoppages",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PlayerStoppage"
}
},
"meta": {
"$ref": "#/components/schemas/PlayerStoppagesMeta"
}
}
}
}
}
},
"400": {
"description": "window_too_long, bad_window, bad_since, bad_until, bad_kind or bad_cursor"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/UpgradeRequired"
},
"404": {
"description": "No roster player holds this id. Carries the archive signpost (`detail` + `see`) when the id is a corpus person id.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PlayerNotFound"
}
}
}
},
"410": {
"$ref": "#/components/responses/PlayerGone"
},
"429": {
"$ref": "#/components/responses/RateLimited"
}
}
}
},
"/matches/{matchId}/status-history": {
"get": {
"summary": "The per-match status ledger — every status / event_status transition with its UTC instant (BASIC, history)",
"description": "Added 2026-09-12. Append-only, oldest first: one row per change of `status` and/or `event_status`, with the instant we published it, the value before and the effective value after, the derived `outcome`, and the newest score row at that instant. A correction is a new row, never an edit — a close published as `unresolved` and later confirmed shows the flip to `completed`; a completion that reopened shows `completed -> live`. `basis: observed` rows exist from 2026-09-11T22:45:48Z; `basis: backfill` rows (2026-09-12) were reconstructed from the one stamp per kind the match row kept before the ledger existed (last promotion to live from 2026-09-05, completion instant from 2026-08-21, last reopen, last event_status change) — one row per stamp, overwritten intermediate transitions are not recovered. A correction to a PUBLISHED result — `status`, `event_status`, winner or the final score changing after the match was first published as completed — is a new row with `basis: restatement` (from 2026-09-22), never a silent edit; `result_restated_at` / `result_version` on the match summarise them and `GET /history/matches?restated_since=` finds them. History capability (BASIC and the Historical Data plans), like the tape.",
"operationId": "getMatchStatusHistory",
"parameters": [
{
"$ref": "#/components/parameters/matchId"
},
{
"$ref": "#/components/parameters/limit"
},
{
"$ref": "#/components/parameters/offset"
}
],
"responses": {
"200": {
"description": "Status transitions, oldest first",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/StatusChange"
}
},
"meta": {
"$ref": "#/components/schemas/ListMeta"
}
}
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/UpgradeRequired"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"410": {
"$ref": "#/components/responses/MatchGone"
},
"429": {
"$ref": "#/components/responses/RateLimited"
}
}
}
},
"/matches/{matchId}/withdrawals": {
"get": {
"summary": "The durable record of every score state we withdrew on this match (ULTRA)",
"description": "Added 2026-09-25. One row per refused `sequence`, oldest first, each carrying the `score_withdrawn` frame exactly as the push feed sent it.\n\nThis is the RECOVERY PATH for a missed frame. The `verdict` object on `GET /matches/{matchId}/score` is recomputed on every read over the newest state, so it returns null again as soon as the next state is accepted, which in a live match is seconds. The record here is written when the judgement is made, before the frame is delivered, so a consumer that was disconnected can still learn that a sequence it holds was taken back. `GET /matches?withdrawn_since=` says which matches to ask about; `?sequence=N` on the score read answers for one sequence without fetching the list.\n\nULTRA, the tier that receives the frame on the push feed and the native WebSocket. An empty `data` is the ordinary answer and means nothing was withdrawn on that match. Records are kept 90 days from the withdrawal.",
"operationId": "getMatchWithdrawals",
"parameters": [
{
"$ref": "#/components/parameters/matchId"
}
],
"responses": {
"200": {
"description": "Withdrawals on this match, oldest first",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"match_id": {
"type": "integer"
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ScoreWithdrawal"
}
}
}
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/UpgradeRequired"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"410": {
"$ref": "#/components/responses/MatchGone"
},
"429": {
"$ref": "#/components/responses/RateLimited"
}
}
}
},
"/matches/{matchId}/analysis": {
"get": {
"summary": "Model analysis for a match (ULTRA)",
"description": "The model's thesis and profile for one match.\n\nCOVERAGE IS NOT UNIVERSAL, and a polling client should plan for that. Analysis is produced per match by the model pipeline rather than emitted for every fixture: over the seven days to 2026-08-27, 1,225 of 2,863 matches that went live or completed carried one (42.8%). A match that has none yet returns `404 {\"error\":\"no_analysis\"}` (since 2026-09-02; before that the body was a bare `not_found`) — that is the documented absence, not a fault, and it can turn into a 200 later in the same match once the pipeline has run. Never treat this 404 as a reason to retry harder. The body names which absence it is: `not_found` is an id that does not exist; `no_analysis` carries `match_id` and `coverage: \"none\"` for a real match with nothing computed.\n\nFILTER THE SLATE FIRST. Every row of `GET /matches` and the detail carries `has_analysis` (every tier), the same fact this endpoint answers 404 about — read it there and call only the matches that carry one, instead of spending one 404 per match.\n\nONE CALL INSTEAD OF THREE. `GET /matches/{matchId}` carries the same thesis and profile in its `analysis` key on ULTRA, alongside `market` and `market_price` on PRO and above, next to the live score. It answers `200` whether or not analysis and a market exist — the keys are `null` instead — so a per-match poll built on the detail route replaces the score, analysis and prices calls with one request and never spends a call on a 404.",
"operationId": "getMatchAnalysis",
"parameters": [
{
"$ref": "#/components/parameters/matchId"
}
],
"responses": {
"200": {
"description": "Thesis + profile (either may be null)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Analysis"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/UpgradeRequired"
},
"404": {
"description": "`error: not_found` — no such match id. `error: no_analysis` (with `match_id`, `coverage: \"none\"`, `detail`) — the match exists and nothing has been computed for it; `has_analysis` on the match list says so without a probe.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"410": {
"$ref": "#/components/responses/MatchGone"
},
"429": {
"$ref": "#/components/responses/RateLimited"
}
}
}
},
"/matches/{matchId}/statistics": {
"get": {
"summary": "In-play statistics — aces, double faults, serve split, hold/break %, break points, service & return points (ULTRA)",
"description": "In-play statistics for one match, in TWO families that are deliberately not merged.\n\nDERIVED (the top level of `players.pN`) are rebuilt from the point-by-point record: service and return games played and won, hold and break percentage, break points faced, saved and converted, service and return points.\n\nMEASURED (`players.pN.measured`) are counted upstream, so they include what no point record can yield — ACES AND DOUBLE FAULTS, the first- and second-serve split, winners and unforced errors. Both families name some of the same quantities, computed two entirely different ways; that is a cross-check, not a duplication to collapse.\n\nMeasured coverage is not uniform and every measured field is optional — an absent field is OMITTED, never zero-filled, so read the keys you are given. Aces and double faults are present across every tour. The serve split and break points saved are present on the main tours and absent on ITF singles. Winners and unforced errors historically appeared on a minority of main-tour matches and have not been delivered upstream since 2026-07-12 (measured 2026-08-17).\n\n`freshness.derived` and `freshness.measured` each carry their own `coverage` (`live` | `final` | `stale` | `none` | `diverged`; `final` = the closing figures of a completed match — a finished match cannot be \"stale\", so its `age_seconds` is null), `as_of`, `age_seconds` and `describes` — the match state the numbers describe. On `diverged` the measured VALUES are withheld and `freshness.measured_divergence` says why; the top-level `coverage` only summarises the response. `none` on both returns 200 with null players, not 404 — the match exists and holding nothing for it is the honest answer.\n\nTHE TWO AGES USE DIFFERENT CLOCKS AND MUST NOT BE COMPARED. The derived age is measured against the newest SCORE row, because between points there is no new score either and wall-clock age would report staleness that does not exist. The measured age is wall clock, because those are fetched on a fixed cadence.\n\nTiebreak games are excluded from the DERIVED family and counted separately; the live record collapses a whole tiebreak onto one entry, so most of its points are lost.",
"operationId": "getMatchStatistics",
"parameters": [
{
"$ref": "#/components/parameters/matchId"
}
],
"responses": {
"200": {
"description": "Statistics with their own coverage and as_of",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MatchStatistics"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/UpgradeRequired"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"410": {
"$ref": "#/components/responses/MatchGone"
},
"429": {
"$ref": "#/components/responses/RateLimited"
}
}
}
},
"/matches/{matchId}/points": {
"get": {
"summary": "Live per-point events in seq order — the REST catch-up for the WebSocket point stream (ULTRA)",
"description": "The live per-point event stream of one match, in `seq` order. The WebSocket `point` frames are best-effort with NO replay, so this endpoint is how you join mid-match and how you recover a dropped connection: subscribe the WS first, then GET with `after_seq` set to the last seq you hold, then dedup everything by `seq` — it is per-match, monotonic and never skips a value, so it is the whole reconciliation key.\n\n`seq` IS ARRIVAL ORDER, NOT MATCH ORDER, ON THE LIVE BASIS. It is assigned in the order points are committed, 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 `seq`. Each row is self-consistent — its `set`, `game`, `number`, `score`, `sets` and `games` all describe the point that was played — but reading the tape in `seq` 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 `reconstruction` basis. So `seq` is the right key for paging, dedup and resume (unique, stable, strictly increasing — all `after_seq` needs) and the wrong key for chronology: sort by `(set, game, number)` to replay in playing order. That tuple ORDERS points; it does not IDENTIFY them. `number` is null wherever we joined a game already in progress, and the same tuple can appear on more than one row — a game re-expanded by a second source re-asserts ordinals it already holds, which on the live basis is a normal re-statement rather than a correction. There is no revision id, superseded-seq or correction flag: rows are append-only and never rewritten, so `after_seq` never needs a refetch, and the page-level `quality` field reads `revised` when the page contains such a re-statement. On the `reconstruction` basis of a completed match, `seq` is contiguous 1..N in true match order and the two agree.\n\nREAD THE COVERAGE HONESTLY BEFORE YOU BUILD ON IT. A match's stream is per-point ONLY where a point-level feed covers it: `pbp_coverage: \"point\"` means a per-point stream has DELIVERED for this match — at least one played point past the `seq` 1 opener; `\"game\"` means no played point has arrived — only the snapshot score path covers it, or the stream holds only its opener so far (a listed match that has not started). An answer, not an error; it flips to `point` on the first played point. To admit a match as advancing, gate on `sequence > 1` (a seeded match is 1) together with `stale: false`. Per-point coverage is never promised slate-wide; ITF and qualifying coverage in particular is partial. `quality: \"revised\"` means the upstream feed rewrote an already-served prefix at least once during this match; served rows are never edited (append-only).\n\nEach row is the state AFTER a played point: `score`/`sets`/`games` (tiebreaks carry the running count in `score` with `games` frozen at the pre-breaker score), its position (`set`/`game`/`number`), `server` (of the next point), the derived `winner` (null when not attributable to a single point — never guessed), and `ts` — CAPTURE time, when our pipeline committed the state, because no feed asserts a per-point clock and we fabricate none.\n\nUp to 500 rows per page; `after_seq=last_seq` fetches the next page while `has_more` is true. 404 unknown match; 400 `points_disabled` while the surface is switched off server-side.\n\nCOMPLETED MATCHES: the stored live stream is served on a completed match too, whenever it is itself measured complete (the match-closing point included) or carries `serve`/`outcome` tags and is legal end to end (every transition one attributable point, judged in playing order). A projection never carries a clock or a tag, so a complete tagged stream is strictly more information than any reconstruction of the same match. Only when the stream falls short — no rows at all, incomplete and untagged, or a transition nobody can attribute — and a measured-complete recorded point sequence of the finished match exists does this endpoint serve THAT instead: the complete sequence projected into the same point-frame shape, love-love opener through the match-closing point, `seq` contiguous 1..N. The response field `basis` says which base served the page: `live` (the persisted live stream rows) or `reconstruction` (the projected complete sequence; `quality` is `clean`, every transition measured legal), and on `reconstruction` `basis_reason` says why the stream was not served: `stream_absent` (no stored stream rows), `stream_incomplete` (the stream is legal but does not measure complete — it joined mid-match or stopped short — and carries no tags) or `stream_illegal` (at least one transition is not attributable to one point: a gap or a torn row). When the reconstruction serves it serves wholesale — the two sequences are never interleaved (they share no key, so any merge would fabricate an order). On projected frames `ts` is null on every row: the recorded sequence carries no per-point clock and we fabricate none. `after_seq` pagination and `seq` dedup work identically on either basis, but the two bases are different sequences: if a completed match reads `reconstruction`, re-read from `after_seq=0` rather than resuming a live cursor into it. Precedence fixed 2026-09-21: until then a measured-complete recorded sequence displaced the stream unconditionally, so a completed match could lose its tags the moment a reconstruction landed.\n\nTHE MATCH-CLOSING POINT (added 2026-09-20). Every row is the state AFTER a point, so the point that wins a game is carried by the next game's `number: 0` opener — and the point that wins the match 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 `live` basis the page now closes with ONE terminal row: `seq` = last + 1, `number` 0, `sets`/`games` the final score, `score` `{\"p1\":\"0\",\"p2\":\"0\"}`, `tiebreak` false, `server` null (nobody serves next), `winner` the match winner, `ts` 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 — the winner held game point and the final is the decided score; nothing is fabricated otherwise. `serve`, `outcome` and `tagged_at` on that row are null: no source's tag for a match's last point is stored yet. When the match ends in a tiebreak (added 2026-09-21) the stream's last row is the decisive tiebreak score itself (7-3, 8-6) and the closing row is the set roll-up after it: the next game number, `number` 0, `tiebreak` false, `sets` incremented for the tiebreak winner, the set banked 7-6 in `games`, `score` 0-0, `server` null, the same `winner` — the same row the stream stores after every other set-ending tiebreak. The response field `ends_at_final` says whether the sequence served ends on the match-closing point: `false` on a completed match whose stream stops short of it — a retirement or walkover (no closing point was played), a capture that stopped two or more points short, or a closer that cannot be stated as one point (from deuce, or from a 10-point match tiebreak). Always `false` on a live match. On the `reconstruction` basis it is judged from the projected sequence's last frame (a complete recorded sequence of a retired match ends at the retirement, so it reads `false` there). WebSocket and push frames are unchanged.\n\nREVISIONS: `changed_since` (added 2026-09-20). `after_seq` is a cursor by `seq`, so it can never return a row you already hold — and a `serve`/`outcome` tag that lands late lands on exactly such a row. Every row now carries `tagged_at`, the UTC instant its tags landed (null while none has). Pass `changed_since=<ISO-8601 instant>` (e.g. `2026-09-20T00:35:18Z`; `Z` or an offset, a naive value is read as UTC, a date alone is refused) to get only the rows whose `ts` OR `tagged_at` is later than that instant, in `seq` order, paged like any other read and composable with `after_seq`. The post-match recipe: read the match, keep the instant, re-read with `changed_since=<that instant>` and replace the rows you hold by `seq` — no socket, no full re-fetch. Anything that is not an ISO-8601 timestamp is a 400 `bad_changed_since`. On the `reconstruction` basis no row carries a clock or a tag, so a `changed_since` read of it is an empty page: that sequence is final at first read.",
"operationId": "getMatchPoints",
"parameters": [
{
"$ref": "#/components/parameters/matchId"
},
{
"name": "after_seq",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"minimum": 0,
"default": 0
},
"description": "Return only points with `seq` greater than this — the resume cursor. Pass the `last_seq` of the previous page (or the last seq your WS stream delivered) to continue; 0 or absent reads from the start of the match. A non-integer or negative value is a 400 `bad_after_seq`."
},
{
"name": "changed_since",
"in": "query",
"required": false,
"schema": {
"type": "string",
"format": "date-time"
},
"description": "Added 2026-09-20. Return only rows whose `ts` OR `tagged_at` is later than this instant — the revision filter for a reader without a socket, composable with `after_seq`. ISO-8601 with a `Z` or an offset (e.g. `2026-09-20T00:35:18Z`); a naive value is read as UTC; a date alone is not an instant and is refused. Anything that is not an ISO-8601 timestamp is a 400 `bad_changed_since`. An empty page on the `reconstruction` basis, whose rows carry neither a clock nor a tag."
}
],
"responses": {
"200": {
"description": "The point events page, seq order",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MatchPoints"
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/UpgradeRequired"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"410": {
"$ref": "#/components/responses/MatchGone"
},
"429": {
"$ref": "#/components/responses/RateLimited"
}
}
}
},
"/players": {
"get": {
"summary": "Search players by name (FREE)",
"operationId": "searchPlayers",
"parameters": [
{
"name": "search",
"in": "query",
"schema": {
"type": "string"
}
},
{
"$ref": "#/components/parameters/limit"
},
{
"$ref": "#/components/parameters/offset"
}
],
"responses": {
"200": {
"description": "Players (ranked first; no stats object on the list)",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Player"
}
},
"meta": {
"$ref": "#/components/schemas/ListMeta"
}
}
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"429": {
"$ref": "#/components/responses/RateLimited"
}
}
}
},
"/players/{playerId}": {
"get": {
"summary": "One player's bio + ranking + cached stats (FREE)",
"description": "`playerId` is a ROSTER id — the live player registry, the id space `/players`, `/matches` and `/rankings` all speak.\nIt is NOT the archive corpus person id. The results archive (1968–2022) keeps its own person registry, and archive match rows publish those ids as `winner.player_id` / `loser.player_id`. The two spaces are disjoint: no corpus id resolves here, and since 2026-09-20 a 404 for one says so and points at `/history/archive/players?id={playerId}`, which is where that id is read. A 404 with no `see` field is simply an id we do not hold.",
"operationId": "getPlayer",
"parameters": [
{
"name": "playerId",
"in": "path",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Player with `stats` ({ratings, season})",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Player"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"404": {
"description": "No roster player holds this id. When the id IS a corpus person id the body adds `detail` and `see` (the archive lookup); otherwise it is the plain `Error` body.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PlayerNotFound"
}
}
}
},
"410": {
"$ref": "#/components/responses/PlayerGone"
},
"429": {
"$ref": "#/components/responses/RateLimited"
}
}
}
},
"/tournaments": {
"get": {
"summary": "Tournament catalogue — the id space `Match.tournament_id` joins (FREE)",
"description": "Stable tournament identity, one row per tournament × event type, stable across seasons. `city`/`country` come from a curated table and `category` only where our catalogues agree unambiguously on an exact-name join — each is null otherwise, never derived from the tournament name. Each row also carries the CURRENT season's `tier` / `tier_source` (added 2026-09-22) — the level in a closed vocabulary (`atp_500`, `challenger_125`, `wta_125`, `itf_w35` … | null); the tier is per season, so a match carries its own.",
"operationId": "listTournaments",
"parameters": [
{
"name": "search",
"in": "query",
"schema": {
"type": "string"
},
"description": "Case-insensitive substring match on the tournament name."
},
{
"$ref": "#/components/parameters/tour"
},
{
"$ref": "#/components/parameters/draw"
},
{
"name": "tier",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Tier filter (added 2026-09-24): comma-separated exact values from the same closed tier vocabulary as `Match.tier` (`?tier=atp_500`, `?tier=atp_500,challenger_100`). Selects tournaments whose own `tier` field carries one of the values, which on this resource is the **current season's** level, because a catalogue row has no season of its own. That is the one difference from `?tier=` on `/matches` and `/history/matches`, where the value is the level in each MATCH'S season (a tournament keeps one `tournament_id` while its level moves). Filter and field always read the same season on the same resource, so they cannot disagree. A tournament with no row for the current season matches no value: null means the level is unknown, not absent. Composes with `?tour=`, `?draw=` and `?search=`. Unknown value → 400 `bad_tier` with the offending values in `bad` and the full vocabulary in `allowed`."
},
{
"$ref": "#/components/parameters/limit"
},
{
"$ref": "#/components/parameters/offset"
}
],
"responses": {
"200": {
"description": "Tournaments, name order",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Tournament"
}
},
"meta": {
"$ref": "#/components/schemas/ListMeta"
}
}
}
}
}
},
"400": {