-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathopenapi.yaml
More file actions
7302 lines (7169 loc) · 355 KB
/
Copy pathopenapi.yaml
File metadata and controls
7302 lines (7169 loc) · 355 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
model-driven match analysis. Read-only. Coverage spans ATP, WTA,
Challenger, ITF and the junior Grand Slam draws — depth differs by tour
and surface; `GET /history/coverage` states the measured numbers.
Access is tiered (FREE / BASIC / PRO / ULTRA). Each tier includes
everything in the tiers below it; the concrete deltas are:
`FREE` — self-serve, no card (https://livetennisapi.com/subscribe/free).
Live and upcoming matches, current scores, players, fixtures, the
tournament catalogue (`/tournaments`), and your own usage stats.
30 requests/minute, 100/day. No market prices, no model fields, no
WebSocket. Historical results are not part of the tier, but a FREE key
may spend 20 calls per calendar month on the history endpoints as a
taste of the product — served exactly like an entitled call. Past that
they answer `403 upgrade_required` carrying
`free_history_taste: "used"`.
`BASIC` — adds historical data: the completed-match listing
(`/history/matches`, and `status=completed` on `/matches`), the
per-match point-by-point tape with the model win-probability on the
rows where the model ran
(`/history/matches/{matchId}`), the measured completeness rollup
(`/history/coverage`), and the results archive (1968–2022) —
deep results (`/history/archive/matches`), archive player bios
(`/history/archive/players`), career aggregates
(`/history/archive/career`) and head-to-head (`/h2h`).
60 requests/minute, 1,000/day.
`PRO` — adds match events (`/matches/{matchId}/events`), market prices
(`/markets`, `/markets/{matchId}/prices`, `/matches/{matchId}/prices`),
the pre-built monthly bulk history packages (`/history/packages`) and the
rank-ordered rankings listing (`/rankings?system=`).
300 requests/minute, 10,000/day.
`ULTRA` — adds model analysis (`/matches/{matchId}/analysis`), the live
model fields (`win_probability_p1`, `danger`) on every score object,
in-play match statistics (`/matches/{matchId}/statistics`), per-player
as-of ranking records (`/rankings?player=`), the as-of Elo tape
(`/rankings?system=elo` — both modes, plus `kind=elo` bulk packages),
rally construction
(`/rally/matches`, shot-by-shot charted data), career and per-match
charting stats (`/charting/players`, `/charting/matches/{chartingMatchId}`),
the reconstructed 2013–2022 archive tape
(`/history/archive/matches/{archiveId}/tape` — also opened by ANY active
History plan, Starter included), the WebSocket live feed at `/ws` and the
high-fan-out push feed (`/ws-token`), and outbound webhooks (direct keys).
600 requests/minute, 500,000/day.
History runs in two continuous halves, deliberately non-overlapping: the
point-by-point tape (2023→now) covers January 2023 to now, match by
match, point by point; the results archive (1968–2022) covers 1968
through 2022 as winner/loser-shaped RESULTS (final score, seeds, ranks at
the time — no point-by-point). The archive ends exactly where the tape
begins, so no match is ever served from two datasets.
Archive results played **2013–2022** additionally carry a RECONSTRUCTED
point-by-point tape at `/history/archive/matches/{archiveId}/tape` — the
score sequence behind the published result, rebuilt from the public record
after the fact. 97,901 matches / 14,340,663 rows, seasons **2013–2022
ONLY**: the archive holds a further 977,903 results from 1968–2012 and NOT
ONE of them has a tape, because there is no public point-by-point record of
those years to rebuild and we do not manufacture one. Write the range as
2013–2022, never as "pre-2023" — the second phrasing reads as 1968 onward
and is wrong by 45 seasons.
Nobody watched those matches, and the data says so: `timestamp`,
`win_probability_p1` and `danger` are null on EVERY row and cannot be
filled in later — the production table has no timestamp column at all, and
the promotion script refuses to run if one ever appears.
Contrast the 2023→now tape, which is our own recording: the rows we
actually watched carry a real clock, and most of them a model probability.
Coverage of the era is real but partial — 19.3% of archive matches played
2013–2022 and 44.9% of tour-level play; main-draw tour buckets run
91.6–98.7%, ATP Challenger main draws 55.3% and Challenger qualifying
33.6%, slam QUALIFYING only 16.0% (ATP) / 18.1% (WTA), and ITF/futures
effectively nothing (25 of 116,575 ATP futures matches). It is not a
complete record of the era and is not sold as one.
Two different gates, on purpose: the per-match tape needs core ULTRA **or
any active History plan, Starter included**; the per-year bulk files
(`/history/packages?kind=archive_tape`, 2013–2022, JSONL + CSV) need core
ULTRA **or** a History Pro/Business subscription (an active one-off package
window counts). Core PRO carries NEITHER — it reads the archive RESULT and
is refused the tape.
A call above your tier returns `403 {"error":"upgrade_required"}` — never
a silent empty result.
CORS is enabled across the REST surface: every response carries
`Access-Control-Allow-Origin: *` (GET/OPTIONS, no credentials mode — there
is no cookie or session, and a wildcard origin is incompatible with
credentials by design). Putting a FREE key in browser code is acceptable —
it is capped and revocable; a paid key belongs server-side only.
The `/history/*` endpoints are also sold standalone as the **Historical
Data API** (no live-API subscription required): **Starter** — single-match
point-by-point tape reads via the API (tape plus the model win-probability
per point), all tours (ATP/WTA/Challenger/ITF/juniors), one match per
request, no bulk downloads; **Pro** — everything in Starter plus bulk
monthly package
downloads and higher rate limits; **Business** — everything in Pro plus
year-scale archive exports, top rate limits and priority support. One-off
1-month and 1-year access passes are available without a subscription.
The results archive (1968–2022) endpoints (`/history/archive/*`, `/h2h`)
ride with the same entitlement — any active History plan, Starter
included, opens them alongside the tape endpoints, and that includes the
reconstructed 2013–2022 archive tape. The per-year `archive_tape` bulk
files do not: those need Pro, Business or an active one-off package pass,
because a Starter grant reads tapes one at a time and does not download
years of them.
Plans and prices: https://livetennisapi.com/historical-tennis-data-api
All timestamps are UTC ISO 8601 with a `Z` suffix. List endpoints return
`{data, meta}`; single resources return the object directly. Ignore
unknown fields — additive changes land within v1.
A native WebSocket live feed (ULTRA) exists at `/ws` under the same base
URL. Subscribe with one JSON frame whose keys are `topics` and
(optionally) `signals`: `{"topics":["live-scores"]}` — `topics` may also
name `"match:<id>"`. The server acks with a `subscribed` frame, then
pushes `score` frames on every change plus a `ping` heartbeat roughly
every 15s. Score frames carry the ULTRA model fields
(`win_probability_p1`, `danger`) live; a null there means the model had
no output for that point, not that the field is REST-only. Opt into extra
signals with `{"topics":["live-scores"],"signals":["break_point"]}` to
also receive `break_point` and `break_point_result` frames — and
`signals:["stoppages"]` (2026-09-12) for the stoppage family: medical
timeouts, trainer calls, toilet breaks, whole-match stops and clock-inferred
pauses, each an Event object plus `match_id` — (schemas
`BreakPoint` / `BreakPointResult`). Without `signals`, score frames only.
`signals` may also name `points` — the live per-point event stream: one
`point` frame (schema `PointFrame`) per persisted point of your
subscribed matches. On the live basis `seq` is ARRIVAL order, not match
order: use it to page, dedup and resume, and sort by
`(set, game, number)` to replay in playing order — a tuple that may
repeat or carry a null `number`, so it orders points without identifying
them (see `GET /matches/{matchId}/points`). The signal is
config-gated and ships OFF by default; the `subscribed` ack echoes the
signals actually active, so `points` present in the ack means point
frames will flow and missing means they will not. Frames arrive only for
matches with `pbp_coverage: "point"` — a `game`-coverage match sends
none, honestly. Best-effort with NO replay: on reconnect (or to join
mid-match) catch up via `GET /matches/{matchId}/points?after_seq=` and
dedup by `seq`.
Max 2 concurrent connections per key. For high fan-out, `GET /ws-token`
mints a token for the separate push feed.
CLOSE CODES. Every refusal sends its `error` frame **and then closes with a
code that says what to do next**, so a reconnect loop or a supervisor keyed
on the close code alone behaves correctly without parsing the frame. The
close *reason* repeats the frame's `error` string, so `(code, reason)` is a
complete diagnosis even if the frame was missed.
`1013` Try Again Later — transient, the request was fine: `connection_limit`
(reason `connection_limit:per_key` or `connection_limit:server`) and
`service_unavailable`. Back off and retry; for `per_key` release a
connection first, or move to the push feed, which has no shared ceiling.
`1008` Policy Violation — the request as sent will never be accepted:
`unauthorized`, `upgrade_required`, `email_unverified`, `client_blocked`,
`bad_json`, `no_topics`, and any mid-stream loss of access. Fix the request
or the credentials; do not retry unchanged.
`1012` Service Restart — reconnect with backoff and re-subscribe.
`1000` Normal Closure — you closed it, or the stream ended normally.
Changed 2026-09-18: refusals raised during the *handshake* previously closed
`1000` with an empty reason, indistinguishable from an orderly shutdown, so
a client awaiting its `subscribed` ack saw only a normal close. The error
frame was, and still is, delivered before the close; only the close code and
reason changed.
Getting a match id: it is the `id` field on any match object returned by
`GET /matches`, `GET /fixtures` or `GET /history/matches`, and the same value
works on every route that takes `matchId`.
servers:
- url: https://api.livetennisapi.com/api/public/v1
security:
- bearerAuth: []
- apiKeyHeader: []
paths:
/health:
get:
summary: Liveness probe (no auth)
operationId: healthCheck
security: []
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
status: { type: string, const: ok }
version: { type: string, const: v1 }
/matches:
get:
summary: List matches by lifecycle status (FREE)
description: >-
`status=live` and `status=upcoming` are the FREE current-state picture.
`status=completed` pages historical results and is part of the paid
History product — it requires BASIC (the same rule as
`/history/matches`). 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.
This 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.
ULTRA, 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.
COVERAGE 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.
FILTER 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.
ONE 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.
DERIVED (the top level of `players.pN`) are rebuilt from the
point-by-point record: service and return games played and won, hold
and break percentage, break points faced, saved and converted, service
and return points.
MEASURED (`players.pN.measured`) are counted upstream, so they include
what no point record can yield — ACES AND DOUBLE FAULTS, the first- and
second-serve split, winners and unforced errors. Both families name
some of the same quantities, computed two entirely different ways; that
is a cross-check, not a duplication to collapse.
Measured coverage is not uniform and every measured field is optional —
an absent field is OMITTED, never zero-filled, so read the keys you are
given. Aces and double faults are present across every tour. The serve
split and break points saved are present on the main tours and absent
on ITF singles. Winners and unforced errors historically appeared on
a minority of main-tour matches and have not been delivered upstream
since 2026-07-12 (measured 2026-08-17).
`freshness.derived` and `freshness.measured` each carry their own
`coverage` (`live` | `final` | `stale` | `none` | `diverged`;
`final` = the closing figures of a completed match — a finished match
cannot be "stale", so its `age_seconds` is null), `as_of`,
`age_seconds` and `describes` — the match state the numbers describe.
On `diverged` the measured VALUES are withheld and
`freshness.measured_divergence` says why; the top-level `coverage` only
summarises the response. `none` on both returns 200 with null players,
not 404 — the match exists and holding nothing for it is the honest
answer.
THE TWO AGES USE DIFFERENT CLOCKS AND MUST NOT BE COMPARED. The derived
age is measured against the newest SCORE row, because between points
there is no new score either and wall-clock age would report staleness
that does not exist. The measured age is wall clock, because those are
fetched on a fixed cadence.
Tiebreak games are excluded from the DERIVED family and counted
separately; the live record collapses a whole tiebreak onto one entry,
so most of its points are lost.
operationId: getMatchStatistics
parameters: [ { $ref: "#/components/parameters/matchId" } ]
responses:
"200":
description: Statistics with their own coverage and as_of
content:
application/json:
schema: { $ref: "#/components/schemas/MatchStatistics" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/UpgradeRequired" }
"404": { $ref: "#/components/responses/NotFound" }
"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.
`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.
READ THE COVERAGE HONESTLY BEFORE YOU BUILD ON IT. A match's stream
is per-point ONLY where a point-level feed covers it:
`pbp_coverage: "point"` means 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).
Each row is the state AFTER a played point: `score`/`sets`/`games`
(tiebreaks carry the running count in `score` with `games` frozen at
the pre-breaker score), its position (`set`/`game`/`number`),
`server` (of the next point), the derived `winner` (null when not
attributable to a single point — never guessed), and `ts` — CAPTURE
time, when our pipeline committed the state, because no feed asserts
a per-point clock and we fabricate none.
Up to 500 rows per page; `after_seq=last_seq` fetches the next page
while `has_more` is true. 404 unknown match; 400 `points_disabled`
while the surface is switched off server-side.
COMPLETED MATCHES: 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.
THE 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.
REVISIONS: `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