Core concepts

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…" }
}
FieldMeaning
dataThe page of results. Always an array on a list endpoint — an empty page is [], never null.
meta.countHow 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_cursorPass 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.

On this page