Help

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 summary

logo_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:

CallModeBehaviour
/oddsBulk snapshotThe latest line per outcome across every open market. Not paginatedlimit truncates silently and meta.next_cursor is always null.
/odds?match=<id>HistoryThe 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:

  1. The key is in the wrong place. It goes in Authorization: Bearer <key> (or ?apiKey= as a fallback). A header sent as Authorization: <key>, without Bearer, won't authenticate.
  2. The key was rotated more than 24 hours ago. Rotation leaves the predecessor working for a 24-hour grace period, then it stops.
  3. 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. Honour Retry-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-Reset says 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.

On this page