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

StatusMeaningCodes you'll see
400Bad Requestinvalid_parameter, invalid_cursor
401Unauthorizedmissing_api_key, invalid_api_key, unauthorized
403Forbiddenforbidden
404Not Foundnot_found, unknown_game
409Conflictconflict
429Too Many Requestsrate_limited, quota_exceeded, trial_quota_exceeded
500Internal Server Errorinternal_error
503Service Unavailableunavailable

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= instead

An 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 4xx as a problem with the request — fix the input or the key; retrying unchanged won't help (except 429, which you retry after a delay).
  • Treat 5xx as transient — retry with exponential backoff.
  • Always branch on the HTTP status first, then read error for a message to log.
  • Honour Retry-After on a 429 rather than picking your own interval — see Rate limits.

On this page