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 |
|---|---|---|
400 | Bad Request | invalid_parameter, invalid_cursor |
401 | Unauthorized | missing_api_key, invalid_api_key, unauthorized |
403 | Forbidden | forbidden |
404 | Not Found | not_found, unknown_game |
409 | Conflict | conflict |
429 | Too Many Requests | rate_limited, quota_exceeded, trial_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.
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':
// Not transient — no requests left until the window resets. Stop, don't retry.
return halt(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. Path ids are validated as canonical 36-character
hyphenated UUIDs; anything else fails the format check and is reported as "not found" rather than
"bad request". Only query-parameter ids (?match=, ?team=, ?opponent=) return 400.
curl -H "Authorization: Bearer YOUR_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/teams/navi"
# 404 { "error": "not found" } <- a slug in the path is not (yet) resolvable; use ?slug= insteadAn unknown id on a sub-resource returns an empty list, not a 404. /matches/{id}/maps,
/stats, /vetoes, /rounds 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.
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.