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
| Endpoint | Paginated |
|---|---|
/matches, /teams, /players, /tournaments | Yes — cursor |
/odds?match=<id> (history mode) | Yes — cursor |
/odds (bulk snapshot) | No — limit truncates silently |
/rankings | No — limit truncates the computed board |
Every /{id}/… sub-resource | No — 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.