Troubleshooting
The behaviours that surprise people most often, and what to do about each one.
Everything here is deliberate API behaviour rather than a bug, but each one has cost somebody an afternoon. If a call isn't doing what you expect, start at the top.
I got a 404 for an id I know exists
Path ids are validated as canonical 36-character hyphenated UUIDs, or as the resource's slug.
Anything that is neither fails the check and comes back as not_found rather than a 400.
# Both of these work
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/teams/natus-vincere"
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/teams/019f23d1-fb5c-7987-b522-47c3a5e72111"Slugs work on teams, players and tournaments. Matches have no slug, so a match id must be a
UUID — get it from /v1/cs2/matches.
A sub-resource returned an empty list instead of a 404
/matches/{id}/maps, /stats, /vetoes, /rounds and /tournaments/{id}/teams don't check that
the parent exists before querying. A match id that doesn't exist returns 200 with data: [],
identical to a real match that has no data of that kind.
So an empty array means "nothing here", not "this exists and is empty". If you need to tell the two
apart, fetch the parent. /players/{id}/stats is the exception — it checks, and 404s.
I fetched a match's detail and lost the team names
A list row carries denormalised names (team_a_name, team_b_name, tournament_name); a
detail row returns bare ids. That trips people who prototype against the list and then switch to
the detail route.
Two fixes, both one request:
# Ask for the related resources inline
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/matches/019f3d18-c15f-7319-81a7-343e8a80a578?include=teams,tournament"
# Or filter the list to that one match if you only need the summarylogo_url is always empty
It is. The field exists but is blanked on every response — we hold no redistribution rights to team marks, so serving the upstream URL isn't something we can do. Don't build UI that expects an image there. This is the one field in the API that is deliberately never populated.
own_rank is null even though the team is clearly ranked
own_rank is opt-in. It costs a window query over the whole leaderboard, so it is only computed
when you ask:
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/teams/natus-vincere?rank=1"?include=rank does the same thing. Without either, own_rank is null — which means "not
requested", not "unranked". A genuinely unranked entity (below the sample floor) is also null, so
if you need to distinguish them, ask for the rank and treat null as unranked.
/teams/{id}/h2h returns a 400
?opponent= is required — a head-to-head needs two teams, and there is no sensible default for the
second. It accepts a UUID or a slug, same as the path id:
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/teams/natus-vincere/h2h?opponent=faze"/odds returned far fewer rows than I expected — or far more
/v1/{game}/odds has two modes, and they behave differently:
| Call | Mode | Behaviour |
|---|---|---|
/odds | Bulk snapshot | The latest line per outcome across every open market. Not paginated — limit truncates silently and meta.next_cursor is always null. |
/odds?match=<id> | History | The full line history for one match, newest first, cursor-paginated. |
If meta.count equals your limit in snapshot mode, there were almost certainly more lines. Raise
limit (max 1000) or scope to a match.
My limit was ignored
limit is clamped, not validated. ?limit=99999 returns the endpoint maximum; ?limit=0,
?limit=-5 and ?limit=abc all return the default. None of them error. Read meta.count for
what you actually received.
Defaults and maxima vary by endpoint — most are 100/500, but /odds is 200/1000 and the team
sub-resources are smaller. Each endpoint's reference page states its own numbers.
Pagination started returning the same rows
A cursor encodes a position within one specific ordering. If you change sort= and reuse a
cursor minted under the previous ordering, that's rejected with invalid_cursor — start again from
the first page. Never construct or modify a cursor by hand; pass meta.next_cursor back verbatim.
Everything returns 401
In order of likelihood:
- The key is in the wrong place. It goes in
Authorization: Bearer <key>(or?apiKey=as a fallback). A header sent asAuthorization: <key>, withoutBearer, won't authenticate. - The key was rotated more than 24 hours ago. Rotation leaves the predecessor working for a 24-hour grace period, then it stops.
- The subscription is paused or cancelled, which suspends its keys.
The 401 body is identical for "no such key" and "revoked key" — deliberately, so the response
can't be used to probe which keys exist.
Requests started failing with 429 partway through the month
Check the code, because the two 429s mean opposite things:
rate_limited— you're going too fast. HonourRetry-After(a second or two) and continue.quota_exceeded— you're out of requests for the month. Retrying cannot help until the reset;X-Quota-Resetsays when. See how often to poll — polling fixtures and odds on the same timer is the usual cause.
A WebSocket client never fires its keep-alive handler
There is no JSON heartbeat message — the server uses native WebSocket pings, which browsers
and mainstream libraries answer automatically. If you wrote a case 'heartbeat' branch, it will
never run, and that's expected. See Live data & WebSocket.
Still stuck?
Every response carries an X-Request-Id. Quote it — with it we can find the exact request in our
logs; without it we're guessing from a timestamp.