Core concepts

Pagination

Forward cursors, why they aren't page numbers, and how to page without missing rows.

List endpoints paginate with an opaque forward cursor, not page/offset.

The loop

Request a page, then pass meta.next_cursor straight back as ?cursor=. Stop when it's null.

import os, requests

BASE, HEADERS = "https://api.esportsodds.gg", {
    "Authorization": f"Bearer {os.environ['ESPORTSODDS_API_KEY']}"
}

def paginate(path, **params):
    cursor = None
    while True:
        if cursor:
            params["cursor"] = cursor
        body = requests.get(BASE + path, params=params, headers=HEADERS, timeout=30).json()
        yield from body["data"]
        cursor = body["meta"]["next_cursor"]
        if not cursor:           # null == last page. The ONLY reliable stop signal.
            return

for team in paginate("/v1/cs2/teams", limit=500):
    print(team["name"])
async function* paginate(path: string, params: Record<string, string> = {}) {
  let cursor: string | null = null
  do {
    const qs = new URLSearchParams(cursor ? { ...params, cursor } : params)
    const res = await fetch(`https://api.esportsodds.gg${path}?${qs}`, {
      headers: { Authorization: `Bearer ${process.env.ESPORTSODDS_API_KEY}` },
    })
    const body = await res.json()
    yield* body.data
    cursor = body.meta.next_cursor
  } while (cursor)
}

Don't stop on a short page

A page shorter than your limit does not mean you've reached the end, and a full page doesn't mean there's more. meta.next_cursor === null is the only correct termination condition.

Which endpoints paginate

EndpointPaginated
/matches, /teams, /players, /tournamentsYes — cursor
/odds?match=<id> (history mode)Yes — cursor
/odds (bulk snapshot)Nolimit truncates silently
/rankingsNolimit truncates the computed board
Every /{id}/… sub-resourceNo — bounded result sets

On an unpaginated endpoint meta.next_cursor is always null, so the loop above terminates after one pass and stays correct either way.

Why cursors, not page numbers

?page=3 is computed as "skip the first N rows", which has two failure modes on live data:

Rows shift under you. Matches are ingested continuously. If three matches are added while you're walking pages, offset=100 now points three rows earlier than it did — so you re-read rows you already have, and rows slide past the boundary unseen. A cursor encodes where you actually were (the row after "Vitality"), so insertions before your position don't move it.

Deep offsets get slow. OFFSET 50000 makes Postgres walk 50,000 rows to discard them. A cursor is an indexed seek, so page 500 costs the same as page 1.

The trade-off is that you can't jump to an arbitrary page. That's a deliberate exchange of a feature almost nobody uses for correctness everybody needs.

What's inside a cursor

An opaque base64url token encoding the last row's sort value, its id, and the ordering it was minted under. Treat it as opaque — decoding it, editing it, or building one by hand is unsupported and will break.

The ordering matters: a cursor is a position within one specific sort. Change sort= mid-walk and reuse an old cursor, and you get 400 invalid_cursor rather than a quietly wrong page. Start again from the first page when you change ordering.

Sorting

/teams, /players and /rankings accept ?sort=, with a - prefix for descending:

curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
  "https://api.esportsodds.gg/v1/cs2/teams?sort=-name&limit=5"

An unrecognised key is a 400 listing the accepted ones — it is never silently ignored. Each endpoint's reference page documents its own keys.

Page size

limit is clamped to the endpoint's maximum rather than rejected, and a missing or nonsensical value falls back to the default. Larger pages mean fewer requests against your monthly quota — if you're walking a whole list, ask for the maximum.

On this page