The response envelope
Every endpoint returns {data, meta} — what each part means and the two exceptions.
Every endpoint wraps its payload in the same envelope, so a client can unwrap responses without knowing which endpoint produced them.
Lists
{
"data": [ { "…": "…" }, { "…": "…" } ],
"meta": { "count": 2, "next_cursor": "eyJ2IjoiVml0YWxpdHkiLCJpZCI6IjAxOWYyODU4…" }
}| Field | Meaning |
|---|---|
data | The page of results. Always an array on a list endpoint — an empty page is [], never null. |
meta.count | How many rows are in data. This is the page size you actually got, not a total: there is no total-count field, because counting the whole set costs a second query most callers don't want. |
meta.next_cursor | Pass back as ?cursor= for the next page. null on the last page — that is the only reliable end-of-pages signal. |
Single resources
{ "data": { "id": "019f3d18-c15f-7319-81a7-343e8a80a578", "…": "…" } }data is an object and there is no meta — nothing to paginate.
Two exceptions
/health is a bare probe: {"status": "ok"}, no envelope, no API key. It exists to be polled by a
monitor, so wrapping it would only add parsing for something that must stay trivial.
/v1/{game}/matches/{id}/depth uses the single-resource envelope, but its data is an object of
five arrays (weapons, grenades, hitgroups, duels, flashes) rather than one row. Each
array is [] when unavailable, never null.
Why it's shaped this way
Two reasons worth knowing, because both affect how you write your client:
A wrapper leaves room to add metadata without a breaking change. meta.next_cursor could be
added to lists because data was already nested; had lists returned a bare top-level array, adding
pagination would have broken every existing caller.
Empty is never null. An absent list is [] and an absent scalar is null, consistently. You
can range over data without a nil check, and a null always means "no value", never "not sent".
Unwrapping it
type Envelope<T> = { data: T; meta?: { count: number; next_cursor: string | null } }
async function get<T>(path: string): Promise<Envelope<T>> {
const res = await fetch(`https://api.esportsodds.gg${path}`, {
headers: { Authorization: `Bearer ${process.env.ESPORTSODDS_API_KEY}` },
})
if (!res.ok) {
const { error } = await res.json()
throw new Error(`${error.code}: ${error.message} (request ${error.request_id})`)
}
return res.json()
}
const { data: matches } = await get<Match[]>('/v1/cs2/matches?status=live')import os, requests
BASE = "https://api.esportsodds.gg"
HEADERS = {"Authorization": f"Bearer {os.environ['ESPORTSODDS_API_KEY']}"}
def get(path):
r = requests.get(BASE + path, headers=HEADERS, timeout=30)
if not r.ok:
err = r.json()["error"]
raise RuntimeError(f"{err['code']}: {err['message']} (request {err.get('request_id')})")
return r.json()
matches = get("/v1/cs2/matches?status=live")["data"]type Envelope[T any] struct {
Data T `json:"data"`
Meta struct {
Count int `json:"count"`
NextCursor *string `json:"next_cursor"`
} `json:"meta"`
}Next: pagination, which is where meta.next_cursor earns its keep.