What does an error response look like?
Every error is JSON. Most use the Error schema:
error: a stable machine-readable code. Branch on this, never on the text.detail: a human-readable explanation, when one adds anything.allowed: on a refused enumerated parameter, the values that would have been accepted.
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.
| Status | Meaning | What to do | Clients retry | Defined in the spec |
|---|---|---|---|---|
304 | Not an error. The broadcast state is unchanged since the ETag you sent. | Keep the copy you have. | No | inline on GET /broadcast/match/{matchId}inline on GET /broadcast/live |
400 | A query parameter was refused. The error code names it. | Fix the request. | No | BadRequest (18 operations)inline on GET /eventsinline on GET /players/{playerId}/stoppagesinline on GET /players/{playerId}/injuriesinline on GET /playersinline on GET /players/{playerId}/statsinline on GET /history/incidents/{incidentId}/matches |
401 | The key is missing, unknown or disabled. | Send a valid key. Re-authenticate. | No | Unauthorized (48 operations) |
403 | The key works and the plan does not include this data. | Upgrade the plan. | No | UpgradeRequired (41 operations) |
404 | No such resource, or no data for it yet. | Check the id, or use the availability flags. | No | NotFound (18 operations)inline on GET /matches/{matchId}/scoreinline on GET /players/{playerId}/stoppagesinline on GET /players/{playerId}/injuriesinline on GET /matches/{matchId}/analysisinline on GET /players/{playerId}inline on GET /players/{playerId}/statsinline on GET /markets/{matchId}/pricesinline on GET /history/incidents/{incidentId}/matchesinline on GET /history/archive/matches/{archiveId}/tape |
409 | The webhook limit is reached. | Delete a webhook first. | No | inline on POST /webhooks |
410 | The id was merged or retired. The body forwards you. | Re-point to merged_into. | No | MatchGone (13 operations)PlayerGone (4 operations) |
429 | A rate limit or quota was hit. | Depends on the body. See below. | Per-minute only | RateLimited (48 operations) |
503 | /history/coverage is not built yet. | Try again later. | Yes | inline 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.
| Status | error | Cause | What to do | Named in the spec on |
|---|---|---|---|---|
400 | bad_status | status is not one of the accepted values. | Use a value from allowed in the body. | GET /matches |
400 | bad_request | A non-integer player id, or updated_since sent with status=cancelled. | Fix the parameter. | GET /history/matchesGET /matches |
400 | bad_updated_since | updated_since sent together with from or to. | Send one or the other. | GET /matches |
400 | bad_withdrawn_since | withdrawn_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 |
400 | bad_has_analysisbad_has_marketbad_is_qualifyingbad_points_complete | The flag was something other than true or false. | Send true or false. | GET /fixturesGET /history/matchesGET /matches |
400 | bad_gender | A value other than men or women. | Fix the value. | GET /fixturesGET /history/matchesGET /matches |
400 | bad_country | A country value that is not 3 letters. | Send a 3-letter code. | GET /history/matchesGET /matches |
400 | bad_drawbad_coverage | An unknown value. | Use a value from allowed in the body. | GET /fixturesGET /history/matchesGET /matchesGET /tournaments |
400 | bad_tier | An unknown tier value. The body names the refused values in bad. | Use a value from allowed in the body. | GET /history/matchesGET /matchesGET /tournaments |
400 | bad_tournament_id | A non-numeric tournament_id. | Send the numeric id. | GET /history/matchesGET /matches |
400 | bad_restated_sincebad_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/matchesGET /matches/{matchId}/pointsGET /players |
400 | bad_datebad_surfacebad_levelbad_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 |
400 | doubles_not_supported | A doubles-team id on player stats. | Ask for a singles player id. | GET /players/{playerId}/stats |
400 | bad_sequence | On 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 |
400 | bad_points | An unknown points value. | Send default or complete. | GET /history/matches/{matchId} |
400 | bad_combination | points=complete together with sequence=clean. | Drop one of the two. | GET /history/matches/{matchId} |
400 | points_read_disabled | points=complete where that read is not enabled yet. | Use the default read. Repeating the same request gets the same answer. | GET /history/matches/{matchId} |
400 | points_disabled | The 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 |
400 | bad_after_seq | after_seq is not a non-negative integer. | Send the last_seq of the previous page. | GET /history/matches/{matchId}/pricesGET /matches/{matchId}/points |
400 | bad_cursor | A malformed cursor. | Send the cursor exactly as the previous page returned it. | GET /matches/{matchId}/pricesGET /players/{playerId}/injuriesGET /players/{playerId}/stoppages |
400 | bad_typebad_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 |
400 | bad_sincebad_untilbad_windowbad_kind | A refused time bound, window or kind. | Fix the parameter named by the code. | GET /eventsGET /players/{playerId}/injuriesGET /players/{playerId}/stoppages |
400 | window_too_long | A stoppage or injury window longer than 366 days. | Split the request into shorter windows. | GET /players/{playerId}/injuriesGET /players/{playerId}/stoppages |
400 | bad_format | format was neither jsonl nor csv. | Send jsonl or csv. | GET /history/incidents/{incidentId}/matches |
400 | bad_period | The 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} |
400 | ambiguous_name | A 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.
| Status | error | Cause | What to do | Named in the spec on |
|---|---|---|---|---|
403 | upgrade_required | The 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}/tapeGET /history/coverageGET /history/matchesGET /history/packagesand 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?
| Status | error | Cause | What to do | Named in the spec on |
|---|---|---|---|---|
404 | not_found | No 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}/matchesGET /markets/{matchId}/pricesGET /matches/{matchId}/analysisGET /matches/{matchId}/prices |
404 | no_analysis | The 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 /matchesGET /matches/{matchId}/analysis |
404 | no_market | The match exists and no match-winner market is mapped to it. | Filter with has_market=true instead of probing each match. | GET /history/matchesGET /history/matches/{matchId}/pricesGET /markets/{matchId}/pricesGET /matchesand 1 more |
404 | not_charted | We hold the match and nobody charted it. | Treat it as uncharted. Most matches are. | GET /history/matches/{matchId}/rally |
404 | not_derivable | The 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 |
409 | webhook_limit | The key already has 3 webhooks. | Delete one before adding another. | POST /webhooks |
410 | merged | The 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.
- Per-minute window.
error: "rate_limited"with noscope. The body names the next tier up inupgrade_url,tierandprice. WaitRetry-Afterseconds and send the request again. - Daily quota.
error: "rate_limited"withscope: "day",limit_per_dayandresets_at, the ISO instant the daily window resets. Do not retry. Stop untilresets_at, or upgrade. - Abuse throttle.
error: "abuse_throttled"withretry_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 untilretry_at_epoch.
| Status | error | Cause | What to do | Named in the spec on |
|---|---|---|---|---|
429 | rate_limited | The per-minute window, or with scope: "day" the daily quota. | See the 429 section. | every RateLimited response (48 operations) |
429 | abuse_throttled | A 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) |
503 | coverage_unavailable | The 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 .
| The API answers | Retried | How |
|---|---|---|
429 rate_limited, per-minute window | Yes | Waits Retry-After if sent, else exponential backoff |
| Any 5xx | Yes | Waits Retry-After if sent, else exponential backoff |
| Connection error or timeout | Yes | Exponential backoff |
429 rate_limited with scope: "day" | No | Raises at once |
429 abuse_throttled | No | Raises at once |
| 400, 401, 403, 404 and every other 4xx | No | Raises at once |
- Up to 2 retries by default, so 3 attempts in all. Set
max_retriesin Python ormaxRetriesin JS. 0 turns retries off. - With
Retry-After, the wait is that many seconds, at most 60. - Without it, the wait is 0.5 s doubled on each attempt plus up to 0.25 s of random jitter, at most 10 s. The jitter keeps many clients from retrying in step.
- When the retries run out, the client raises the error for the last answer.
| Status | Class (Python and JS) |
|---|---|
| 400 | BadRequest |
| 401 | Unauthorized |
| 403 | UpgradeRequired |
| 404 | NotFound |
| 429 | RateLimited |
429 abuse_throttled | AbuseThrottled |
| 503 | ServiceUnavailable |
| Other 5xx | ServerError |
| 409, 410 and other statuses | APIStatusError |
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.
| Plan | Per minute | Per day |
|---|---|---|
| FREE | 30 | 100 |
| BASIC | 60 | 1,000 |
| PRO | 300 | 10,000 |
| ULTRA | 600 | 500,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.
| Close code | Reasons | What to do |
|---|---|---|
1013 | connection_limitservice_unavailable | Transient. Back off and reconnect. For connection_limit:per_key, close a connection first or move to the push feed. |
1008 | unauthorizedupgrade_requiredemail_unverifiedclient_blockedbad_jsonno_topics | The request will never be accepted as sent. Fix it, do not retry unchanged. |
1012 | None | The server is restarting. Reconnect with backoff and subscribe again. |
1000 | None | Normal closure: you closed it, or the stream ended. |