All notable changes to this project are documented here. This project adheres to Semantic Versioning.
- Signal frames on the push feed —
PushStream(signals=[…]). The same opt-in vocabulary as the native streamer:signals=["break_point"]subscribes the signal channels (signal:slate, orsignal:match:<id>per entry ofmatch_ids) and yields the sameBreakPoint/BreakPointResultobjectsLiveScoreStreamyields;signals=["divergence"]adds the divergence events, each a genericPushFramewithtype == "divergence"(no dedicated model on either streamer). The signal channels carry every family, so the stream filters to the families you asked for — the exact behaviour of the nativesignalssubscription. Signal frames are events with no replay: a stream that connects mid-break-point does not receive the onset. Break-point and divergence users no longer need the capacity-capped native feed for them.- Like the point channels, the signal channels are subscribed ONLY from
the token mint's advertised vocabulary: when the mint does not advertise
them (server gate off, or the plan lacks them), a non-empty
signalsraisesPushRefusedimmediately, naming that cause — never a guessed channel name, never a reconnect loop. signals=["points"]onPushStreamraisesValueErrorpointing atpoints=True— on the push feed the per-point stream is its own opt-in with its own resume machinery.WSTokengains the matching vocabulary helpers:signal_slate_channelandsignal_match_channel(match_id), returningNonewhen the mint does not advertise the family.
- Like the point channels, the signal channels are subscribed ONLY from
the token mint's advertised vocabulary: when the mint does not advertise
them (server gate off, or the plan lacks them), a non-empty
livetennis watch --pushstreams over the push feed instead of the native WebSocket, with identical output — recommended for continuous use (the native feed is capacity-capped shared infrastructure). Without the flag the command behaves exactly as before.
- README:
PushStreamis now the first streaming example; the native streamer follows with its shared-capacity ceiling stated. The claim that break-point signal frames exist only on the native streamer is gone — as of this release it is no longer true.
- Fully backwards compatible. A
PushStreamwithoutsignalsbehaves exactly as before; the CLI without--pushis unchanged.
draw— singles vs doubles, three-valued and filterable.Matchgainsdraw:"singles"|"doubles"|None— and theNoneis an answer, not a gap. Team ties and team exhibitions never state which discipline a rubber was, so those matches carry a null draw rather than a guess; the existingis_doublesstays untouched but is lossy (it cannot say "unknown"), so branch ondraw. The same word filters:list_matches,list_completed_matches,list_tournamentsandlist_fixturestakedraw="singles" | "doubles", passed through to the server as given (the server owns validation — an invalid value is a 400bad_drawwith the allowed list in the body). A null-draw row matches NEITHER filter value, sodraw="singles"plusdraw="doubles"is not everything.get_history_coverage()— the measured point-completeness table. BASIC, or any History plan. One object for the whole completed archive, typed asHistoryCoverage: per-tour_drawbucket (atp_singles,itf_doubles, …) how many completed matches we hold (completed), how many carry any tape (any_tape), how many have a complete point-by-point tape AVAILABLE (point_complete), how many a default read serves complete (complete_on_default_read), and theshare— plustotalsacross every bucket. The two completeness counts differ on purpose: a complete tape can exist for a match a default read does not serve complete. The numbers are a built artifact (as_of— parsed — stamps the build,methodhow they were measured); while it is not built the endpoint answers 503coverage_unavailable, raised asServiceUnavailablewith the code readable — never an empty object.list_fixturesgainstour=anddraw=. The server accepted atourfilter on/fixturesall along; this client never passed it. That gap closes here, alongside the newdraw.- The per-row
tapeblock on/history/matchesrows also carriesstarts_at_loveandcomputed_aton servers that measure them — reachable today through the models' forward-compatible field pass-through. An absent field there means an older server or "not measured", never "no".
- Fully backwards compatible. Every addition is a new method, a new optional keyword argument, a new optional field, or a new model.
- The per-point stream, on every surface the SDK has. One record per
committed point — who served, who won it, the score after — keyed by the
per-match
seq: monotonic, gapless, starting at 1, and identical across REST and both streamers, so any two reads deduplicate against each other byseqalone. All of it is ULTRA.- REST:
get_match_points(match_id, after_seq=0)returns aPointsPage(every committed point withseq > after_seq, ≤500 per page), anditer_match_pointswalks the pages on the sequence cursor —has_more/last_seq, never the page length (a live match's newest page is routinely short while more points are coming), stopping rather than looping on a page that makes no forward progress. Both on the async client too.PointsPage.covers_from_startsays whetherseq1 really is the match's first point —Nonewhen the server did not state it (older servers omit the field), which means "not measured", never "no". A 400points_disabledmeans the feature is off server-side; no retry changes that. - Native streamer (
LiveScoreStream):signals=["points"]yields aPointUpdateper point (match_id, the nestedLivePoint,pbp_coverage,quality), interleaved with score frames.pointframes were previously dropped with the protocol noise; they now dispatch — a subscription that asked for points actually sees them. - Push streamer (
PushStream):points=Truesubscribes the point channels —point:slate, orpoint:match:<id>per entry ofmatch_ids— read STRICTLY from the token mint's advertised channel vocabulary. Because point frames are events (not self-correcting state), the push side adds a resume:points_resume=True(the default) keeps per-match last-seqcursors, REST-catches-up every tracked match on each (re)connect — fetched points are yielded before live frames, so the caller's per-match order never skips — drops any point at or below the cursor (live or fetched), and fills a mid-stream gap synchronously before yielding the frame that revealed it. The optionalon_gap(match_id, expected_seq, got_seq)callback is informational; filling happens regardless. Catch-up covers matches the stream has already seen a point for — a from-start read of a match isiter_match_points.
- REST:
LivePointandPointsPagemodels;PointUpdatejoins the native streamer'sStreamFrameunion and the push streamer'sPushStreamFrameunion.WSTokengrowspoint_match_channel(id)/point_slate_channelbeside the existing helpers — reading the mint's vocabulary and returningNonewhen the point family is not advertised, never guessing a name.list_rankingsgains the Elo companion parameters on both clients:tour,surface,archive_player,min_matches,activity_weeks, passed to the server as given.system="elo"is a valid system — and Elo is never included implicitly: no mode returns Elo records unlesssystemnames it. The Elo leaderboard (the listing mode) requirestour.
- The push point channels are server-gated. They are subscribed only
when the mint's channel vocabulary advertises them; when it does not —
the server's point feature gate is off, or the plan lacks point streams —
points=TrueraisesPushRefusedimmediately, naming that cause. That is an honest refusal, not a retry case: the same key gets the same vocabulary on every reconnect, and each doomed retry would mint a REST token against a refusal that cannot clear. - Read
pbp_coverage(point|game) andquality(clean|revised) before treating any point stream as one-row-per-point truth — they describe the whole match's stream, on every surface that serves it. - Backwards compatible, with one deliberate exception. Without
signals=["points"]/points=Trueboth streamers behave as 1.4.0, and the newlist_rankingsparameters default to absent and send nothing. The exception: aPushStreamthat subscribed a point channel through the rawchannelsescape hatch now receives typedPointUpdateframes where 1.4.0 yielded genericPushFrameobjects — dispatch is by frame type, as documented. The resume machinery (cursors, dedup drops, REST catch-up) stays off for that path; it belongs to thepoints=Trueopt-in only.
PushStream— a client for the high-fan-out push feed. The second of the SDK's two streamers, recommended for continuous / production streaming (no shared connection ceiling). It mints a short-lived token via/ws-token, connects to the push endpoint, subscribes — the whole slate (slate:all, the default) or specific matches (match_ids=[…]) — answers server heartbeats, and reconnects with backoff, minting a fresh token on every reconnect (tokens are never reused). Ergonomics mirrorLiveScoreStream: same constructor shape, iterate for frames, context manager,close().scoreframes are yielded as the sameScoreUpdatethe native feed produces (nested score, ULTRA model fields included); frame types newer than this SDK are yielded as a genericPushFramerather than dropped. Auth/tier refusals surface from the token mint as the SDK's normal exceptions — an ULTRA gate raisesUpgradeRequirednaming the tier — and are never retried; neither are the abuse throttle and the daily cap, which hold for hours; nor are deterministic connect/subscribe refusals — an unknown or unpermitted channel raises the newPushRefused(orUnauthorized) immediately rather than reconnect-looping, since every doomed reconnect would mint a token against the daily quota (only error replies the server itself markstemporaryare retried, and when reconnect attempts run out the terminalAPIConnectionErrorchains the last underlying failure as its__cause__). A per-minute rate-limited mint honours the server'sRetry-Afterbefore reconnecting, the same rule as the REST client's backoff. Steady-state reads are bounded by twice the ping cadence the connect reply advertises (60s fallback), so a half-open TCP connection is treated as dead and reconnected instead of blocking the stream forever. Newline-batched messages are always drained in full — a publication or server ping packed into the same message as a handshake ack is never dropped or left unanswered. The push feed carries score frames only today: the native streamer's opt-inbreak_pointsignal frames do not exist there yet.PushFrameand thePushStreamFrameunion are exported from the package (lazily, like the rest of the WebSocket surface, sowebsocketsstays optional).- New typed exceptions:
PushRefused(a deterministic push-feed connect/subscribe refusal; carries the server's numericcode) andMissingDependencyError(an optional extra is not installed). Both streamers now raiseMissingDependencyError— never retried, and before any REST traffic — when thewebsocketspackage is missing, instead of a baseLiveTennisAPIErrorthat the reconnect loop would retry forever.
- Fully backwards compatible. The native
LiveScoreStreamis untouched apart from the typed (and now non-retried) missing-websocketserror — a subclass of theLiveTennisAPIErrorit raised before;PushStreamis additive.
- History-package docstrings told the truth about only two of the four
package kinds: they implied every non-tape
kindneeds ULTRA and that everyperiodisYYYY-MM. Corrected —rankings/rallyare ULTRA,archiverides the tape entitlement, and the yearly kinds (rally,archive) take a bareYYYYperiod.
- WebSocket score frames parsed as all-None. The wire nests the score
object — a frame is
{"type": "score", "match_id": N, "score": {sets, games, points, server, is_tiebreak, timestamp, win_probability_p1?, danger?}}with the ULTRA model fields INSIDE the score object — butScoreUpdate.from_dictbuilt itsScorefrom the whole frame, assuming the fields sat inline. On real frames every field ofupdate.score(includingsetsand the model fields) came backNone. The nested object is now parsed when present, with the inline read kept only as a defensive fallback, so a flat emitter still parses rather than yielding an all-None score.break_point/break_point_resultframes are flat on the wire and were never affected.
- The point-by-point tape, typed.
get_match_tape(match_id, sequence=)(BASIC, or any History plan) — the chronological score sequence for one match, live matches included.TapeRowis aScorepluspoint_winner(clean-sequence rows only, where the transition is a single attributable point);HistoryTapecarries per-set tiebreak final scores (tiebreaks), modelprofilesand a typedTapeMeta(coverage, point_source, row counts). - In-play statistics.
get_match_statistics(match_id)(ULTRA) — aces, double faults, the serve split, hold/break percentages, break points, service and return points, in the API's two deliberately-unmerged families (derived vs measured) with per-family freshness. - Point-in-time rankings.
list_rankings()in both modes with both gates: the full published table for onesystem(PRO), and per-player as-of records viaplayer=(an id or a list, repeated on the wire, max 50 — ULTRA). TypedRankingRecordincludingprevious_rank(ATP/WTA),rank_movement(ITF) andrating(UTR). - Rally construction and charting (ULTRA).
list_rally_matches()/get_rally_match()over the charted corpus's own id space,get_match_rally()by OUR match id (404not_chartedstays distinguishable from "no such match"),get_charting_player()/get_charting_match()for the Match Charting Project stat families.RallyPointexposes the charter's verbatim string as.notation(the wire calls itraw, which every model already uses for its payload). - Bulk packages.
list_history_packages(kind=, year=)andget_history_package(period, kind=)(PRO; non-tape kinds and theyear=archive listing are ULTRA). - Push-feed token.
get_ws_token()(ULTRA) — typedWSTokenwithws_url,expires_inand the channel vocabulary (match_channel(match_id),slate_channel—slate:all). - Usage.
get_usage()— your tier, limits, today's calls (current to the second) and a 30-day history; the read itself is quota-exempt. - Errors, sharpened. The abuse throttle is its own type:
AbuseThrottled(aRateLimited, so existing handlers keep working) with.retry_at_epoch/.retry_at— and it is never auto-retried, since the retry loop is what earns the 24h block. The daily-cap 429 now surfaces.scope,.limit_per_dayand.resets_at(parsed; an absolute instant derived from a local midnight — never assume a UTC midnight) and is not auto-retried either. - Parameter-aware 403s.
UpgradeRequired.required_tiernow understands endpoints gated by their parameters:/rankingsnames PRO for the listing and ULTRA for per-player mode,/history/packagesnames ULTRA for non-tape kinds andyear=, and/matches?status=completednames BASIC.
- WebSocket score frames carry the model fields.
win_probability_p1anddangerarrive on ULTRA score frames over the stream exactly as on REST — aNonemeans the model had no output for that state, not that the feed withholds them. Docs saying otherwise were wrong and are gone. - README now states the current quota grid (2026-08-06: FREE 100/day, BASIC 1,000/day, PRO 10,000/day, ULTRA 500,000/day) with FREE polling guidance, the full endpoint/tier table, and the five-tour coverage phrasing (ATP, WTA, Challenger, ITF and juniors).
- Fully backwards compatible. Every addition is a new method, a new optional keyword argument, a new optional field, or an exception subclass.
- The results archive (1968–2022). Five new methods (on both clients) over
the licensed historical results corpus — ATP and WTA, main draws, qualifying
and the ITF/futures tiers, ending 2022-12-31 exactly where the
point-by-point tape (2023→now) begins:
list_archive_matches()/get_archive_match()— winner/loser-shaped results with final score, seeds, ranks at the time, and (on the detail read) per-match serve statistics where the era recorded them.event_dateis the tournament START date.list_archive_players()— archive bios: hand, DOB, country, height, career-high rank and the earliest week it was reached.get_archive_career(name)— career aggregates: W-L by surface/level/year, titles, summed serve stats with honest coverage (serve["matches_with_stats"]).get_h2h(p1, p2)— cross-era head-to-head over the archive PLUS our own completed matches, name-keyed; each meeting'swinneris 1|2 of the request. Ambiguous name fragments raiseBadRequestwitherror_code == "ambiguous_name"and the candidate list inexc.body["candidates"](also true ofget_archive_career); all four BASIC-gated reads nameBASICon a 403.
- Tournament catalogue.
list_tournaments()/get_tournament(id)(FREE) — the stable id spaceMatch.tournament_idjoins, withsurface,indoor, curatedcity/country, andcategory(set only where the catalogues agree unambiguously, never derived from the name). - New list filters.
list_matches()takestour,player(an id or a list of ids — repeated on the wire, max 50),from_/to(play-date bounds;fromis a Python keyword, the wire parameter is stillfrom) andcountry(IOC-style lowercase 3-letter codes, asplayer.countryreturns them — not ISO-3166);list_completed_matches()takes those pluscoverage. - New match fields, typed.
Matchgainstour(the same vocabulary the filter accepts),tournament_id,round_code(normalized round — the field to branch on) andwithdrew.Fixturegainsstart_time,player1_id/player2_idandround_code.ListMetagainstotalandhas_more. - New models, all exported:
Tournament,ArchiveMatch,ArchiveParticipant,ArchivePlayerBio,ArchiveCareer,HeadToHead.
- Fully backwards compatible. Every addition is a new method, a new optional keyword argument, or a new optional field following the same forward-compatible rules as the rest.
- Break-point signals over the WebSocket feed.
LiveScoreStreamtakes a newsignals=argument; passsignals=["break_point"]and the stream also yields aBreakPointthe instant a break point arises and aBreakPointResultwhen it resolves, alongside the usualScoreUpdate. Previously the subscribe frame carried nosignalskey andlisten()swallowed every non-scoreframe, so the headline break-point feed was unreachable from this client. Switch on the yielded object's type (or its.typefield) to tell frames apart. BreakPoint,BreakPointResultand theStreamFrameunion are exported from the package (lazily, sowebsocketsstays optional). Both models follow the same forward-compatible rules as the rest — unknown fields are preserved in.rawand readable as attributes.
- Fully backwards compatible. With no
signals(the default) the subscribe frame and everythinglisten()yields are byte-for-byte identical to 1.0.2 — score frames only. - The break-point feed is ULTRA-only, like the rest of the WebSocket surface.
- A 403 on
list_completed_matches()could not be attributed to a tier./history/matchesused to be the entitlement floor, so nothing needed to name a tier for it. With the new FREE tier below it, a free key calling that method got anUpgradeRequiredwith norequired_tier, leaving the caller with the API's bareupgrade_requiredand no idea which plan to buy./historynow maps toBASIC.
- WebSocket backoff never grew against a flapping server. The retry counter
reset on a successful subscribe, so a server that accepted then immediately
dropped the socket pinned the delay at step one forever and
max_reconnect_attemptswas never reached. The counter now resets only after a connection has stayed up for 60s. - WebSocket leaked a socket per failed handshake.
send/recvduring the subscribe exchange were outside anytry, so a recv timeout or an early close escaped with the socket still open — once per reconnect attempt, indefinitely. livetennis --jsonwas honoured by only three of the eight subcommands, andlivetennis live --jsonwas an argparse error. Every command now emits JSON, and--jsonworks before or after the subcommand.format_scoreusedzip, silently dropping the in-progress set when the two per-player game lists differed in length. Now useszip_longest, matching the JavaScript client.livetennisapi.AsyncLiveScoreStreamwas advertised by the lazy importer but never existed, producing a confusingImportError.LiveScoreStreamandScoreUpdateare now correctly exported instead.[tool.mypy] python_version = "3.9"made mypy ≥1.18 refuse to run at all. Removed; ruff'starget-versionalready enforces 3.9-compatible syntax.
First release.
LiveTennisAPIandAsyncLiveTennisAPIcovering all 12 REST endpoints.LiveScoreStream— reconnecting WebSocket live-score feed (ULTRA).livetennisCLI:health,live,match,score,players,fixtures,history,watch.- Typed error hierarchy.
UpgradeRequiredcarries.required_tier;RateLimitedcarries.retry_after. - Automatic retries on 429 and 5xx only, honouring
Retry-Afterwith exponential backoff and jitter. Other 4xx are never retried. paginate()for walking list endpoints on both clients.- Full type hints and a
py.typedmarker.
- Models never reject unknown fields. The API ships additive changes within
v1, so unrecognised fields are preserved in.rawand readable as attributes — a new server field works without upgrading this package. Score.gamesis player-major ([games_p1, games_p2], each a per-set list).Score.games_for_set()reads it safely.