Live Tennis API · Schema
Score
One state at one instant. ULTRA adds win_probability_p1 + danger. Order and de-duplicate on `sequence`, never on `timestamp`.
CompanySportsDataReal-timeAPITennis
Properties
| Name | Type | Description |
|---|---|---|
| sets | array | |
| games | arraynull | [games_p1, games_p2]; each a per-set list. NULL means WITHHELD — we hold no state for this match we can stand behind (see `detail`); it never means 0-0, and an empty array is never used to mean "unkno |
| points | array | |
| server | integernull | |
| is_tiebreak | boolean | The current game is a tiebreak. On a DECIDING set played as a 10-point match tiebreak this is true for the whole set, whose `games` slot holds the breaker. |
| win_probability_p1 | numbernull | |
| danger | numbernull | |
| win_probability_p1_model | numbernull | ULTRA, since 2026-09-12. The same model read computed WITHOUT the market-prior anchor — a probability no market price touched. Equals win_probability_p1 on a match the anchor does not apply to, and al |
| win_probability_meta | objectnull | ULTRA, since 2026-09-12. Our own model-regime identifier (`model_version`), the UTC instant the two probabilities were generated (`generated_at`), whether the anchor applies to this MATCH (`market_anc |
| timestamp | stringnull | Our clock, not the court's: stamped in our process when the state was accepted (the same step that assigns `sequence`), then refreshed — at most every 8 s — while the owning source keeps re-asserting |
| accepted_at | stringnull | Since 2026-09-15. The instant we accepted this state (UTC): stamped once at insert, never refreshed by a re-assertion — the clock to difference a latency study against. Present on live score reads and |
| sequence | integernull | Per-match accept counter for this state: 1, 2, 3, … in the order we accepted states for THIS match, gapless within a match — higher is later, equal is the same state re-served, and a gap between two r |
| age_seconds | integernull | Seconds since the score CONTENT last changed (not since we last polled) — it keeps rising between points. Null = change-clock unknown for this row, never "fresh". Always null on the archived final tha |
| stale | boolean | Our own staleness verdict on age_seconds for a LIVE match (threshold currently 120s). Always false on a completed match: its last state is final, not stale. |
| observed_age_seconds | integernull | Seconds since ANY of our sources last CONFIRMED this state — the insert itself, the owning source re-asserting it, or an independent source agreeing with it. The "is anyone still watching" clock, next |
| sources_count | integernull | How many DISTINCT sources inserted or independently confirmed this match's state in the last 60 seconds (our public source window). A count, never a name; it counts sources we accepted a state from or |
| changing_sources_count | integernull | Since 2026-09-24. How many DISTINCT sources CHANGED this match's score in the last 300 seconds — sources that inserted a new state, counted on the content clock alone. A source re-asserting an unchang |
| corroborated | booleannull | Since 2026-09-24. Has an INDEPENDENT source vouched for THIS EXACT state? True when a source other than the one that wrote this state has since presented us the same state; false when none has, includ |
| corroborated_age_seconds | integernull | Since 2026-09-24. Seconds since that independent vouch. Null when corroborated is false (nobody has) or null (unknown). Compare it with age_seconds: a vouch OLDER than the state means somebody agreed |
| detail | string | Present only when something is withheld — plain-language reason, e.g. a null `games` array. |
| verdict | objectnull | Why this read is not the newest state we judged (since 2026-09-23). Null on an ordinary read. Otherwise the legality gate refused a state a stream had already published, and this says which and why. ` |
JSON Schema
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://artifacts.apis.io/livetennisapi-com/json-schema/livetennisapi-com-score-schema.json",
"title": "Score",
"description": "One state at one instant. ULTRA adds win_probability_p1 + danger. Order and de-duplicate on `sequence`, never on `timestamp`.",
"x-generated": "2026-10-28",
"x-method": "derived",
"x-generator": "derive-json-schema.py",
"x-source": "openapi/livetennisapi-com-openapi.yaml#/components/schemas/Score",
"type": "object",
"properties": {
"sets": {
"type": "array",
"items": {
"type": "integer"
}
},
"games": {
"type": [
"array",
"null"
],
"description": "[games_p1, games_p2]; each a per-set list. NULL means WITHHELD — we hold no state for this match we can stand behind (see `detail`); it never means 0-0, and an empty array is never used to mean \"unknown\". A deciding set played as a 10-point MATCH TIEBREAK (super-tiebreak) carries the BREAKER's score in its slot WHILE IT IS BEING PLAYED — `[[6,4,10],[4,6,5]]` is `6-4 3-6 [10-5]`, not a 10-game set — and `is_tiebreak` is true then. Once the breaker is over that set is often published as a one-game `1-0`: take the match winner from `sets`, never from the deciding set's games.",
"items": {
"type": "array",
"items": {
"type": "integer"
}
}
},
"points": {
"type": "array",
"items": {
"type": "string"
}
},
"server": {
"type": [
"integer",
"null"
],
"enum": [
1,
2,
null
]
},
"is_tiebreak": {
"type": "boolean",
"description": "The current game is a tiebreak. On a DECIDING set played as a 10-point match tiebreak this is true for the whole set, whose `games` slot holds the breaker."
},
"win_probability_p1": {
"type": [
"number",
"null"
]
},
"danger": {
"type": [
"number",
"null"
]
},
"win_probability_p1_model": {
"type": [
"number",
"null"
],
"description": "ULTRA, since 2026-09-12. The same model read computed WITHOUT the market-prior anchor — a probability no market price touched. Equals win_probability_p1 on a match the anchor does not apply to, and also on some rows of an anchored match (the anchor shifts the pre-match hold rates, which enter the engine snapped to a 0.01 grid, so a shift smaller than a grid step often solves to an identical value); the per-row test is win_probability_p1 != win_probability_p1_model. Null on rows stamped before it existed (never back-filled)."
},
"win_probability_meta": {
"type": [
"object",
"null"
],
"description": "ULTRA, since 2026-09-12. Our own model-regime identifier (`model_version`), the UTC instant the two probabilities were generated (`generated_at`), whether the anchor applies to this MATCH (`market_anchored` — per match, not per row) and, since 2026-09-15, whether it moved THIS ROW's win_probability_p1 away from the twin (`anchor_effective`). Null on rows stamped before it existed. SEGREGATING A RECORDED CORPUS — cut on the CLOCK, not on the version string. Two changes moved published live probabilities before `model_version` reflected them, so split a corpus on `generated_at` (or the row's `timestamp`) at these two instants: 2026-08-18T08:29:28Z, when the over-inclusive deciding-set rule for lower-tier singles began (some final sets priced as a race-to-ten breaker), and 2026-09-16T07:59:54Z, when the draw-based rule replaced it (main-draw singles priced as a full set — a move of up to 0.20 on affected deciding-set states). `model_version` is NOT a safe discriminator across the second boundary: the `+itfdraw-2026-09-16` suffix first appears on rows generated 2026-09-17T05:04:25Z, 21 h 04 m AFTER the rule itself landed, and in that gap 33,510 states across 351 matches (9,719 states over 113 matches at the ITF M15/W15/W35 levels the rule governs) were computed under the CORRECTED rule while still carrying `markov-population-2026-08-23`. Cutting on the version string — or on a whole-day boundary — mislabels exactly those rows; cutting at 2026-09-16T07:59:54Z does not. Measured against production 2026-09-19. CORRECTION (2026-09-19): both instants were first published, the same day, THREE HOURS LATE — 2026-08-18T11:29:28Z and 2026-09-16T11:02:00Z — because the switch times were read off a column stored in local wall time and labelled UTC. The instants above are the corrected UTC ones. If you cut a corpus on either of the old values, re-cut: everything in the three hours before them is on the wrong side. Earlier than that there is no stamp to cut on at all: no row generated before 2026-09-12T12:08:17Z carries `model_version` or `generated_at`, and the bulk tape packages have no version column, so for those the row `timestamp` is the only discriminator. The `model_version` on an analysis profile (`ModelProfile`) is a different identifier for a different object — the forecast's prompt scaffold — and does not apply to score rows.",
"properties": {
"model_version": {
"type": "string",
"description": "The model regime that produced this row: engine family, calibration key scheme and — since 2026-09-17 — every pricing rule in force, as a `+…` suffix. It is not a safe cut for a recorded corpus across the 2026-09-16 boundary; see this object's description for the two instants to segregate on."
},
"generated_at": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"market_anchored": {
"type": "boolean",
"description": "The market-prior anchor applies to this match: a two-sided pre-match price and a pre-play first score gave the model a shift to its pre-match prior. Per match, not per row."
},
"anchor_effective": {
"type": [
"boolean",
"null"
],
"description": "Since 2026-09-15. This row's win_probability_p1 differs from win_probability_p1_model, i.e. the anchor moved this value. Null when either probability is null."
}
}
},
"timestamp": {
"type": [
"string",
"null"
],
"format": "date-time",
"description": "Our clock, not the court's: stamped in our process when the state was accepted (the same step that assigns `sequence`), then refreshed — at most every 8 s — while the owning source keeps re-asserting the unchanged state, so on a live read it is usually the last-assertion instant, not the accept instant. Never an observation time from outside our process. NOT an ordering key — use `sequence`; for the accept instant use `accepted_at`."
},
"accepted_at": {
"type": [
"string",
"null"
],
"format": "date-time",
"description": "Since 2026-09-15. The instant we accepted this state (UTC): stamped once at insert, never refreshed by a re-assertion — the clock to difference a latency study against. Present on live score reads and push frames, and since 2026-09-24 on history-tape rows too (this description said \"absent on history-tape rows\" until 2026-09-25, which had been wrong since the tape started carrying it — see `HistoryTapeRow.accepted_at`). Null on the archive fallback of a retention-trimmed completed match."
},
"sequence": {
"type": [
"integer",
"null"
],
"description": "Per-match accept counter for this state: 1, 2, 3, … in the order we accepted states for THIS match, gapless within a match — higher is later, equal is the same state re-served, and a gap between two reads is exactly that many missed updates. On /matches/{matchId}/score it moves BACKWARDS in two cases only: we withdrew a state we cannot stand behind and re-served the last good one, or the read deferred to a strictly higher-trust source's fresh state (about 1% of live reads, measured 2026-09-15). On the push feed and the native WebSocket it only ever rises. Null on states that predate the counter — never fabricated. Present on live score reads AND, since 2026-09-26, on history-tape rows too — see `HistoryTapeRow.sequence`. This description said \"absent on history-tape rows, which are already served in order\" until then, and the reasoning was the gap: being served in order does not let you resolve the `replaced_by_sequence` that `GET /matches/{matchId}/withdrawals` hands you, which is a pointer AT a counter and not a position in a list. Since 2026-09-24 a set completion that does not end the match is assigned a sequence only once confirmed — the same source keeps presenting the completed set for 10 s or more, a source at least as trusted presents it, or the same source shows play continuing inside the new set. A live scoring display can render the set-ending frame ON set point, before the point is decided, and step back when it is not won; the unconfirmed frame is now never stored, so no sequence ever carries it and nothing is published as provisional. Until a frame is confirmed every surface keeps serving the set-point state; one nobody confirms within 120 s is discarded. A set completion that ENDS the match is exempt — it follows the completion rules instead. Measured over the 30 days to 2026-09-24 this adds a median 10 s between the point and the published set-end, and on a feed that does not re-present states the set-end lands with the first point of the next set (90th percentile 133 s). It does not cover a set genuinely won and then corrected on court, which can still be published and rewound."
},
"age_seconds": {
"type": [
"integer",
"null"
],
"description": "Seconds since the score CONTENT last changed (not since we last polled) — it keeps rising between points. Null = change-clock unknown for this row, never \"fresh\". Always null on the archived final that `/matches/{matchId}/score` serves for a settled match whose live rows were retired (since 2026-09-20): no clock is claimed for a state nobody watched."
},
"stale": {
"type": "boolean",
"description": "Our own staleness verdict on age_seconds for a LIVE match (threshold currently 120s). Always false on a completed match: its last state is final, not stale."
},
"observed_age_seconds": {
"type": [
"integer",
"null"
],
"description": "Seconds since ANY of our sources last CONFIRMED this state — the insert itself, the owning source re-asserting it, or an independent source agreeing with it. The \"is anyone still watching\" clock, next to `age_seconds`, the \"has anything happened\" clock: a long age_seconds with a small observed_age_seconds is a changeover or set break, not a stall. Null = unknown: no observation clock stored on the row, or the archive fallback on a retention-trimmed completed match (where age_seconds and sources_count are null too). Absent on history-tape rows."
},
"sources_count": {
"type": [
"integer",
"null"
],
"description": "How many DISTINCT sources inserted or independently confirmed this match's state in the last 60 seconds (our public source window). A count, never a name; it counts sources we accepted a state from or that agreed with the stored state, so it can under-count and never over-counts. 1 is ordinary; 0 on a live match past a full minute means nobody we accepted a state from, or who agreed with the stored state, has reported it in the last 60 seconds; 0 on a completed match is normal. Null = unknown (a read blip, or the archive fallback). Present wherever observed_age_seconds is."
},
"changing_sources_count": {
"type": [
"integer",
"null"
],
"description": "Since 2026-09-24. How many DISTINCT sources CHANGED this match's score in the last 300 seconds — sources that inserted a new state, counted on the content clock alone. A source re-asserting an unchanged state is not counted, which is the difference from sources_count: a source stuck re-sending its last state keeps sources_count at 1 for as long as the echo lasts and keeps this at 0. The window is wider than sources_count's on purpose, because a point lands every 30-60 s and a changeover is about 90 s. A cross-confirmation is agreement, not a change, so this can read below sources_count and never above it. 0 on a live match past five minutes means nobody moved the score in that time; 0 on a completed match is normal. Null = unknown (a read blip, or the archive fallback). A count, never a name. Present wherever sources_count is."
},
"corroborated": {
"type": [
"boolean",
"null"
],
"description": "Since 2026-09-24. Has an INDEPENDENT source vouched for THIS EXACT state? True when a source other than the one that wrote this state has since presented us the same state; false when none has, including a brand-new state nobody has had a chance to agree with yet. This is the field to gate a SET-END on. The two counts above are window measures over the match and say nothing about the state in front of you, so a set completion only one source has announced can sit beside a perfectly healthy sources_count — and a set-end is the one state whose correction changes a result rather than a number. EVIDENCE, NOT PROOF: a vouched state can still be corrected, so read corroborated_age_seconds beside it and treat a vouch that is younger than the state it vouches for as the weaker signal it is. When we do take a state back the `sequence` goes down and `verdict` says so. Null = unknown (the archive fallback on a retention-trimmed completed match, where age_seconds and sources_count are null too). A yes/no, never a name. Present wherever sources_count is."
},
"corroborated_age_seconds": {
"type": [
"integer",
"null"
],
"description": "Since 2026-09-24. Seconds since that independent vouch. Null when corroborated is false (nobody has) or null (unknown). Compare it with age_seconds: a vouch OLDER than the state means somebody agreed with this state after it was published; a vouch younger than the state is one that landed late."
},
"detail": {
"type": "string",
"description": "Present only when something is withheld — plain-language reason, e.g. a null `games` array."
},
"verdict": {
"type": [
"object",
"null"
],
"description": "Why this read is not the newest state we judged (since 2026-09-23). Null on an ordinary read. Otherwise the legality gate refused a state a stream had already published, and this says which and why. `superseded_sequence` is the `sequence` of the refused state, the value a stream consumer holds. `reason` is the gate's slug. `kind` is `withdrawn` (discard the refused state and resume from the served one), `deferred` (it may be early and is pending the next accept) or `withheld` (nothing publishable, `games` is null). `safe_to_resume` is true exactly when `kind` is `withdrawn`. The push feed, the WebSocket and webhooks announce the same judgement as a `score_withdrawn` frame naming the same sequence, so a stream consumer never has to poll to learn a state was taken back. That frame carries `type`, `match_id`, `kind`, `superseded_sequence`, `reason`, `safe_to_resume`, `ts` and `published_at`, and no replacement score: resume from the next `score` frame or from a fresh read. Since 2026-09-25 this field also answers for a sequence you name with `?sequence=N`, from the durable record served by `GET /matches/{matchId}/withdrawals` — which is how a consumer that missed the frame recovers it.",
"properties": {
"kind": {
"type": "string",
"enum": [
"withdrawn",
"deferred",
"withheld"
]
},
"superseded_sequence": {
"type": [
"integer",
"null"
]
},
"reason": {
"type": "string"
},
"safe_to_resume": {
"type": "boolean"
}
}
}
}
}
Work with this as data
Every JSON Schema here is available over the APIs.io API and to AI agents over MCP.
MCP server
One button, every client — Claude, Cursor, VS Code and the rest.
https://apis.io/mcp
Tools for schemas
4 MCP tools reach this
find_json_schemasBrowse and filter every JSON Schema in the catalog.apis_io_searchSTART HERE — APIs, providers and tags for one query, each with its total.resolveTurn a domain, URL or GitHub org into the provider it belongs to.find_cohortsEvery scored population of providers in the catalog.
Call it yourself
curl for this page
This JSON Schema
curl "https://apis.io/api/v1/json-schemas/livetennisapi-com-score"
All schemas
curl "https://apis.io/api/v1/json-schemas?limit=25"
Discovery needs no key. Ratings and market analysis are Pro.
Get an API key
Free tier, no form to fill in. Signing in shares your email address with us — we store it to create your key and to recognise you if you sign in with another provider. See our Privacy Policy and Terms.
A second provider on the same verified email joins the account you already have.