Errors
HTTP status codes and the neutral JSON error body the API returns.
Errors use standard HTTP status codes and one JSON body shape:
{
"error": {
"code": "invalid_parameter",
"message": "match must be a valid id",
"request_id": "019f3d18-c15f-7319-81a7-343e8a80a578"
}
}Branch on code, never on message. code is a stable identifier that changes only as a
breaking change; message is human prose for your logs and its wording may change at any time.
Messages describe what went wrong plainly and carry no promotional or betting framing.
request_id is also returned on every response — success or failure — as the X-Request-Id
header. Log it. Quoting it turns "a call failed yesterday afternoon" into one log lookup.
Status codes
| Status | Meaning | Codes you'll see |
|---|---|---|
308 | Permanent Redirect | match_merged: a match id retired by a merge; see When two ids turn out to be one match |
400 | Bad Request | invalid_parameter, invalid_cursor |
401 | Unauthorized | missing_api_key, invalid_api_key, unauthorized |
403 | Forbidden | forbidden, sandbox_scope |
404 | Not Found | not_found, unknown_game |
409 | Conflict | conflict |
429 | Too Many Requests | rate_limited, quota_exceeded, trial_quota_exceeded, trial_endpoint_quota_exceeded, sandbox_quota_exceeded |
500 | Internal Server Error | internal_error |
503 | Service Unavailable | unavailable |
The code is deliberately more specific than the status. A 429 that is rate_limited means slow
down and retry in a second or two; a 429 that is quota_exceeded means you have no requests left
this month and retrying sooner cannot help. Treating both as "back off and retry" wastes the rest of
your allowance on requests that are guaranteed to fail.
trial_quota_exceeded and trial_endpoint_quota_exceeded belong to
legacy trials only — subscriptions no longer start with one. The
first is the trial's whole allowance used up. The second is this endpoint's trial ceiling used up
(1,000 requests; 20 on /matches/bulk) while the overall trial allowance still has requests left:
retrying the same endpoint cannot help; calling a different one can.
sandbox_scope and sandbox_quota_exceeded come only from a sandbox key: the first means the
request is outside the sample (it is not counted), the second that the key's 2,000 requests are used. Neither is
worth retrying.
Examples
# A filter value that isn't a valid id
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/odds?match=not-an-id"
# 400 { "error": { "code": "invalid_parameter", "message": "match must be a valid id", … } }# A game that isn't onboarded
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/dota2/matches"
# 404 { "error": { "code": "unknown_game", "message": "unknown game", … } }Handling them in code
const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } })
if (!res.ok) {
const { error } = await res.json()
switch (error.code) {
case 'rate_limited':
// Transient. Honour Retry-After and try again.
return retryAfter(res.headers.get('Retry-After'))
case 'quota_exceeded':
case 'trial_quota_exceeded':
case 'sandbox_quota_exceeded':
// Not transient: no requests left until the window resets (a sandbox key's never does). Stop, don't retry.
return halt(error.message)
case 'trial_endpoint_quota_exceeded':
// Legacy trials only: this endpoint is used up, the rest still works. Don't retry it.
return skipEndpoint(error.message)
case 'invalid_cursor':
// Restart pagination from the first page rather than reusing a stale cursor.
return restartPaging()
default:
throw new Error(`${error.code}: ${error.message} (request ${error.request_id})`)
}
}Cases that surprise people
A few behaviours are deliberate but not what you might guess. Each of these is stable — code against it.
A malformed id is a 404, not a 400. A path id that is neither a canonical 36-character
hyphenated UUID nor — on teams, players and tournaments — a known slug is reported as "not found"
rather than "bad request". Only query-parameter ids (?match=, ?team=, ?opponent=) return
400. On match routes the message says which case it is: a malformed id, or a well-formed id no match
has. On teams, players and tournaments it says no row has that id or slug, and where to search by
name (?q= on the list). Switch on code, not the message.
curl -H "Authorization: Bearer YOUR_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/matches/not-a-uuid"
# 404 { "error": { "code": "not_found", "message": "not found: not a valid match id (match ids are UUIDs)", … } }
# A well-formed id that no match has: "not found: no match has this id"An unknown id on a sub-resource returns an empty list, not a 404. /matches/{id}/maps,
/stats, /vetoes, /rounds, /lineups, /streams and /tournaments/{id}/teams answer 200 with data: [] for a
match or tournament that doesn't exist — they don't check the parent first. If you need to know
the parent exists, fetch it directly. /players/{id}/stats is the exception: it does check, and
404s. A match id retired by a merge is different: every /matches/{id} route redirects it to the
surviving match (308).
An out-of-range limit is clamped, not rejected, and a nonsense one falls back to the default.
?limit=99999, ?limit=0 and ?limit=abc all return a page rather than a 400. Read
meta.count for what you actually got.
An empty result is not an error. A filter that matches nothing, a match with no odds, or a
source you're not entitled to all return 200 with data: []. Branch on emptiness, not on status.
Handling errors
- Treat
4xxas a problem with the request — fix the input or the key; retrying unchanged won't help (except429, which you retry after a delay). - Treat
5xxas transient — retry with exponential backoff. - Always branch on the HTTP status first, then read
errorfor a message to log. - Honour
Retry-Afteron a429rather than picking your own interval — see Rate limits.