Live Tennis API DocsGet a free key
Explore the documentation

Live Tennis API · docs · guide

Errors and rate limits

What does each Live Tennis API error mean, and when should a client retry? Every error is JSON with a stable error code. Retry a per-minute 429 and any 5xx, waiting Retry-After when it is sent. Fix the request on a 400, the key on a 401 and the plan on a 403. The daily-quota 429 and abuse_throttled are never retried.

Status codes and error codes are read from the OpenAPI spec when the page is built. Retry rules checked against livetennisapi 1.8.0 for Python and 1.10.2 for JS on .

What does an error response look like?

Every error is JSON. Most use the Error schema:

Two other shapes exist. A 410 carries MatchMerged or PlayerMerged: error: "merged", merged_into, merged_at and detail. A 404 on /players/{playerId} and its stoppage and injury routes carries PlayerNotFound. It adds detail and see when the id belongs to the results archive. Some codes add their own fields, named in the tables below.

{"error": "upgrade_required"}

Which status codes can the API return?

These are all the non-2xx statuses in the OpenAPI spec, read from it when this page is built. Every endpoint except /health, which takes no key, documents 401.

Every non-2xx status the spec documents
StatusMeaningWhat to doClients retryDefined in the spec
304Not an error. The broadcast state is unchanged since the ETag you sent.Keep the copy you have.Noinline on GET /broadcast/match/{matchId}
inline on GET /broadcast/live
400A query parameter was refused. The error code names it.Fix the request.NoBadRequest (18 operations)
inline on GET /events
inline on GET /players/{playerId}/stoppages
inline on GET /players/{playerId}/injuries
inline on GET /players
inline on GET /players/{playerId}/stats
inline on GET /history/incidents/{incidentId}/matches
401The key is missing, unknown or disabled.Send a valid key. Re-authenticate.NoUnauthorized (48 operations)
403The key works and the plan does not include this data.Upgrade the plan.NoUpgradeRequired (41 operations)
404No such resource, or no data for it yet.Check the id, or use the availability flags.NoNotFound (18 operations)
inline on GET /matches/{matchId}/score
inline on GET /players/{playerId}/stoppages
inline on GET /players/{playerId}/injuries
inline on GET /matches/{matchId}/analysis
inline on GET /players/{playerId}
inline on GET /players/{playerId}/stats
inline on GET /markets/{matchId}/prices
inline on GET /history/incidents/{incidentId}/matches
inline on GET /history/archive/matches/{archiveId}/tape
409The webhook limit is reached.Delete a webhook first.Noinline on POST /webhooks
410The id was merged or retired. The body forwards you.Re-point to merged_into.NoMatchGone (13 operations)
PlayerGone (4 operations)
429A rate limit or quota was hit.Depends on the body. See below.Per-minute onlyRateLimited (48 operations)
503/history/coverage is not built yet.Try again later.Yesinline on GET /history/coverage

The spec documents no other 5xx. The official clients treat any 5xx they get as temporary and retry it.

Which 400 codes are there?

A 400 is never retried. The request is wrong as sent and gets the same answer every time. The last column lists the operations whose spec text names the code.

400 error codes
StatuserrorCauseWhat to doNamed in the spec on
400bad_statusstatus is not one of the accepted values.Use a value from allowed in the body.GET /matches
400bad_requestA non-integer player id, or updated_since sent with status=cancelled.Fix the parameter.GET /history/matches
GET /matches
400bad_updated_sinceupdated_since sent together with from or to.Send one or the other.GET /matches
400bad_withdrawn_sincewithdrawn_since sent with any parameter other than limit and offset, or with an instant that does not parse.Send it alone, as an ISO-8601 instant.GET /matches
400bad_has_analysis
bad_has_market
bad_is_qualifying
bad_points_complete
The flag was something other than true or false.Send true or false.GET /fixtures
GET /history/matches
GET /matches
400bad_genderA value other than men or women.Fix the value.GET /fixtures
GET /history/matches
GET /matches
400bad_countryA country value that is not 3 letters.Send a 3-letter code.GET /history/matches
GET /matches
400bad_draw
bad_coverage
An unknown value.Use a value from allowed in the body.GET /fixtures
GET /history/matches
GET /matches
GET /tournaments
400bad_tierAn unknown tier value. The body names the refused values in bad.Use a value from allowed in the body.GET /history/matches
GET /matches
GET /tournaments
400bad_tournament_idA non-numeric tournament_id.Send the numeric id.GET /history/matches
GET /matches
400bad_restated_since
bad_changed_since
The value is not an ISO-8601 instant. A bare date is refused for restated_since, because a day is not an instant.Send a full UTC timestamp.GET /history/matches
GET /matches/{matchId}/points
GET /players
400bad_date
bad_surface
bad_level
bad_last
A rejected query on player stats. bad_surface and bad_level list the accepted values in allowed.Fix the parameter named by the code.GET /players/{playerId}/stats
400doubles_not_supportedA doubles-team id on player stats.Ask for a singles player id.GET /players/{playerId}/stats
400bad_sequenceOn the tape, a sequence other than raw or clean. On the score endpoint, a sequence that is not an integer.Fix the value.GET /history/matches/{matchId}
GET /matches/{matchId}/score
400bad_pointsAn unknown points value.Send default or complete.GET /history/matches/{matchId}
400bad_combinationpoints=complete together with sequence=clean.Drop one of the two.GET /history/matches/{matchId}
400points_read_disabledpoints=complete where that read is not enabled yet.Use the default read. Repeating the same request gets the same answer.GET /history/matches/{matchId}
400points_disabledThe per-point surface is switched off on the server.Stop polling it. A retry gets the same answer while it is off.GET /matches/{matchId}/points
400bad_after_seqafter_seq is not a non-negative integer.Send the last_seq of the previous page.GET /history/matches/{matchId}/prices
GET /matches/{matchId}/points
400bad_cursorA malformed cursor.Send the cursor exactly as the previous page returned it.GET /matches/{matchId}/prices
GET /players/{playerId}/injuries
GET /players/{playerId}/stoppages
400bad_type
bad_after_id
A refused type or after_id on the events feed.Take after_id from meta.next_cursor. Send event types or stoppages as type.GET /events
400bad_since
bad_until
bad_window
bad_kind
A refused time bound, window or kind.Fix the parameter named by the code.GET /events
GET /players/{playerId}/injuries
GET /players/{playerId}/stoppages
400window_too_longA stoppage or injury window longer than 366 days.Split the request into shorter windows.GET /players/{playerId}/injuries
GET /players/{playerId}/stoppages
400bad_formatformat was neither jsonl nor csv.Send jsonl or csv.GET /history/incidents/{incidentId}/matches
400bad_periodThe period is not YYYY-MM, or not YYYY for the yearly kinds (rally, archive, elo, archive_tape).Fix the period format.GET /history/packages/{period}
400ambiguous_nameA name fragment matched more than one player. The body lists the candidates.Send a player id, or a longer name.GET /h2h

The Error schema also lists bad_tour and bad_year. The spec does not say which endpoint returns them.

401 or 403: is it the key or the plan?

A 401 means the key itself failed: it is missing, unknown or disabled. Send it as Authorization: Bearer <key>, X-API-Key: <key> or ?token=<key>. A 403 means the key works and the plan stops short of the data. It is never a silent empty result.

403 error codes
StatuserrorCauseWhat to doNamed in the spec on
403upgrade_requiredThe key is valid and the plan does not include this data. free_history_taste: "used" means a FREE key has spent its 20 history calls for the calendar month. capability: archive_tape means a core BASIC or PRO key asked for the archive tape.Upgrade the plan (pricing). Retrying does not help.GET /history/archive/matches/{archiveId}/tape
GET /history/coverage
GET /history/matches
GET /history/packages
and 1 more
every UpgradeRequired response (41 operations)

The 403 body names no tier. The Python client's UpgradeRequired.required_tier and the JS client's requiredTier work out the lowest tier from the endpoint you called.

What does a 404 or 410 mean for a stored id?

404, 409 and 410 error codes
StatuserrorCauseWhat to doNamed in the spec on
404not_foundNo such id. On the player routes the body adds detail and see when the id belongs to the results archive.Check the id. If see is present, read the archive lookup it names.GET /history/incidents/{incidentId}/matches
GET /markets/{matchId}/prices
GET /matches/{matchId}/analysis
GET /matches/{matchId}/prices
404no_analysisThe match exists and nothing has been computed for it. The body carries match_id and coverage: "none".Filter the slate with has_analysis=true instead of probing each match.GET /matches
GET /matches/{matchId}/analysis
404no_marketThe match exists and no match-winner market is mapped to it.Filter with has_market=true instead of probing each match.GET /history/matches
GET /history/matches/{matchId}/prices
GET /markets/{matchId}/prices
GET /matches
and 1 more
404not_chartedWe hold the match and nobody charted it.Treat it as uncharted. Most matches are.GET /history/matches/{matchId}/rally
404not_derivableThe incident has no exact per-match derivation. The body carries its window_utc, affected_surfaces and a note saying why.Use the window and surfaces in the body.GET /history/incidents/{incidentId}/matches
409webhook_limitThe key already has 3 webhooks.Delete one before adding another.POST /webhooks
410mergedThe id was merged into another record, or a player id was retired.Replace the stored id with merged_into. If it is null, drop the id.every MatchGone response (13 operations)
every PlayerGone response (4 operations)

Two 404s carry no code of their own. GET /matches/{matchId}/score answers 404 for an unknown match or an upcoming match with no score yet. It does the same for a cancelled match that was never played, and for a settled match with nothing recorded.

The archive tape answers a plain 404 for a result from 1968 to 2012. So does a 2013 to 2022 result that was never reconstructed, or one whose reconstruction did not prove its binding. The three are not told apart.

A 410 is only ever about an id that once existed. An id that was never issued stays a 404.

What does a 429 mean, and when do I retry it?

Every 429 carries a Retry-After header. The body tells you which of three limits you hit, and only the first is worth retrying.

  1. Per-minute window. error: "rate_limited" with no scope. The body names the next tier up in upgrade_url, tier and price. Wait Retry-After seconds and send the request again.
  2. Daily quota. error: "rate_limited" with scope: "day", limit_per_day and resets_at, the ISO instant the daily window resets. Do not retry. Stop until resets_at, or upgrade.
  3. Abuse throttle. error: "abuse_throttled" with retry_at_epoch. A 24-hour block for a client that keeps calling far past its cap. A retry loop that backs off never sees it. Fix the loop and wait until retry_at_epoch.
429 and 503 error codes
StatuserrorCauseWhat to doNamed in the spec on
429rate_limitedThe per-minute window, or with scope: "day" the daily quota.See the 429 section.every RateLimited response (48 operations)
429abuse_throttledA 24-hour block for a client that kept calling far past its cap.Fix the retry loop. Do not retry until retry_at_epoch.every RateLimited response (48 operations)
503coverage_unavailableThe coverage rollup has not been built yet, or cannot be read.Try again after the nightly build.GET /history/coverage

The official clients retry a 503 like any 5xx and give up after 2 retries. The coverage rollup is built nightly, so for coverage_unavailable check again later.

How do the official clients retry?

The Python client (1.8.0) and the JS client (1.10.2) follow the same rules. We read both from source and ran them against a local server on .

What the official clients retry
The API answersRetriedHow
429 rate_limited, per-minute windowYesWaits Retry-After if sent, else exponential backoff
Any 5xxYesWaits Retry-After if sent, else exponential backoff
Connection error or timeoutYesExponential backoff
429 rate_limited with scope: "day"NoRaises at once
429 abuse_throttledNoRaises at once
400, 401, 403, 404 and every other 4xxNoRaises at once
Error classes in both clients
StatusClass (Python and JS)
400BadRequest
401Unauthorized
403UpgradeRequired
404NotFound
429RateLimited
429 abuse_throttledAbuseThrottled
503ServiceUnavailable
Other 5xxServerError
409, 410 and other statusesAPIStatusError

In both clients RateLimited carries retry_after (Python) or retryAfter (JS), and resets_at or resetsAt on a daily 429. AbuseThrottled carries retry_at_epoch or retryAtEpoch.

How should my own client back off?

Do what the official clients do. Retry a 429 only when the body has no scope: "day" and the code is not abuse_throttled. Retry a 5xx or a dropped connection. Wait Retry-After when it is sent, and otherwise back off exponentially with jitter. Cap the number of retries. Never retry a 400, 401, 403, 404, 409 or 410 unchanged.

What are the limits on each plan?

Read from the spec. Each plan includes everything in the plans below it.

Requests per minute and per day by plan
PlanPer minutePer day
FREE30100
BASIC601,000
PRO30010,000
ULTRA600500,000

GET /usage returns your plan, your limits and today's calls, and calling it never uses quota. Refused requests do not spend the daily allowance, so remaining_day does not fall on a 429, 403 or 404. The per-minute window is on the X-RateLimit-* headers of every response. Plans and prices are on the pricing page.

What do the WebSocket close codes mean?

The WebSocket feed sends an error frame, then closes with a code that says what to do next. The close reason repeats the frame's error.

WebSocket close codes
Close codeReasonsWhat to do
1013connection_limit
service_unavailable
Transient. Back off and reconnect. For connection_limit:per_key, close a connection first or move to the push feed.
1008unauthorized
upgrade_required
email_unverified
client_blocked
bad_json
no_topics
The request will never be accepted as sent. Fix it, do not retry unchanged.
1012NoneThe server is restarting. Reconnect with backoff and subscribe again.
1000NoneNormal closure: you closed it, or the stream ended.