# EsportsOdds API documentation
> Developer documentation for the EsportsOdds API: authentication, rate limits, WebSocket updates, and an interactive CS2 endpoint reference.
The complete machine-readable API contract is at https://docs.esportsodds.gg/openapi.yaml — prefer it
for endpoint, parameter and schema detail. This file is the narrative documentation.
---
# Introduction
Source: https://docs.esportsodds.gg/docs
Counter-Strike 2 odds, match data, and player stats — one REST + WebSocket API.
The **EsportsOdds API** gives you a derived market odds line, match data, and player stats for
Counter-Strike 2 through a single, versioned REST API plus a WebSocket channel for update
notifications. It's built for esports media, stats and developer tools, individual bettors and
traders, and fantasy/prediction platforms.
Every response is plain JSON over HTTPS. Odds are decimal and carry an explicit `source`:
`eo_market` is our **de-vigged market line**, combined from multiple bookmakers and exchanges —
never any single book's price, and never naming the contributing books. Our own modeled line
(`eo_model`) joins it once it clears validation. See the methodology note below.
This documentation previously flagged some fields and parameters as intended-but-not-yet-shipped.
That reconciliation finished on 2026-07-26 and nothing is flagged any more — if it's described
here, it works. Two exceptions are called out explicitly where they appear: the
[model odds line](/docs/concepts/model-line) isn't served until it clears its accuracy gate, and
[`logo_url`](/docs/help/troubleshooting#logo_url-is-always-empty) is deliberately always empty.
## Base URL
```
https://api.esportsodds.gg
```
All endpoints are versioned and game-namespaced as `/v1/{game}/{resource}`. CS2 is the only game
populated today (`cs2`), but the path shape is game-agnostic by construction — a second title would
extend it as `/v1/{game}/...` without breaking existing paths.
## Your first request
```bash
curl -H "Authorization: Bearer YOUR_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/matches?status=live"
```
```json
{
"data": [
{
"id": "018f...e2a1",
"game_id": "018f...0001",
"tournament_id": "018f...7c3d",
"team_a_id": "018f...aa10",
"team_b_id": "018f...bb20",
"format": "bo3",
"status": "live",
"stage": "Playoffs",
"scheduled_at": "2026-07-01T18:00:00Z",
"score_a": 1,
"score_b": 0,
"winner_team_id": null
}
],
"meta": {
"count": 1,
"next_cursor": null
}
}
```
> JSON field names are **snake_case** (`id`, `team_a_id`, `scheduled_at`) — exactly as documented
> in the OpenAPI reference. That's deliberate and stable; code against it exactly.
Every list endpoint responds with this `{data, meta}` envelope: `data` is the page of results,
`meta.count` its size, and `meta.next_cursor` an opaque cursor for the next page — pass it back as
`?cursor=` to paginate; it's `null` on the final page. Single resources
(`/v1/cs2/matches/{id}`, teams, players, tournaments) wrap the one object the same way:
`{"data": {...}}`.
## What's here
The full, interactive endpoint reference — generated directly from the API's OpenAPI spec, with a
"try it" panel per endpoint — is in the sidebar, grouped by resource.
## Machine-readable
- **[OpenAPI specification](/openapi.yaml)** — the complete contract: every endpoint, parameter,
schema and a real response example. Point your client generator at it.
- **[llms.txt](/llms.txt)** and **[llms-full.txt](/llms-full.txt)** — this documentation for coding
agents. Any guide is also available as markdown by swapping `/docs/` for `/md/` in its URL.
## Related reading
Worked tutorials and background explainers live on the main site:
- **[Guides](https://esportsodds.gg/guides)** — build a Discord score bot, a live dashboard, fantasy
projections, and more.
- **[Learn](https://esportsodds.gg/learn)** — the concepts behind the data: de-vigging, implied
probability, ADR, KAST, map win rates.
- **[Glossary](https://esportsodds.gg/glossary)** — short definitions of the CS2 and odds terms used
throughout these docs.
## Odds methodology, briefly
This API serves exactly **two odds lines**, both derived, both explicit in the `source` field:
- **`eo_market` — live today.** A de-vigged aggregate: we monitor prices across multiple
bookmakers and exchanges, remove each book's margin, take the median fair probability, and
re-normalize. Published only when at least **two** books contribute; each line carries a
`book_count`. We never republish a single book's price and never name a contributing book.
- **`eo_model` — coming soon.** Our own proprietary modeled line, gated behind an
accuracy-validation step; it appears here once validation clears, with its track record
(calibration + Brier score) published alongside.
Full detail on the [methodology page](https://esportsodds.gg/methodology).
---
# Authentication
Source: https://docs.esportsodds.gg/docs/authentication
Authenticate every request with an API key in the Authorization Bearer header.
Every request to the API is authenticated with an **API key** — an opaque token you create in your
[dashboard](https://esportsodds.gg/signup). Keys are validated on each request; there are no
sessions, cookies, or OAuth flows.
## Passing your key
Pass the key in the **`Authorization: Bearer` header** — the primary scheme:
```bash
curl -H "Authorization: Bearer YOUR_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/matches"
```
For callers that can't set a header, the **`apiKey` query parameter** is a supported fallback
(the same convention the-odds-api.com uses):
```bash
curl "https://api.esportsodds.gg/v1/cs2/matches?apiKey=YOUR_API_KEY"
```
If both are present, the header wins. Either applies to every REST endpoint; the
[WebSocket](/docs/live-data) channel instead uses a short-lived connection ticket minted from
`POST /v1/{game}/ws-token`, so the raw key never appears in a socket URL.
> Keep your key server-side. If you're building a browser or mobile client, proxy requests through
> your own backend rather than shipping the raw key to the client — the same way this site's public
> demo mints a short-lived connection token server-side instead of exposing its key.
## Key format
Keys look like `eo_live_` followed by 48 hex characters:
```
eo_live_4f3c1a08b27e59d6a41f0c8b3e7d2591ac6b04f8e1d37a52
```
The prefix is stable, so a secret scanner or a log filter can match on `eo_live_[0-9a-f]{48}`. A
key is shown **once**, at creation — afterwards the dashboard displays only its prefix and last
four characters, because the raw value is never stored (see below).
Rotating a key from the dashboard issues a replacement and leaves the old one working for a
**24-hour grace period**, so you can roll it through your deployments without downtime. After that
window the old key `401`s.
## Calling from a browser
Requests from a browser are subject to CORS. The API allows a small, fixed set of origins — this
documentation site among them, which is what makes the **Send** button on each endpoint's reference
page work. Your own site's origin is *not* on that list, so a browser app should call the API
through your own backend rather than directly. That's the right pattern anyway: it's the only way
to keep your key off the client.
Where a browser does reach the API, `Access-Control-Expose-Headers` names every
`X-RateLimit-*`/`X-Quota-*` header plus `Retry-After`, so a cross-origin `fetch` can read your
remaining allowance rather than guessing at it.
## How keys are stored
Only a SHA-256 hash of your key is stored — never the raw value. If you lose a key, rotate it from
the dashboard; a compromised database never yields usable keys.
## Missing or invalid keys
| Situation | Response |
| --- | --- |
| No key provided (neither header nor `apiKey`) | `401 Unauthorized` |
| Unknown or revoked key | `401 Unauthorized` |
The `401` body is deliberately identical for "no such key" and "revoked key" — the response never
reveals which case occurred. See [Errors](/docs/errors) for the body shape.
## Usage & billing
Each authenticated request records a usage event against your key. Pricing is a single **$99/mo plan**
(20,000 requests/month, no free tier) — see [pricing](https://esportsodds.gg/pricing). The monthly
quota is **enforced**: past 20,000 requests in a calendar month the API responds `429` until the
month resets (see [Rate limits](/docs/rate-limits)). Your current usage is visible in the dashboard
and in the `X-Quota-*` headers on every response.
---
# Changelog
Source: https://docs.esportsodds.gg/docs/changelog
Notable changes to the EsportsOdds API and documentation.
Notable, developer-facing changes to the API. Breaking changes ship under a new version prefix;
within `v1`, changes are additive.
## 2026-07-26
Error responses moved from `{"error": "message"}` to a coded envelope. If you match on the old
flat string, update before this reaches you — everything else in this release is additive.
- **Coded error envelope.** Every non-2xx response now returns:
```json
{ "error": { "code": "invalid_cursor", "message": "invalid cursor", "request_id": "019f…" } }
```
`code` is a stable identifier you can switch on; `message` is prose and explicitly not a contract.
This exists because the flat shape gave you nothing machine-readable beyond the HTTP status, which
collapses a dozen distinct 400s into one bucket — so clients were left string-matching text we
were free to change. The codes that matter most: a `429` is now distinguishably `rate_limited`
(retry after a moment) or `quota_exceeded` (retrying cannot help until the window resets). See
[Errors](/docs/errors) for the full list and a worked handler.
- **`X-Request-Id` on every response**, success or failure, and repeated as `error.request_id`. Log
it — quoting it turns a support question into a single log lookup. A panic now also returns the
envelope rather than an empty body.
- **Slug addressing.** A path `{id}` accepts either a UUID or the resource's slug on teams, players
and tournaments: `/v1/cs2/teams/natus-vincere` works. `?opponent=` on head-to-head accepts both
too. Matches have no slug.
- **`?include=` on match detail.** `?include=teams,tournament,odds` embeds the related resources, so
a match view is one request instead of four. Omit it and the response is byte-for-byte what it was.
- **`?sort=` on `/teams`, `/players` and `/rankings`**, with a `-` prefix for descending. Previously
the parameter was accepted and silently ignored; an unrecognised key is now a `400` listing the
accepted ones. Cursors are bound to the sort they were minted under — cursors issued before this
change keep working on the default order.
- **CORS for browser callers.** The rate-limit and quota headers are now readable cross-origin via
`Access-Control-Expose-Headers`, and the interactive "try it" panel in these docs executes real
requests.
- **Documentation.** Every operation, parameter and schema field is now documented, every endpoint
carries a real response example captured from production, and the reference is grouped by resource
rather than by URL segment (old reference URLs redirect). The spec is published at
[/openapi.yaml](/openapi.yaml). Nothing in these docs describes unshipped behaviour any more — the
fields previously flagged as intended-but-not-yet-live are all shipped.
## 2026-07
- **Per-match data availability** — matches now carry `data_available`, telling you which
sub-resources actually hold data for that match: `{maps, stats, vetoes, rounds, depth}`. Each key
is the sub-resource's own path segment, so `GET /v1/{game}/matches/{id}/{key}` returns rows if and
only if that key is true — no more fetching an endpoint to discover it's empty. `?has=rounds,depth`
filters a list the same way (comma-separated, AND-ed). Coverage is not uniform: round-by-round and
per-player depth come from a single upstream, so matches it doesn't cover carry results and little
else. The flags describe what is stored now, not a forecast — a scheduled match reads all-false and
flips as data lands. `/coverage` also now declares `round_stats` and `player_depth`, two
capabilities that were being served but never listed.
- **Roster history** — `GET /v1/{game}/teams/{id}/roster-history` returns the five a team actually
fielded in each recent completed match, newest first, with the roster changes between them
(`players_in`/`players_out`, `has_debutant`, `days_since_prev`). Read `continuity` before
interpreting `changed_count`: a `reset` is an organisation rename or full rebuild, not five
stand-ins, and matches where a team used more than five players across maps are omitted rather
than guessed at.
- **Market odds line live** — `GET /v1/{game}/odds` serves the derived `eo_market` line: a
de-vigged aggregate combined from multiple bookmakers and exchanges (published only with ≥2
contributing books; `book_count` on every line; contributing books are never named).
- **WebSocket tickets** — `POST /v1/{game}/ws-token` mints a short-lived (60s) signed ticket for
the WebSocket handshake (`wss://…/v1/ws?token=…`), so raw API keys stay out of URLs. The
`?apiKey=` handshake fallback is deprecated and will be removed.
- Initial `v1` surface for Counter-Strike 2: `matches`, `odds`, `teams`, `tournaments`, `players`,
`rankings`, `coverage`.
- [WebSocket](/docs/live-data) channel for update notifications. Metering is settled: a per-key
concurrent-connection cap (5 on the standard plan), one metered request to mint the ticket, and no
per-message charge.
- Interactive API reference generated directly from the OpenAPI spec.
## Coming soon
- **Modeled odds** — our own proprietary line (`eo_model` source) is in development, gated behind
an accuracy-validation step. It will be documented and served here once that clears, with its
track record (calibration + Brier score) published alongside.
- Additional markets beyond match winner, as their data clears the same quality bar.
- Additional game namespaces beyond `cs2`, following the same `/v1/{game}/{resource}` convention.
---
# The data model
Source: https://docs.esportsodds.gg/docs/concepts/data-model
How games, tournaments, matches, maps, rounds and players relate — and which endpoint returns each.
Everything in the API hangs off six entities. Knowing which one owns a fact tells you which endpoint
to call.
```
Game (cs2)
└─ Tournament /v1/cs2/tournaments
└─ Match /v1/cs2/matches
├─ Map result /v1/cs2/matches/{id}/maps
│ └─ Round /v1/cs2/matches/{id}/rounds
├─ Veto step /v1/cs2/matches/{id}/vetoes
├─ Player stats /v1/cs2/matches/{id}/stats
└─ Depth /v1/cs2/matches/{id}/depth
Team /v1/cs2/teams
Player /v1/cs2/players
```
## The entities
**Game** is the namespace segment in every path. `cs2` is the only populated title today, but the
shape is game-agnostic by construction — a second title extends the same paths rather than replacing
them. A game that isn't onboarded returns `404 unknown_game`.
**Tournament** is an event. It has dates, a tier, a prize pool and a region; its `status`
(`upcoming`/`ongoing`/`finished`) is **derived from its dates**, not stored, so a tournament with no
known start date has a `null` status and is excluded by any status filter.
**Match** is one fixture between two teams. `format` (`bo1`/`bo3`/`bo5`) tells you how many maps to
expect at most. The two sides are `team_a` and `team_b` — stable slots, not home/away; CS2 has no
home side.
**Map result** is one map within a series, numbered from 1. **Rounds** are per-team rows within a
map — two per round, one for each side.
**Team** and **Player** are the competitors. A player's `team_id` is their *current* team; the team
they played for in a specific match is on that match's stat line, which is not always the same.
## Which id joins to which
Everything is a UUIDv7. The joins that matter:
- A match carries `team_a_id`, `team_b_id`, `tournament_id`.
- A map result, veto, stat line, round and depth row all carry `match_id`.
- A stat line and a round both carry `map_number`, so they join to a map result on
`(match_id, map_number)`.
- A stat line's `map_number` is **`null` for the whole-match aggregate**. Filter for `null` to avoid
double-counting a player across per-map and aggregate rows.
## List rows vs detail rows
A **list** row is denormalised for display: a match from `/v1/cs2/matches` carries `team_a_name`,
`team_b_name`, `team_a_short`, `team_b_short` and `tournament_name` alongside the ids.
A **detail** row is not: `/v1/cs2/matches/{id}` returns bare ids. Ask for the related resources with
`?include=teams,tournament` rather than making three more requests. This catches people who
prototype against the list then switch to detail — see
[fetching a full match](/docs/how-to/fetch-a-full-match).
## What isn't exposed
Markets and market types are modelled internally but have no endpoint of their own — you get a
`market_id` on each odds line, which groups lines belonging to the same proposition. Per-book
bookmaker prices are never served in any form; see [the market line](/docs/concepts/market-line).
---
# The response envelope
Source: https://docs.esportsodds.gg/docs/concepts/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
```json
{
"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
```json
{ "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
```ts
type Envelope = { data: T; meta?: { count: number; next_cursor: string | null } }
async function get(path: string): Promise> {
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('/v1/cs2/matches?status=live')
```
```python
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"]
```
```go
type Envelope[T any] struct {
Data T `json:"data"`
Meta struct {
Count int `json:"count"`
NextCursor *string `json:"next_cursor"`
} `json:"meta"`
}
```
Next: [pagination](/docs/concepts/pagination), which is where `meta.next_cursor` earns its keep.
---
# Filtering and lookups
Source: https://docs.esportsodds.gg/docs/concepts/filtering
The filters each list accepts, how they combine, and resolving a name to an id.
Every list filter is applied server-side and costs the same single request as an unfiltered call —
so filtering is always cheaper than fetching broadly and discarding rows client-side.
## Filters combine with AND
```bash
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/matches?status=completed&date_from=2026-07-01&date_to=2026-07-31&has=rounds"
```
There is no OR, no negation and no field-expression syntax. Every filter is an exact match on one
field, and they narrow together.
## What each list accepts
| Endpoint | Filters |
| --- | --- |
| `/matches` | `status`, `tournament`, `team`, `date_from`, `date_to`, `has` |
| `/teams` | `slug`, `region` |
| `/players` | `team`, `slug`, `role`, `ids` |
| `/tournaments` | `slug`, `tier`, `region`, `year`, `status` |
| `/odds` | `match`, `source` |
| `/rankings` | `type`, `region`, `role` |
Values are exact and case-sensitive. Two that catch people out:
- **`region` is a full name**, not a code: `Europe`, `North America`, `South America`, `Asia`,
`CIS`, `Oceania`, `Africa`. `?region=EU` matches nothing.
- **`role`** is one of `AWP`, `IGL`, `Lurker`, `Rifler`, `Support` — and only about 14% of players
have one on file, so the filter excludes every player whose role is simply unknown.
## Turning a name into an id
You rarely start with a UUID. Two ways to get one:
**Address the resource by slug directly.** Teams, players and tournaments accept a slug wherever a
path id is expected:
```bash
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/teams/natus-vincere"
```
**Or filter the list by slug** when you want the row in list shape:
```bash
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/teams?slug=natus-vincere"
```
`?slug=` returns the single matching row — or an empty page — in the normal list envelope, and never
depends on where the row falls in the paging order. Matches have no slug, so a match id must be a
UUID.
See [resolving by slug](/docs/how-to/resolve-by-slug) for the pattern in code.
## Batch lookups
`/players?ids=` takes 1–500 comma-separated ids in one request. After reading a match's stat lines
you have ten player ids; resolve them all at once rather than ten times:
```bash
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/players?ids=019f28e1-e6ae-7032-8255-4c7e26548918,019f28e1-e6af-730c-a332-402b928fe87f"
```
Outside the 1–500 range is a `400`.
## Dates
`date_from` and `date_to` are `YYYY-MM-DD`, compared against `scheduled_at` in UTC, and **both are
inclusive of the whole day** — `date_to=2026-07-01` includes everything on 1 July. A malformed date
is a `400`, never a silently ignored filter.
---
# IDs, slugs and time
Source: https://docs.esportsodds.gg/docs/concepts/ids-and-time
UUIDv7 identifiers, human-readable slugs, and the timestamp formats the API uses.
## Identifiers
Every resource id is a **UUIDv7** — canonical, 36 characters, lowercase and hyphenated:
```
019f3d18-c15f-7319-81a7-343e8a80a578
```
UUIDv7 embeds a millisecond timestamp in its leading bits, so ids sort roughly by creation time.
That is an implementation detail, not a contract: don't parse a timestamp out of an id or infer
ordering from one. It does mean ids from the same era share a visible prefix (`019f…`), which is a
handy sanity check that you're looking at our data.
Ids are validated by **format**, not by version — a v4 UUID is accepted as well-formed and then
simply matches nothing.
## Slugs
Teams, players and tournaments also have a `slug`: lowercase, hyphenated, unique within the game.
```
natus-vincere s1mple pgl-major-singapore-2026
```
A slug works anywhere a path id does, so you can address a resource straight from a name you already
have. Matches have no slug.
Slugs are **stable but not guaranteed permanent** — an organisation rename can change one. If you're
storing a reference for the long term, store the UUID and treat the slug as a display convenience.
## Timestamps
Every timestamp is **RFC 3339 in UTC**, with a trailing `Z`:
```json
{ "scheduled_at": "2026-07-04T15:36:36.403882Z" }
```
Sub-second precision appears where the source has it (odds captures) and is absent where it doesn't
(scheduled fixtures). Parse with a standard RFC 3339 parser rather than a fixed-width format string
— `time.Parse(time.RFC3339, …)`, `datetime.fromisoformat(…)`, `new Date(…)` all handle both.
There is no timezone parameter. Everything is UTC; convert at the display layer.
## Date-only parameters
Filters that take a day rather than an instant use `YYYY-MM-DD`:
```
?date_from=2026-07-01&date_to=2026-07-31
```
Both bounds are inclusive of the entire day in UTC. `X-Quota-Reset` is the one header that carries a
full RFC 3339 instant rather than a date.
## Nulls
A nullable field is `null`, never omitted and never an empty string — except where the API
deliberately uses `""` for "no value on file" (`short_name`, `real_name`, `nationality`). Arrays are
`[]` rather than `null` when empty. So `null` always means "no value", and you never have to
distinguish "absent" from "explicitly nothing".
---
# How the market line is computed
Source: https://docs.esportsodds.gg/docs/concepts/market-line
eo_market — de-vig, median, renormalise, and the coverage rule behind every published line.
`source: "eo_market"` is a **derived** line: an aggregate of several bookmakers and exchanges with
each one's margin removed. It is not any single book's price, and no contributing book is ever
named.
## The steps
**De-vig each contributor.** A bookmaker's prices imply probabilities summing to more than 1 — the
excess is their margin. Each contributor's prices are converted to probabilities and normalised so
they sum to 1, recovering that book's implied fair view.
**Take the median per outcome.** Across contributors, the median fair probability — not the mean.
A median is resistant to one book being briefly mispriced or stale, which is exactly the failure
mode that matters here.
**Renormalise.** Medians taken independently per outcome don't necessarily sum to 1, so the set is
rescaled until it does.
**Convert back to a price.** `price = 1 / probability`, published as decimal odds.
The consequence you can verify: **the implied probabilities of a market's outcomes sum to almost
exactly 1**. Sum `1/price` across a market's outcomes and you should land within rounding distance
of 1.0 — a raw bookmaker market would sum to noticeably more.
## The coverage rule
A line is published **only when at least two independent contributors priced that outcome**. One
contributor is not a market, so no row is emitted at all rather than a single book's price wearing
an aggregate's label.
`book_count` reports how many contributed — always present on `eo_market`, never below 2, typically
2–4. Treat it as a confidence signal: a line built from 2 contributors is thinner evidence than one
built from 4.
## Why no book is named
Naming contributors would make the line a price-comparison surface, which is a different product
with a different legal posture. The published line is a *descriptive statistic about the market*,
not a shopping list. So there is no bookmaker name, no per-book price and no "via" attribution
anywhere in the API — by construction, not by filtering.
## What it is and isn't
`eo_market` describes **what the market thinks**. It carries no accuracy gate because it isn't a
forecast — it's a measurement of prices that existed. It is the right baseline to compare a model
against, and the right input for anything that needs a market-implied probability.
It is *not* our opinion. That's the [model line](/docs/concepts/model-line), which is a separate
source and held to a much stricter standard.
## Freshness
Lines are appended **only when something changes**. Consecutive captures can be far apart without
the data being stale — a gap means the price held. Judge freshness by `captured_at` on the newest
row for a market, and see [odds movement](/docs/concepts/odds-movement) for reading a series.
For the conceptual background on de-vigging, the marketing site has a longer explainer at
[esportsodds.gg/learn/devig-fair-odds](https://esportsodds.gg/learn/devig-fair-odds).
---
# Markets and outcomes
Source: https://docs.esportsodds.gg/docs/concepts/markets-and-outcomes
How an odds line is structured — markets, outcomes, lines and prices.
An odds line answers one question about one proposition. Four fields locate it.
## The shape
```json
{
"market_id": "019f28da-10a5-744c-8281-6bc1f97f17d3",
"source": "eo_market",
"outcome_key": "away",
"label": "DONSTU",
"line": null,
"price": 13.39,
"captured_at": "2026-07-04T15:36:36.403882Z"
}
```
**`market_id`** identifies the proposition — "who wins this match". Every outcome of the same
proposition shares it, so grouping a page of lines by `market_id` reconstructs whole markets.
**`outcome_key`** is the machine-readable side: `home`, `away`, `over`, `under`, and occasionally a
correct-score literal in `a_b` form — `2_1`, `0_3` — with an **underscore**, not a hyphen.
**`label`** is the human name for the same thing (a team name). Switch on
`outcome_key`; display `label`.
**`line`** is the threshold that defines a market variant — `2.5` for a total, `-1.5` for a
handicap. It is `null` on a moneyline, where there is no threshold. Two rows differing only by
`line` are genuinely different markets.
**`price`** is **decimal odds**, always. No American, no fractional, no implied-probability field.
## Decimal odds
A decimal price is the total return per unit staked, so it converts to an implied probability by
reciprocal:
```
implied probability = 1 / price
```
`price: 1.80` → 0.556. `price: 13.39` → 0.075.
Because our published line is already de-vigged, **the implied probabilities of a market's outcomes
sum to almost exactly 1** — unlike a raw bookmaker price, where they sum to more. See
[the market line](/docs/concepts/market-line) for what that means and why.
`price` is a float from real market data and can be large for a heavy underdog. It is never zero,
but defensive code should still avoid dividing without a check.
## Participants
When an outcome is about a competitor, `participant_type` (`team` or `player`) and `participant_id`
point at it, so you can join to the entity without parsing `label`. Both are `null` on outcomes that
aren't about a competitor, such as a total.
## Match markets and tournament markets
An odds line carries **either** `match_id` **or** `tournament_id`, never both. A tournament-scoped
line is an outright — "who wins the event" — rather than a fixture price. Check which is populated
before assuming you can join to a match.
## What you won't find
There is no endpoint listing markets or market types. You get `market_id` on each line, which is
enough to group them; a market catalogue isn't exposed.
And no per-book prices, ever — not as a field, not as a `source`, not as an attribution. Every line
you receive is one of exactly two derived sources. That's a product decision, not an omission; see
[the market line](/docs/concepts/market-line).
---
# Match lifecycle
Source: https://docs.esportsodds.gg/docs/concepts/match-lifecycle
What populates when, from scheduled through live to completed.
A match moves through three states, and which fields are trustworthy depends on where it is.
## The states
| `status` | Meaning | Scores | `winner_team_id` |
| --- | --- | --- | --- |
| `scheduled` | Not started | `null` | `null` |
| `live` | In progress | Partial | `null` |
| `completed` | Finished | Final | Set, once the result is ingested |
| `cancelled` | Called off — will not be played | `null` | `null` |
A `cancelled` match keeps its id and any odds history captured while it was scheduled, and stays
fetchable by id — but it never appears in upcoming listings. There is no `postponed` state: a
rescheduled fixture keeps `scheduled` and its `scheduled_at` moves.
`scheduled_at` is the **planned** start. There is no separate actual-start field, so a match that
started late still shows its scheduled time. Treat it as the fixture's slot, not as evidence of when
play began.
## What appears when
Fixtures are created ahead of time with teams, tournament and format. Odds start being captured as
soon as a market opens — often days before. Everything else lands as the match plays out or shortly
after:
- **Map results** appear per map as each finishes.
- **Round stats** and **player stats** arrive with the map they belong to.
- **Vetoes** are known before play starts, since the pick/ban happens first.
- **Depth** (weapons, grenades, hitgroups, duels, flashes) is the last and least universal layer.
A `completed` match therefore does **not** guarantee every sub-resource exists.
## Don't guess — read `data_available`
Every match row carries flags for exactly which sub-resources have data:
```json
"data_available": { "maps": true, "stats": true, "vetoes": true, "rounds": true, "depth": false }
```
Use them instead of speculatively fetching five endpoints and discarding empty responses. Each
avoided request is one you keep in your [monthly quota](/docs/rate-limits#how-often-to-poll).
You can also filter the list to matches that have what you need:
```bash
# Only completed matches with round-level AND per-player depth data
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/matches?status=completed&has=rounds,depth&limit=20"
```
`has=` accepts any comma-separated subset of `maps,stats,vetoes,rounds,depth`, AND-ed together. An
unknown value is a `400` rather than a silently ignored filter.
## Settled odds
Once a match settles, its odds lines gain `is_winner` (which outcome came in) and one line per
outcome is flagged `is_closing` — the last price captured before `scheduled_at`. The closing line is
the standard benchmark for evaluating a forecast, so if you are scoring predictions, that's the row
you want. See [odds movement](/docs/concepts/odds-movement).
## How fast it changes
Measured over a recent 7-day window, the whole fixture corpus takes about **6 writes an hour** —
matches simply don't change often. Odds move roughly **110× faster**. Polling both on the same timer
is the single most common way to burn a monthly quota; the arithmetic is in
[how often to poll](/docs/rate-limits#how-often-to-poll).
---
# The model line and its validation gate
Source: https://docs.esportsodds.gg/docs/concepts/model-line
eo_model, why it isn't served yet, and how to read the accuracy we publish.
`source: "eo_model"` is our own forecast, as distinct from the
[market line](/docs/concepts/market-line), which describes what bookmakers think.
## It is not served yet
Requesting it returns an empty page:
```bash
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/odds?source=eo_model"
# 200 { "data": [], "meta": { "count": 0, "next_cursor": null } }
```
That's a `200`, not an error — the source is valid, there is simply nothing to return until the
model clears its accuracy gate. Write your client to tolerate an empty page for this source; it will
start returning rows without any API change on your side.
## Why a gate exists at all
The market line needs no accuracy gate: it is descriptive, a measurement of prices that existed. A
*forecast* is a claim, and shipping a claim we haven't tested would be the easiest thing in this
product to get wrong quietly. So the model is held behind a published, numeric bar.
## The bar
Accuracy is scored with the **Brier score** — the mean squared error of forecast probabilities.
Lower is better; 0 is perfect. For a two-way market, always guessing 50/50 scores **0.25**, so 0.25
is the no-skill baseline a forecast has to beat.
The gate requires the **upper bound of the confidence interval** to sit below that baseline, not
just the point estimate — a lucky score over a small sample with a wide interval does not pass — and
a minimum sample of several hundred settled matches.
## Reading the published metrics
`/v1/{game}/model/metrics` is the transparency surface, and it is **not** gated — you can read the
model's score before the model itself is served:
```json
{
"market": "match_winner",
"brier": 0.2814810548009245,
"brier_skill_score": -0.125924219203698,
"settled_matches": 514,
"model_version": "v0-placeholder",
"gate_passed": false
}
```
That is the honest current state, not an illustration. Reading it:
- `brier` **0.281** is *worse* than the 0.25 baseline.
- `brier_skill_score` is `1 − brier/0.25`, so positive means better than a coin flip. **−0.126**
means worse.
- `gate_passed: false` — so nothing is served.
We publish this rather than a headline number precisely because it currently says the model isn't
good enough. When that changes, the same endpoint will say so with the same fields.
## What to build now
Treat `eo_model` as a source that may start returning rows later. Two habits make that free:
1. Don't hardcode `source=eo_market` in a way that would need a rewrite — filter explicitly, and
handle an unfamiliar `source` value by ignoring it rather than erroring.
2. Colour or label model data distinctly from market data in any UI. They answer different
questions and shouldn't be visually merged.
---
# Odds movement
Source: https://docs.esportsodds.gg/docs/concepts/odds-movement
open_price, delta_since_open and is_closing — reading a price series correctly.
Odds lines are a time series. Three fields let you read movement without fetching the whole history.
## The movement fields
| Field | Meaning |
| --- | --- |
| `open_price` | The earliest captured price in this `(market, outcome, source)` series. |
| `delta_since_open` | `price − open_price`. Positive means the price drifted out (less likely); negative means it shortened. |
| `is_closing` | True on the last line captured before `scheduled_at` — the closing price. |
| `is_winner` | Which outcome came in. `null` until the market settles. |
`open_price` and `delta_since_open` are **computed per response**, not stored — so they're correct
on every row without you fetching the series first.
## Append-on-change
A new row is written **only when the price changes**. This is the single most important thing to
know about the series, and it has a consequence people get wrong:
A line captured eleven hours ago is not stale — it means the price hasn't moved in eleven hours,
which is itself information. A recency check that discards "old" lines will discard perfectly
valid prices for quiet markets.
Judge whether data is flowing by whether *any* market is updating, not by the age of one line.
## Snapshot vs history
The same endpoint serves two different things:
```bash
# Snapshot: latest line per outcome, across every open market. NOT paginated.
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/odds"
# History: every captured line for one match, newest first, cursor-paginated.
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/odds?match=019f3d18-c15f-7319-81a7-343e8a80a578"
```
In snapshot mode `limit` truncates silently and `meta.next_cursor` is always `null` — if `meta.count`
equals your limit, assume there were more.
## The closing line
`is_closing` marks the last price before the scheduled start. It's the standard benchmark for
evaluating a forecast: comparing a prediction against the closing line asks "did this beat where the
market ended up", which is a much harder and more meaningful test than comparing against an opening
price.
To collect closing lines for settled matches, walk history mode per match and take the flagged rows;
they also carry `is_winner`, so you have prediction and outcome in one place.
## Reading a series
```python
lines = get(f"/v1/cs2/odds?match={match_id}&source=eo_market&limit=1000")["data"]
# History comes back newest-first; reverse for chronological order.
series = sorted(lines, key=lambda l: l["captured_at"])
for l in series:
implied = 1 / l["price"]
print(f"{l['captured_at']} {l['label']:20} {l['price']:6.2f} p={implied:.3f}"
f" {'CLOSE' if l['is_closing'] else ''}")
```
Group by `(market_id, outcome_key, line)` if a match has more than one market — otherwise you'll
interleave a moneyline with a handicap and see movement that isn't there.
---
# Pagination
Source: https://docs.esportsodds.gg/docs/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`.
```python
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"])
```
```ts
async function* paginate(path: string, params: Record = {}) {
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)
}
```
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=` (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:
```bash
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](/docs/rate-limits#how-often-to-poll) — if you're walking a whole list, ask for the
maximum.
---
# Enum reference
Source: https://docs.esportsodds.gg/docs/cs2-data/enums
Every fixed value the API returns or accepts, in one place.
Every enumerated value in the API. All are lowercase unless shown otherwise, and all are exact and
case-sensitive when used as a filter.
## Match
| Field | Values |
| --- | --- |
| `status` | `scheduled` · `live` · `completed` · `cancelled` |
| `format` | `bo1` · `bo3` · `bo5` |
| `stage` | **Not an enum** — free text (`Quarterfinal`, `Group A`, `Grand Final`). Don't switch on it. |
## Tournament
| Field | Values |
| --- | --- |
| `tier` | `S` · `A` · `B` · `C` (S highest) |
| `status` | `upcoming` · `ongoing` · `finished` — **derived from dates**, `null` when `starts_at` is unknown |
| `region` | `Europe` · `North America` · `South America` · `Asia` · `CIS` · `Oceania` · `Africa` |
## Team and player
| Field | Values |
| --- | --- |
| `region` (team) | Same seven full names as above. **Not codes** — `EU` matches nothing. |
| `country_code` | ISO 3166-1 alpha-2, uppercase (`UA`, `DE`) |
| `role` (player) | `AWP` · `IGL` · `Lurker` · `Rifler` · `Support` — populated on ~14% of players |
## Odds
| Field | Values |
| --- | --- |
| `source` | `eo_market` · `eo_model` — **only ever these two** |
| `outcome_key` | `home` · `away` · `over` · `under` · `odd` · `even`, or a correct-score literal in `a_b` form with an **underscore** (`2_1`, `0_3`) — team A's maps then team B's |
| `participant_type` | `team` · `player` · `null` |
| `price` | Decimal odds. Never American or fractional. |
## Rounds and maps
| Field | Values |
| --- | --- |
| `team_side` | `ct` · `t` (lowercase) |
| `choice_type` (veto) | `pick` · `ban` · `decider` — `decider` has a `null` `team_id` |
| `map_name` | Valve identifiers (`de_ancient`, `de_mirage`) |
| `hit_group` | `Head` · `Chest` · `Stomach` · `LeftArm` · `RightArm` · `LeftLeg` · `RightLeg` · `Gear` · `Generic` |
| `grenade_name` | `Flashbang` · `Smoke Grenade` · `HE Grenade` · `Molotov` · `Incendiary Grenade` · `Decoy Grenade` |
| `weapon_class` | `Rifle` · `Pistols` · `SMG` · `Heavy` · `Grenade` · `Equipment`, or an empty string where the source didn't classify it |
## Rankings
| Field | Values |
| --- | --- |
| `entity_type` | `team` · `player` |
| `metric` | `glicko2_conservative` (teams) · `rating` (players) |
| `window` | `3mo` |
## Coverage
| Field | Values |
| --- | --- |
| `group` | `data` · `odds` |
| `status` | `live` · `pending_validation` · `schema_ready` · `planned` · `licence_required` |
## Errors
`missing_api_key` · `invalid_api_key` · `unauthorized` · `rate_limited` · `quota_exceeded` ·
`trial_quota_exceeded` · `unknown_game` · `not_found` · `invalid_cursor` · `invalid_parameter` ·
`conflict` · `forbidden` · `internal_error` · `unavailable`
See [Errors](/docs/errors) for which status each accompanies.
## WebSocket frames
Client → server: `subscribe` · `unsubscribe` · `pong`
Server → client: `snapshot` · `update` · `subscribed` · `unsubscribed` · `error`
There is **no** `heartbeat` type — the server uses native WebSocket pings. See
[Live data](/docs/live-data#keep-alive).
---
# Map results
Source: https://docs.esportsodds.gg/docs/cs2-data/map-results
Per-map scores, the half-by-half split, and why overtime is null rather than zero.
`GET /v1/{game}/matches/{id}/maps` returns one row per map played, ordered by `map_number`.
A `bo3` has up to 3 rows and a `bo1` exactly 1 — but a series that ended early has fewer rows than
its format allows, so never assume a count from `format`.
## Scores
`score_a` and `score_b` are final rounds won by team A and team B (the parent match's `team_a_id` /
`team_b_id`), **including overtime**.
`map_name` is always the Valve identifier — `de_ancient`, `de_mirage` — never a display name.
## The half split
`first_half_a`/`first_half_b` and `second_half_a`/`second_half_b` break the map into halves, and are
`null` together when the split isn't available for that map. The final score is authoritative
regardless.
## Overtime is null, not zero
`overtime_a`/`overtime_b` are **`null` when the map didn't go to overtime** — deliberately, so you
can distinguish "no overtime" from "overtime happened and this team won no rounds in it". Treating
`null` as `0` collapses that distinction.
```python
maps = get(f"/v1/cs2/matches/{match_id}/maps")["data"]
for m in maps:
ot = "" if m["overtime_a"] is None else f" (OT {m['overtime_a']}-{m['overtime_b']})"
print(f"Map {m['map_number']}: {m['map_name']} {m['score_a']}-{m['score_b']}{ot}")
```
## Reconciling with the series score
The match's `score_a`/`score_b` count **maps won**, not rounds. So a 2–1 series with map scores
13–8, 10–13, 13–11 gives `score_a: 2`, `score_b: 1`. Mixing the two is a common source of confusing
totals.
## Which side started where
Not exposed. The half-by-half columns tell you the shape of the map without telling you which side
each team opened on — that's held internally and isn't part of the customer contract today.
---
# Player match stats
Source: https://docs.esportsodds.gg/docs/cs2-data/match-stats
The per-player stat line — which fields are always present, which are nullable, and the map_number trap.
`GET /v1/{game}/matches/{id}/stats` returns one row per player per map, plus a whole-match aggregate
row per player.
## The map_number trap
**`map_number` is `null` on the whole-match aggregate row.** Every player therefore appears
`maps + 1` times: once per map, once in total.
```python
rows = get(f"/v1/cs2/matches/{match_id}/stats")["data"]
per_map = [r for r in rows if r["map_number"] is not None]
aggregate = [r for r in rows if r["map_number"] is None]
```
Summing all rows double-counts every player. This is the single most common mistake against this
endpoint.
## Always present
`kills`, `assists`, `deaths`, `adr` and `rating` are on every row. `rating` is **our own** computed
rating, not a third party's — don't compare it numerically against a rating from elsewhere.
## Nullable
The richer detail comes from a source that doesn't cover every match, so these are `null` rather
than `0` where unavailable — an honest absence, not a claim that nothing happened:
`kast`, `headshots`, `first_kills`, `first_deaths`, `trade_kills`, `trade_deaths`, `clutches`,
`multikills_2k`/`3k`/`4k`/`5k`, `damage`, `utility_value`.
`kast` here is a **fraction between 0 and 1** — multiply by 100 for the percentage usually shown.
On a round row it is a **count of 0–5 players**, and on a team profile the `*_pct` fields are
already 0–100. Three different units for related ideas; check which endpoint you're reading.
## team_id
`team_id` is the team the player played **for in this match**, which is not always their current
team — rosters change, and stand-ins happen. It is nullable where attribution couldn't be resolved.
Use it rather than the player's own `team_id` when attributing historical performance.
## Field reference
For what ADR, KAST and rating *mean* as concepts, the marketing site has explainers at
[/learn/cs2-adr](https://esportsodds.gg/learn/cs2-adr) and
[/learn/cs2-kast](https://esportsodds.gg/learn/cs2-kast). This page documents the fields as served.
---
# Player depth
Source: https://docs.esportsodds.gg/docs/cs2-data/player-depth
Weapons, grenades, hitgroups, duels and flashes — the deepest layer, and the least universal.
`GET /v1/{game}/matches/{id}/depth` returns a single object containing **five arrays**. Each is `[]`
when unavailable, never `null`.
This is the least universally available layer — check `data_available.depth` on the match, or filter
the match list with `?has=depth`, before requesting it.
## weapons
One row per player per weapon per **side** they held it on, so a player who used an AK-47 on both
sides appears twice.
`weapon_slug` (`ak-47`) is the stable key to group on; `weapon_name` (`AK-47`) is for display;
`weapon_class` buckets it (`Rifle`, `Pistols`, `SMG`, `Heavy`, `Grenade`, `Equipment`, or an empty string where the source didn't classify it).
Counters: `shots`, `hits`, `kills`, `headshots`, `damage`, `wall_bangs`, `trade_kills`. `hits/shots`
is an accuracy rate — guard the zero denominator, since a weapon can be equipped without being
fired.
## grenades
One row per player per grenade type per side: `throws`, `hits`, `kills`, `owns`. `grenade_name`
is one of `Flashbang`, `Smoke Grenade`, `HE Grenade`, `Molotov`, `Incendiary Grenade` or
`Decoy Grenade`.
## hitgroups
Where shots landed: `hit_group` — one of `Head`, `Chest`, `Stomach`, `LeftArm`, `RightArm`, `LeftLeg`, `RightLeg`,
`Gear` or `Generic` — with `hits`, `damage` and `kills` whose finishing shot landed there.
## duels
One row per **ordered** (killer, victim) pair, match-level. A mutual rivalry is **two rows** — swap
the ids to find the reverse direction.
`kills` is how many times the killer killed the victim; `weapon_names` is a comma-separated string
in kill order.
## flashes
One row per **ordered** (flasher, flashed) pair. Self-flashes are real rows where
`flasher_player_id == flashed_player_id` — they are recorded, not filtered, and dropping them is a
decision you should make deliberately.
`count` is how many times; **`duration_ns` is total blind time in nanoseconds** — divide by 1e9 for
seconds. It's an int64 because a match total exceeds 32-bit range.
```python
depth = get(f"/v1/cs2/matches/{match_id}/depth")["data"]
for f in depth["flashes"]:
if f["flasher_player_id"] == f["flashed_player_id"]:
continue # self-flash
print(f"{f['flasher_player_id']} blinded {f['flashed_player_id']} "
f"{f['count']}× for {f['duration_ns'] / 1e9:.1f}s")
```
## Everything here is match-level
None of the five arrays carry `map_number` — the upstream exposes them per match, not per map. If
you need per-map weapon usage, that isn't available.
---
# Ratings and rankings
Source: https://docs.esportsodds.gg/docs/cs2-data/ratings-and-rankings
Glicko-2, why the board sorts on a discounted rating, and the sample floors.
Two related surfaces: a team's **rating over time**, and the **leaderboard** built from it.
## Rating history
`GET /v1/{game}/teams/{id}/ratings` returns `{rating, rd, as_of}` points, **oldest-first** so the
series plots directly. Default 30 points, max 200.
`rating` is Glicko-2. `rd` is the **rating deviation** — the model's uncertainty. It falls as a team
plays and rises during inactivity, so a returning team has a high `rd` and its rating should be read
as provisional.
## The leaderboard
`GET /v1/{game}/rankings?type=team|player` over a rolling **3-month** window.
Teams rank on `glicko2_conservative` — the rating **discounted by twice its deviation**:
```
value = rating − 2 × rd
```
That's the important design decision. A team with a high rating the model isn't confident about
sorts below an equally-rated team it is confident about. It rewards demonstrated form over a lucky
run, and it means an inactive team drifts down as its `rd` grows rather than sitting on a stale
rating forever.
Players rank on `rating`. Each row reports which via its `metric` field, so you never have to infer
it.
## Sample floors
Entities below a minimum sample are **omitted entirely** rather than ranked on noise:
- Teams: at least **5** rated matches in the window.
- Players: at least **30** rated maps.
So absence from the board means "not enough evidence", not "ranked last".
## Row fields
`rank` is 1-based board position. Sorting rows by `value` reproduces `rank` exactly.
Teams additionally get:
- `form` — up to 5 recent results as `["W","L",…]`, newest first.
- `rank_delta` — movement versus roughly 7 days ago. Positive means moved **up**. `null` means the
team wasn't on the board then, which is different from "didn't move" (`0`).
- `external_rank` — a separately-sourced world ranking where one exists, reported alongside for
comparison. It is **not** used for ordering.
## Sorting and paging
`/rankings` accepts `sort=rank|value|name` with a `-` prefix for descending, and is **not**
cursor-paginated — `limit` truncates the computed board.
Re-sorting changes the order rows come back in, never the `rank` each entity holds. So
`?sort=-rank&limit=10` returns the bottom ten *with their real ranks*, rather than renumbering them
1–10.
## Own rank on a detail route
A team or player detail row can carry `own_rank`, but only when you ask:
```bash
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/teams/natus-vincere?rank=1"
```
It costs a window query over the whole board, so it is off by default. Without the parameter,
`own_rank` is `null` meaning "not requested" — not "unranked".
---
# Roster history
Source: https://docs.esportsodds.gg/docs/cs2-data/roster-history
The five a team actually fielded, match by match, and what each continuity state means.
`GET /v1/{game}/teams/{id}/roster-history` returns the lineups a team actually fielded, newest
first. Default 20, max 100; not paginated.
This is **fielded** rosters, not signed rosters — it's derived from who played, so stand-ins appear
exactly as they occurred.
## A row
```json
{
"match_id": "019f3d18-c15f-7319-81a7-343e8a80a578",
"scheduled_at": "2026-07-04T18:00:00Z",
"player_ids": ["019f…", "019f…", "019f…", "019f…", "019f…"],
"continuity": "changed",
"changed_count": 1,
"players_in": ["019f…"],
"players_out": ["019f…"],
"has_debutant": false,
"days_since_prev": 4.2
}
```
## The continuity states
| State | Meaning |
| --- | --- |
| `stable` | Identical to the previous lineup. |
| `changed` | One to four players swapped. `changed_count`, `players_in` and `players_out` describe exactly what moved. |
| `reset` | All five swapped. Usually an organisation rename or a full rebuild — not five simultaneous stand-ins. |
| `resumed` | The gap since the previous match is long enough that a direct comparison would mislead. |
| `new` | No prior completed match to compare against. |
On a `new` row, `players_in` and `players_out` are **empty because there is no baseline to diff
against** — not because nothing changed. Reading empty arrays as "no change" inverts the meaning.
`days_since_prev` is `null` on the first entry for the same reason.
## What it's good for — and what it isn't
It answers "was this the same five?" precisely, which matters when you're comparing performance
across a period that contains a roster move: aggregate stats spanning a `reset` are two different
teams wearing one name.
What it is **not** is a betting edge. We measured roster churn against closing lines and the market
already prices it — teams with recent changes performed slightly *worse* than the line implied, not
better. Treat this as context for analysis, not as a signal.
## Tenure
Combine `scheduled_at` with the continuity states to compute how long the current five have played
together — a `stable` run since the last `changed`/`reset` row. That's usually a more meaningful
covariate than the raw roster itself.
---
# Round stats
Source: https://docs.esportsodds.gg/docs/cs2-data/round-stats
Two rows per round, the signed economy tier, and what each counter measures.
`GET /v1/{game}/matches/{id}/rounds` returns **two rows per round** — one per team. Exactly one has
`won: true`.
Join to a map with `(match_id, map_number)`; rounds are numbered from 1 within a map and **overtime
keeps counting up**, so round 25+ on a standard map is overtime rather than a restart.
## economy_level is a signed tier, not a sentinel
`economy_level` can be **negative**, and a negative value is meaningful. It is a signed
classification of the buy — eco and force-buy rounds sit below zero. It is **not** a missing-data
sentinel, and filtering out negatives silently discards every eco round, which is usually the
exact population an economy analysis is about.
Read it alongside `equipment_value`, `enemy_equipment_value` and `money_spent` — the raw dollar
figures, with the opponent's value denormalised onto the same row so you can judge the buy matchup
without a join.
## Sides
`team_side` is lowercase `ct` or `t`. Sides swap at the half, so a team's side changes partway
through a map — never assume a team's side from the map alone.
`pistol_round` flags round 1 and the second-half opener.
## Counters
All integers, never null:
| Field | Meaning |
| --- | --- |
| `kills` / `deaths` / `assists` | Team totals for the round. `deaths` is 0–5. |
| `damage`, `headshots` | Damage dealt; how many kills were headshots. |
| `first_kills` / `first_deaths` | Opening duel. Across a round's two rows these sum to 0 or 1. |
| `trade_kills` / `trade_deaths` | Trade discipline — a kill that avenged a teammate who just died, and a death that was subsequently traded. |
| `clutches` / `clutch_attempts` | Won, and reached. Divide for a conversion rate — guard the zero denominator. |
| `bomb_plants` / `bomb_defuses` | 0 or 1. Plants are always 0 on the CT side, defuses always 0 on the T side. |
| `flash_assists`, `utility_value` | Kills enabled by blinding; dollar value of grenades used. |
| `kast` | **A count of 0–5** players who contributed — not a percentage. |
## Rebuilding a scoreline
```python
rounds = get(f"/v1/cs2/matches/{match_id}/rounds")["data"]
from collections import Counter
score = Counter(r["team_id"] for r in rounds if r["won"] and r["map_number"] == 1)
```
That should reconcile with map 1's `score_a`/`score_b` from
[map results](/docs/cs2-data/map-results). If it doesn't, you're probably summing across maps.
---
# Team records — form, head-to-head, maps, playstyle
Source: https://docs.esportsodds.gg/docs/cs2-data/team-records
The four derived team endpoints and what each is actually measuring.
Four endpoints summarise a team's history. All are derived from completed matches, so a team with
little history returns thin results rather than an error.
## Recent form
`GET /v1/{game}/teams/{id}/form` — recent completed results, **newest first**, from the subject
team's perspective. Default 10, max 50; not paginated.
Each row: `match_id`, `opponent_id`, `won`, `score_for`, `score_against`, `scheduled_at`. The scores
are **maps won**, not rounds, and are `null` where the series score wasn't recorded.
## Head-to-head
`GET /v1/{game}/teams/{id}/h2h?opponent=` — **`opponent` is required.**
```bash
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/teams/natus-vincere/h2h?opponent=faze"
```
Returns `team_wins`, `opponent_wins`, `total`. Only matches with a settled winner are counted, so
`team_wins + opponent_wins == total` exactly — there is no draw bucket.
`total` is the number to sanity-check first: a 3–1 record over 4 meetings is close to no evidence.
## Per-map record
`GET /v1/{game}/teams/{id}/maps` — one row per map the team has a completed result on:
`map_name`, `played`, `wins`.
Raw counts, deliberately — the win rate is `wins / played`, left for you to compute so the model
stays a fact rather than a formatted ratio. Guard small `played` values: 3–0 on a map is not a 100%
map.
Ordered by win rate descending, then by matches played.
## Playstyle profile
`GET /v1/{game}/teams/{id}/round-stats` — a single object of percentages aggregated over the team's
rounds:
Every percentage is `0–100`, and **`null` when the denominator is empty** — a team with no recorded
eco rounds has `eco_win_pct: null`, not `0`.
An unknown team id returns `rounds: 0` with every percentage `null` — **not a 404**. If you need to
know a team exists, fetch it directly.
## Units, one more time
These are the only team fields expressed as 0–100 percentages. Player KAST is a 0–1 fraction and
round KAST is a 0–5 count. When in doubt, check the field's own description in the reference.
---
# Map vetoes
Source: https://docs.esportsodds.gg/docs/cs2-data/vetoes
The pick/ban sequence, including the decider that no team chose.
`GET /v1/{game}/matches/{id}/vetoes` returns the map pool negotiation in order, one row per step.
Vetoes are known **before** play starts, so this is often populated on a `scheduled` match while
every other sub-resource is still empty.
## The steps
`order` is 1-based and rows come back in it. `choice_type` is one of:
| Value | Meaning | `team_id` |
| --- | --- | --- |
| `ban` | A team removed this map from the pool | The team that banned |
| `pick` | A team chose this map to be played | The team that picked |
| `decider` | The map left over once vetoing finished | **`null`** |
A `decider` has no acting team, so `team_id` is `null` there. Code that assumes every veto row has
a team will fail on the last step of most series.
## Reading a sequence
```python
vetoes = get(f"/v1/cs2/matches/{match_id}/vetoes")["data"]
for v in vetoes:
who = v["team_id"] or "—"
print(f"{v['order']}. {v['choice_type']:8} {v['map_name']:14} {who}")
```
A typical bo3: ban, ban, pick, pick, ban, ban, decider.
## What it tells you
The veto is a statement of preference under pressure: which maps a team is willing to spend a ban
on, and which they'll pick when it counts. Cross-referenced with
[per-map records](/docs/cs2-data/team-records) it shows whether a team's stated preferences match
their actual results — the two diverge more often than you'd expect.
`map_name` uses the same Valve identifiers as map results, so the two join directly.
---
# Errors
Source: https://docs.esportsodds.gg/docs/errors
HTTP status codes and the neutral JSON error body the API returns.
Errors use standard HTTP status codes and one JSON body shape:
```json
{
"error": {
"code": "invalid_parameter",
"message": "match must be a valid id",
"request_id": "019f3d18-c15f-7319-81a7-343e8a80a578"
}
}
```
**Branch on `code`, never on `message`.** `code` is a stable identifier that changes only as a
breaking change; `message` is human prose for your logs and its wording may change at any time.
Messages describe what went wrong plainly and carry no promotional or betting framing.
`request_id` is also returned on **every** response — success or failure — as the `X-Request-Id`
header. Log it. Quoting it turns "a call failed yesterday afternoon" into one log lookup.
## Status codes
| Status | Meaning | Codes you'll see |
| --- | --- | --- |
| `400` | Bad Request | `invalid_parameter`, `invalid_cursor` |
| `401` | Unauthorized | `missing_api_key`, `invalid_api_key`, `unauthorized` |
| `403` | Forbidden | `forbidden` |
| `404` | Not Found | `not_found`, `unknown_game` |
| `409` | Conflict | `conflict` |
| `429` | Too Many Requests | `rate_limited`, `quota_exceeded`, `trial_quota_exceeded` |
| `500` | Internal Server Error | `internal_error` |
| `503` | Service Unavailable | `unavailable` |
The `code` is deliberately more specific than the status. A `429` that is `rate_limited` means slow
down and retry in a second or two; a `429` that is `quota_exceeded` means you have no requests left
this month and retrying sooner cannot help. Treating both as "back off and retry" wastes the rest of
your allowance on requests that are guaranteed to fail.
## Examples
```bash
# A filter value that isn't a valid id
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/odds?match=not-an-id"
# 400 { "error": { "code": "invalid_parameter", "message": "match must be a valid id", … } }
```
```bash
# A game that isn't onboarded
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/dota2/matches"
# 404 { "error": { "code": "unknown_game", "message": "unknown game", … } }
```
## Handling them in code
```js
const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } })
if (!res.ok) {
const { error } = await res.json()
switch (error.code) {
case 'rate_limited':
// Transient. Honour Retry-After and try again.
return retryAfter(res.headers.get('Retry-After'))
case 'quota_exceeded':
case 'trial_quota_exceeded':
// Not transient — no requests left until the window resets. Stop, don't retry.
return halt(error.message)
case 'invalid_cursor':
// Restart pagination from the first page rather than reusing a stale cursor.
return restartPaging()
default:
throw new Error(`${error.code}: ${error.message} (request ${error.request_id})`)
}
}
```
## Cases that surprise people
A few behaviours are deliberate but not what you might guess. Each of these is stable — code
against it.
**A malformed id is a `404`, not a `400`.** Path ids are validated as canonical 36-character
hyphenated UUIDs; anything else fails the format check and is reported as "not found" rather than
"bad request". Only *query-parameter* ids (`?match=`, `?team=`, `?opponent=`) return `400`.
```bash
curl -H "Authorization: Bearer YOUR_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/teams/navi"
# 404 { "error": "not found" } <- a slug in the path is not (yet) resolvable; use ?slug= instead
```
**An unknown id on a sub-resource returns an empty list, not a `404`.** `/matches/{id}/maps`,
`/stats`, `/vetoes`, `/rounds` and `/tournaments/{id}/teams` answer `200` with `data: []` for a
match or tournament that doesn't exist — they don't check the parent first. If you need to know
the parent exists, fetch it directly. `/players/{id}/stats` is the exception: it does check, and
`404`s.
**An out-of-range `limit` is clamped, not rejected**, and a nonsense one falls back to the default.
`?limit=99999`, `?limit=0` and `?limit=abc` all return a page rather than a `400`. Read
`meta.count` for what you actually got.
**An empty result is not an error.** A filter that matches nothing, a match with no odds, or a
source you're not entitled to all return `200` with `data: []`. Branch on emptiness, not on status.
## Handling errors
- Treat `4xx` as a problem with the request — fix the input or the key; retrying unchanged won't
help (except `429`, which you retry after a delay).
- Treat `5xx` as transient — retry with exponential backoff.
- Always branch on the HTTP status first, then read `error` for a message to log.
- Honour `Retry-After` on a `429` rather than picking your own interval — see
[Rate limits](/docs/rate-limits).
---
# Your first integration
Source: https://docs.esportsodds.gg/docs/first-integration
A complete, runnable script that lists matches, expands one, and reads its odds.
The quickstart gets you a response. This gets you something you'd actually ship: a script that
handles errors, paginates, expands a match in one request, and reads its odds — with the mistakes
already removed.
## Set your key
```bash
export ESPORTSODDS_API_KEY="eo_live_…"
```
Never put a literal key in source. Every sample here reads the environment.
## The script
```python
"""A minimal but correct EsportsOdds integration."""
import os
import time
import random
import requests
BASE = "https://api.esportsodds.gg"
HEADERS = {"Authorization": f"Bearer {os.environ['ESPORTSODDS_API_KEY']}"}
RETRYABLE = {"rate_limited", "internal_error", "unavailable"}
class ApiError(RuntimeError):
def __init__(self, code, message, request_id):
super().__init__(f"{code}: {message} (request {request_id})")
self.code = code
def get(path, params=None, attempts=4):
"""GET with the retry policy the API's error codes imply."""
for attempt in range(attempts):
r = requests.get(BASE + path, params=params, headers=HEADERS, timeout=30)
if r.ok:
return r.json()
err = r.json().get("error", {})
code = err.get("code", "")
# quota_exceeded is NOT retryable — no amount of waiting inside this process helps.
if code not in RETRYABLE or attempt == attempts - 1:
raise ApiError(code, err.get("message", ""), err.get("request_id"))
wait = float(r.headers.get("Retry-After") or 2**attempt)
time.sleep(wait + random.uniform(0, 0.5))
def paginate(path, **params):
"""Walk every page. meta.next_cursor is null on the last one — the only stop signal."""
cursor = None
while True:
if cursor:
params["cursor"] = cursor
body = get(path, params=params)
yield from body["data"]
cursor = body["meta"]["next_cursor"]
if not cursor:
return
def main():
# 1. Upcoming fixtures. The list row carries team and tournament NAMES, so rendering a
# schedule needs no further calls.
upcoming = list(paginate("/v1/cs2/matches", status="scheduled", limit=50))
print(f"{len(upcoming)} scheduled matches\n")
for m in upcoming[:5]:
print(f" {m['scheduled_at']} {m['team_a_name']} vs {m['team_b_name']}"
f" ({m['tournament_name']})")
if not upcoming:
return
# 2. One match, fully expanded — one request instead of four.
match_id = upcoming[0]["id"]
match = get(f"/v1/cs2/matches/{match_id}",
params={"include": "teams,tournament,odds"})["data"]
print(f"\n{match['team_a']['name']} vs {match['team_b']['name']}")
print(f" {match['tournament']['name']} · {match['format']} · {match['status']}")
# 3. Odds. Group by market first — a match can have more than one.
from collections import defaultdict
markets = defaultdict(list)
for line in match.get("odds", []):
markets[line["market_id"]].append(line)
for market_id, lines in markets.items():
print(f"\n market {market_id[:8]}…")
for l in lines:
implied = 1 / l["price"]
print(f" {l['label']:24} {l['price']:6.2f} p={implied:5.1%}"
f" ({l['book_count']} books)")
# De-vigged, so implied probabilities sum to ~1.0. A good parsing sanity check.
total = sum(1 / l["price"] for l in lines)
print(f" {'sum':24} {'':6} p={total:5.1%}")
if __name__ == "__main__":
main()
```
## What it demonstrates
**Errors are classified, not blanket-retried.** `quota_exceeded` and `rate_limited` are both `429`
but mean opposite things — one is transient, the other cannot succeed until the month resets.
**Pagination stops on `next_cursor`, not on a short page.** A short page doesn't mean the end.
**One request per match, not four.** `?include=teams,tournament,odds` embeds what would otherwise be
three follow-up calls.
**Odds are grouped by market before being read.** Mixing a moneyline with a handicap produces
numbers that look like movement but aren't.
**The de-vig sum is asserted.** Implied probabilities summing to ~100% confirms you've grouped
correctly; ~105% means two markets got mixed.
## Where to go next
- [Budget your requests](/docs/how-to/request-budget) — before you put anything on a timer.
- [Connect a WebSocket](/docs/how-to/connect-websocket) — for live data, cheaper than polling.
- [Troubleshooting](/docs/help/troubleshooting) — the behaviours that surprise people.
---
# FAQ
Source: https://docs.esportsodds.gg/docs/help/faq
The questions that come up before and during an integration.
Counter-Strike 2 only. The path shape (`/v1/{game}/{resource}`) is game-agnostic by construction, so
a second title would extend the same paths rather than replacing them — your `cs2` calls would be
unaffected. A game that isn't onboarded returns `404 unknown_game`.
Ask the API rather than trusting a number in a doc:
```bash
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/matches?status=completed&limit=1&sort=scheduled_at"
```
Depth varies by layer — fixtures and results go back furthest, per-player depth is the newest and
least universal. `/v1/cs2/coverage` states which capabilities exist at all.
The WebSocket pushes score and odds changes at our ingestion cadence, which is not the same as
sub-second tick-by-tick data. The connection path, envelope and sequencing are final; only the tick
rate changes when the underlying service ships. We'd rather say that plainly than let you discover
it after building.
We serve one de-vigged aggregate line (`eo_market`) with a `book_count`, never per-book prices or
names. That's a product decision: the published line is a descriptive statistic about the market,
not a price-comparison surface. See [the market line](/docs/concepts/market-line).
The model line isn't served until it clears a published accuracy gate. `/v1/cs2/model/metrics` shows
exactly where it stands — currently a Brier score worse than the 0.25 no-skill baseline, so
`gate_passed` is `false`. When that changes, the same endpoint will say so. See
[the model line](/docs/concepts/model-line).
No free tier. There's a trial with a **1,000-request** allowance, sized for evaluating the API. The
public demo widget on esportsodds.gg shows real API output without an account.
Requests return `429` with `error.code: "quota_exceeded"` until `X-Quota-Reset`. Retrying cannot
help — it's a hard cap, not a throttle. The quota is per **account**, shared across all your keys,
so creating a second key doesn't grant a second allowance.
Not yet. The API is plain REST with bearer auth, and every endpoint's reference page carries
copy-pasteable cURL, Python, TypeScript and Go samples. The OpenAPI spec is published at
[/openapi.yaml](https://docs.esportsodds.gg/openapi.yaml) if you'd like to generate a client.
You can, but you shouldn't — a key in browser JavaScript is a published key. Proxy through your own
backend. For live data in a browser, mint a WebSocket ticket server-side and hand the browser only
the ticket. See [CORS and browser clients](/docs/platform/cors).
Your plan covers use within your own product. Republishing bulk data as a competing dataset is a
different conversation — get in touch before building on that assumption.
We hold no redistribution rights to team marks, so the field is blanked on every response rather
than serving an upstream URL. It's the one field in the API that is deliberately never populated.
Quote the `X-Request-Id` from the response — it's on every response and repeated in
`error.request_id`. With it we can find the exact request in our logs. See
[status and support](/docs/platform/status-and-support).
---
# Glossary
Source: https://docs.esportsodds.gg/docs/help/glossary
API terms defined as they are used here, with pointers to the deeper explainers.
Definitions **as this API uses them**. For the concepts themselves — the maths and the CS2 domain —
the marketing site's [glossary](https://esportsodds.gg/glossary) and
[learn](https://esportsodds.gg/learn) sections go deeper.
## API terms
**Envelope** — the `{data, meta}` wrapper on every response.
[Details](/docs/concepts/envelope).
**Cursor** — an opaque forward pagination token from `meta.next_cursor`. Encodes a position within
one specific ordering; never construct one. [Details](/docs/concepts/pagination).
**Slug** — a lowercase hyphenated identifier (`natus-vincere`), usable anywhere a path id is
expected on teams, players and tournaments.
**Expansion** — `?include=teams,tournament,odds` on match detail, embedding related resources so one
request replaces four.
**Ticket** — a 60-second credential from `POST /v1/{game}/ws-token` used to open a WebSocket, so the
raw key never reaches a browser.
**Quota vs rate limit** — the quota is your monthly request allowance (hard cap); the rate limit is
short-term throughput (a token bucket). Independent. [Details](/docs/rate-limits).
## Odds terms
**`eo_market`** — the served market line: a de-vigged aggregate of several bookmakers and
exchanges, published only when at least two contributed. [How it's computed](/docs/concepts/market-line).
**`eo_model`** — our own forecast. Not served until it clears its accuracy gate.
[Details](/docs/concepts/model-line).
**De-vig** — removing a bookmaker's margin so implied probabilities sum to 1 rather than more. Why
our lines sum to ~1.0. Longer explainer: [/learn/devig-fair-odds](https://esportsodds.gg/learn/devig-fair-odds).
**`book_count`** — how many independent contributors priced an `eo_market` line. Never below 2.
**Decimal odds** — total return per unit staked. Implied probability is `1 / price`. The only format
served.
**Closing line** (`is_closing`) — the last price captured before `scheduled_at`. The standard
benchmark for evaluating a forecast.
**Brier score** — mean squared error of forecast probabilities. Lower is better; 0.25 is the
coin-flip baseline for a two-way market.
## CS2 terms
**ADR** — average damage per round. [Explainer](https://esportsodds.gg/learn/cs2-adr).
**KAST** — share of rounds with a kill, assist, survival or trade. **Units differ by endpoint**: a
0–1 fraction on a player stat line, a 0–5 count on a round row.
[Explainer](https://esportsodds.gg/learn/cs2-kast).
**Rating** — our own computed player rating. Not a third party's; don't compare absolute values
across sources.
**Opening duel** (`first_kills`/`first_deaths`) — the round's first kill. Across a round's two team
rows these sum to 0 or 1.
**Trade** — a kill avenging a teammate who has just died.
**Eco / force buy** — rounds bought below a full buy. Identified by a **negative** `economy_level`,
which is a signed tier, not a missing value.
[Explainer](https://esportsodds.gg/learn/cs2-economy).
**Veto** — the pick/ban sequence. The leftover map is a `decider` and has no acting team.
**Glicko-2** — the rating system behind team ratings. The leaderboard sorts on
`rating − 2 × rd`, discounting by uncertainty. [Details](/docs/cs2-data/ratings-and-rankings).
---
# Known limitations
Source: https://docs.esportsodds.gg/docs/help/limitations
What the API doesn't do today, stated plainly so you find out before you build.
This page exists because discovering a gap after you've built around it is worse than reading about
it now.
## Not served
**Per-book odds.** One de-vigged aggregate line, never individual bookmaker prices or names. Not a
roadmap item — it's a product decision.
**The model line.** `eo_model` returns an empty page until it clears its accuracy gate.
`/v1/cs2/model/metrics` shows the current standing, and it currently says the model isn't good
enough.
**Team logos.** `logo_url` exists but is always empty — no redistribution rights.
**Market metadata.** You get `market_id` on each odds line, which is enough to group them, but
there's no endpoint listing markets or market types.
**Which side started a map.** Half-by-half scores are exposed; the starting side is not.
## Thin or absent data
**`seed` on tournament participants is always `null`.** No upstream we ingest publishes seeding, so
it's reserved rather than populated. Don't order participants by it.
**`role` on players** is populated on roughly 14% of players — a `?role=` filter excludes everyone
whose role is simply unknown, which is most of them.
**Per-player depth** (weapons, grenades, hitgroups, duels, flashes) is the least universal layer.
Check `data_available.depth` before requesting it.
**Depth is match-level, not per-map.** The upstream exposes it per match, so per-map weapon usage
isn't available.
## Shape constraints
**No total count on lists.** `meta.count` is the page size, not the size of the set — counting the
whole set costs a second query most callers don't want.
**No random-access pagination.** Cursors are forward-only; you can't jump to page 7. That's the
trade for correctness under concurrent writes.
**`/odds` bulk snapshot and `/rankings` don't paginate.** `limit` truncates silently.
**No sparse fieldsets.** There's no `?fields=` — responses are complete or nothing.
**No conditional requests.** No `ETag` or `If-Modified-Since`, so you can't cheaply ask "has this
changed". Use `updated_at` on a match instead.
**No compression.** Responses are uncompressed JSON.
## Sorting
`sort=` exists on `/teams`, `/players` and `/rankings` only, with a small allowlist of keys per
endpoint. Player performance metrics aren't sortable on the players list — they live on match stats,
not the player row. Use `/rankings?type=player` for a performance-ordered board.
## Live data
The WebSocket carries ingestion-cadence updates, not sub-second ticks. The protocol is final; the
tick rate improves when the underlying service ships.
## If one of these blocks you
Several are "not yet" rather than "never" — tell us which one and why. Knowing what people are
actually blocked on is how this list gets shorter.
---
# Troubleshooting
Source: https://docs.esportsodds.gg/docs/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`.
```bash
# 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 `404`s.
## 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:
```bash
# 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:
```bash
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:
```bash
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:
| Call | Mode | Behaviour |
| --- | --- | --- |
| `/odds` | Bulk snapshot | The latest line per outcome across every open market. **Not paginated** — `limit` truncates silently and `meta.next_cursor` is always `null`. |
| `/odds?match=` | History | The 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 ` (or `?apiKey=` as a
fallback). A header sent as `Authorization: `, 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](/docs/rate-limits#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](/docs/live-data#keep-alive).
## 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.
---
# Backfill historical data
Source: https://docs.esportsodds.gg/docs/how-to/backfill-history
Walking the archive once without spending your whole month on it.
A backfill is the one workload that can exhaust a monthly quota in an afternoon. Plan it before you
start it.
## Estimate first
```python
# One request tells you roughly how big the job is.
page = get("/v1/cs2/matches?status=completed&date_from=2025-01-01&date_to=2025-12-31&limit=500")
print(page["meta"]["count"], "in the first page")
```
Then the arithmetic:
```
requests ≈ (matches / 500) # walking the list
+ matches × layers_per_match # detail calls
```
The list is cheap; the **per-match detail calls dominate**. 2,000 matches × 3 layers = 6,000
requests — 30% of a month — before you've fetched a single odds series.
## Only fetch layers that exist
Filter at the list level so you never pull a match you'd discard:
```bash
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/matches?status=completed&has=rounds,depth&date_from=2026-01-01&limit=500"
```
and gate each call on `data_available` rather than requesting blind. A request that returns `[]`
still costs you one.
## Make it resumable
Backfills get interrupted — by a quota, a deploy, a laptop lid. Checkpoint the cursor, not the
offset:
```python
def backfill(db, date_from, date_to):
cursor = db.get_checkpoint("matches") or None
while True:
params = {"status": "completed", "date_from": date_from, "date_to": date_to, "limit": 500}
if cursor:
params["cursor"] = cursor
page = get("/v1/cs2/matches", params=params)
for match in page["data"]:
if db.has_match(match["id"]):
continue # already done — no request spent
ingest_match(db, match)
cursor = page["meta"]["next_cursor"]
db.set_checkpoint("matches", cursor)
if not cursor:
return
```
Two properties worth having: skipping already-ingested matches makes a rerun nearly free, and
checkpointing the cursor means a restart resumes rather than re-walks.
## Pace it
Nothing forces a backfill to finish today. Spending a fixed budget per day turns a job that would
blow your quota into one that fits alongside normal traffic:
```python
DAILY_BUDGET = 400
spent = 0
for match in resumable_matches():
if spent >= DAILY_BUDGET:
break
spent += ingest_match(db, match) # returns requests used
```
Watch `X-Quota-Remaining` and stop early if normal traffic needs the headroom.
## Odds history is the expensive part
`/odds?match=` is per match and cursor-paginated, so a match with a long price series is several
requests on its own. If you only need the outcome rather than the path, fetch the closing lines:
one page per match, taking the rows flagged `is_closing`.
## Rate limit vs quota
You will hit the **monthly quota** long before the per-second rate limit (20 rps sustained). Don't
bother throttling to a slow crawl — just handle `429 rate_limited` with `Retry-After` and stop
entirely on `quota_exceeded`, which retrying cannot fix. See
[errors and retries](/docs/how-to/errors-and-retries).
---
# Build your own leaderboard
Source: https://docs.esportsodds.gg/docs/how-to/build-a-board
Using /rankings as a base, and what to do when you want different weights.
`/v1/{game}/rankings` is our board. If it's what you want, it's one request.
```bash
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/rankings?type=team&limit=50"
```
Each row carries `rank`, `value`, `metric`, plus `form` (last 5 results) and `rank_delta` (movement
vs ~7 days ago) for teams. That's a complete standings widget from a single call.
## Understand what you're getting
Teams rank on `glicko2_conservative` — the Glicko-2 rating **minus twice its deviation**. A team the
model is unsure about sorts below an equally-rated team it's confident about, and an inactive team
drifts down as its uncertainty grows. Players rank on `rating`.
Entities below the sample floor (5 rated matches for teams, 30 rated maps for players) are **omitted
entirely**, so absence means "not enough evidence", not "ranked last".
## Filtering and reordering
```bash
# Regional board
…/rankings?type=team®ion=Europe
# By role
…/rankings?type=player&role=AWP
# The bottom of the board, with real ranks preserved
…/rankings?type=team&sort=-rank&limit=10
```
Re-sorting changes the order rows come back in, never the `rank` each entity holds — so `-rank`
gives you the bottom ten *with their true ranks* rather than renumbering them 1–10.
`/rankings` is not cursor-paginated; `limit` truncates the computed board.
## Rolling your own
If you want different weights — a different window, or map-specific strength — build from the
primitives rather than trying to re-derive ours:
```python
# Rating trajectory rather than a point-in-time rating
history = get(f"/v1/cs2/teams/{team_id}/ratings?limit=200")["data"] # oldest-first
# Recent results
form = get(f"/v1/cs2/teams/{team_id}/form?limit=50")["data"]
# Per-map strength
maps = get(f"/v1/cs2/teams/{team_id}/maps")["data"]
```
Budget carefully: three calls per team across 200 teams is 600 requests — 3% of a month per full
refresh. Cache and refresh weekly; ratings don't move fast enough to justify daily rebuilds.
## Weight by confidence
Whatever you build, apply the lesson `glicko2_conservative` encodes: **discount by uncertainty**. A
90% win rate over 5 matches is weaker evidence than 65% over 60. Use `rd` from the ratings series,
or `played` from the per-map record, as the confidence term. Sorting on a raw rate puts small
samples at the top and makes the board look wrong to anyone who knows the scene.
## Comparing with an external ranking
Team rows carry `external_rank`, a separately-sourced world ranking where one exists. It is reported
alongside for comparison and is **not** used for ordering — the gap between it and our `rank` is
often the interesting part.
---
# Connect a WebSocket
Source: https://docs.esportsodds.gg/docs/how-to/connect-websocket
A working client, from ticket to subscription, with the mistakes pre-removed.
Polling for changes costs a request every interval, forever. One WebSocket costs one request to
mint a ticket and then pushes changes for as long as it stays open.
## The handshake
**Mint a ticket** from your backend. This is an ordinary authenticated REST call and the only
metered part.
```bash
curl -X POST -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/ws-token"
```
```json
{ "data": { "token": "MDE4ZjJjNGEt…", "expires_at": "2026-07-26T09:14:20.481293Z" } }
```
The token is an opaque base64url string — **not a JWT**, so don't try to read claims from it. It is
valid for **60 seconds** and is **not single-use**: a retried connection inside that window can
reuse it.
**Open the socket** with the ticket in the query string.
```js
const ws = new WebSocket(`wss://api.esportsodds.gg/v1/ws?token=${token}`)
```
`Authorization: Bearer` is not accepted here — browsers can't set headers on a `WebSocket`, which is
exactly why tickets exist. Keep the raw key server-side and hand the client only a ticket.
**Subscribe** to the matches you care about. One connection multiplexes many.
```js
ws.addEventListener('open', () => {
ws.send(JSON.stringify({ type: 'subscribe', data: { match_ids: [matchId] } }))
})
```
Each id yields a `snapshot`, then one `subscribed` ack.
## Handling frames
```js
ws.addEventListener('message', (event) => {
const frame = JSON.parse(event.data)
switch (frame.type) {
case 'snapshot':
// Full state — replace, don't merge. Can arrive unsolicited on a server-side resync.
setMatch(frame.match_id, frame.data.match, frame.data.odds)
break
case 'update':
if (frame.data.odds) applyOdds(frame.match_id, frame.data.odds)
if (frame.data.match) applyMatch(frame.match_id, frame.data.match)
break
case 'error':
// Always followed by a close. `connection_limit` means back off, don't reconnect immediately.
console.warn(frame.data.code, frame.data.message)
break
}
})
```
Three things people get wrong:
**There is no `heartbeat` message.** The server sends **native** WebSocket pings every 54 seconds
and closes anything it hasn't heard from in 60. Browsers and mainstream libraries answer
automatically, so there's usually no code to write. A `case 'heartbeat'` branch will never fire.
**A `snapshot` can arrive unsolicited.** When our internal feed reconnects, every live subscription
is re-baselined. Treat `snapshot` as "replace this match's state" wherever it arrives.
**`connection_limit` is the only error frame**, and it only occurs at connect time. An established
connection is never terminated by one.
## Limits
Five concurrent connections per key on the standard plan. A sixth gets an `error` frame then a
close — it isn't queued. Reuse one connection across many matches rather than opening one per match.
Messages are **not** billed. An idle or busy subscription accrues no per-message charge for its
lifetime; only the ticket mint counts.
## Reconnecting
Networks drop. Reconnect with exponential backoff and jitter, re-send your `subscribe`, and rebuild
from the resulting `snapshot`. Reuse the ticket if it's still inside its 60 seconds; mint a fresh one
otherwise. See [recovering from disconnects](/docs/how-to/recover-from-disconnects).
The full protocol reference is on [Live data & WebSocket](/docs/live-data).
---
# Handle errors and retries
Source: https://docs.esportsodds.gg/docs/how-to/errors-and-retries
Which failures are worth retrying, which aren't, and how to back off correctly.
The single most useful habit: **branch on `error.code`, not on the HTTP status alone**. The status
tells you the category; the code tells you whether retrying can possibly help.
## The decision table
| `code` | Status | Retry? |
| --- | --- | --- |
| `rate_limited` | 429 | **Yes** — honour `Retry-After`, usually a second or two. |
| `quota_exceeded` | 429 | **No.** Out of requests until `X-Quota-Reset`. Retrying burns nothing but achieves nothing. |
| `trial_quota_exceeded` | 429 | **No.** Subscribe to continue. |
| `internal_error` | 500 | **Yes** — with exponential backoff. |
| `unavailable` | 503 | **Yes** — with backoff. |
| `invalid_parameter` | 400 | No — fix the request. |
| `invalid_cursor` | 400 | No — restart pagination from the first page. |
| `not_found` / `unknown_game` | 404 | No. |
| `missing_api_key` / `invalid_api_key` | 401 | No — fix credentials. |
Treating every 429 as "back off and retry" turns a quota exhaustion into a tight retry loop that
can never succeed. Read the code.
## A retry wrapper
```python
import os, time, random, requests
BASE = "https://api.esportsodds.gg"
HEADERS = {"Authorization": f"Bearer {os.environ['ESPORTSODDS_API_KEY']}"}
RETRYABLE = {"rate_limited", "internal_error", "unavailable"}
class ApiError(RuntimeError):
def __init__(self, code, message, request_id):
super().__init__(f"{code}: {message} (request {request_id})")
self.code = code
def get(path, params=None, attempts=5):
for attempt in range(attempts):
r = requests.get(BASE + path, params=params, headers=HEADERS, timeout=30)
if r.ok:
return r.json()
err = r.json().get("error", {})
code = err.get("code", "")
if code not in RETRYABLE or attempt == attempts - 1:
raise ApiError(code, err.get("message", ""), err.get("request_id"))
# Server-supplied delay wins; otherwise exponential backoff with jitter so a fleet of
# workers doesn't retry in lockstep.
wait = float(r.headers.get("Retry-After") or 2 ** attempt)
time.sleep(wait + random.uniform(0, 0.5))
raise AssertionError("unreachable")
```
Two details that matter:
**`Retry-After` wins.** We know when the bucket refills; your backoff curve is a guess. Only fall
back to exponential when the header is absent.
**Add jitter.** Without it, every worker that hit the limit at the same moment retries at the same
moment, and you re-trigger the limit as a group.
## Log the request id
Every response — success or failure — carries `X-Request-Id`, and it's repeated in `error.request_id`
on failures. Log it. With it we can find the exact request; without it, a support conversation
starts from a timestamp and a guess.
```python
r = requests.get(url, headers=HEADERS)
log.info("api call", extra={"request_id": r.headers.get("X-Request-Id"), "status": r.status_code})
```
## Don't retry a 400 by changing nothing
An `invalid_parameter` will fail identically forever. The one 400 worth handling programmatically is
`invalid_cursor`: drop the cursor and restart that walk from the first page.
## Empty is not an error
A filter matching nothing, a match with no odds, or a source you aren't entitled to all return `200`
with `data: []`. Branch on emptiness, never on status, for those.
---
# Fetch everything about one match
Source: https://docs.esportsodds.gg/docs/how-to/fetch-a-full-match
One request instead of five, and how to skip the sub-resources that don't exist.
A match view usually needs the fixture, both teams, the tournament and the odds. Done naively
that's five requests. It can be one.
## Use include=
```bash
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/matches/019f3d18-c15f-7319-81a7-343e8a80a578?include=teams,tournament,odds"
```
The response gains `team_a`, `team_b`, `tournament` and `odds` alongside the usual match fields.
Without `include=` those keys are **absent**, not `null`, so existing clients are unaffected.
Unknown expansion names are ignored rather than rejected — adding one later can't break you.
## Then fetch only the sub-resources that exist
`include=` covers the related *entities*. The CS2 detail layers are separate endpoints, and not
every match has every layer. Read `data_available` first:
```python
m = get(f"/v1/cs2/matches/{match_id}?include=teams,tournament,odds")["data"]
print(f"{m['team_a']['name']} vs {m['team_b']['name']} — {m['tournament']['name']}")
avail = m["data_available"]
maps = get(f"/v1/cs2/matches/{match_id}/maps")["data"] if avail["maps"] else []
stats = get(f"/v1/cs2/matches/{match_id}/stats")["data"] if avail["stats"] else []
rounds = get(f"/v1/cs2/matches/{match_id}/rounds")["data"] if avail["rounds"] else []
depth = get(f"/v1/cs2/matches/{match_id}/depth")["data"] if avail["depth"] else None
```
Every skipped call is a request you keep. Requesting a layer that doesn't exist isn't an error — it
returns `200` with an empty list — but it still costs you one.
## Find matches that have what you need
If you're building a dataset rather than rendering one match, filter at the list level so you never
fetch a match you'd discard:
```bash
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/matches?status=completed&has=rounds,depth&limit=100"
```
`has=` takes any subset of `maps,stats,vetoes,rounds,depth`, AND-ed.
## Don't double-count the stats
`/stats` returns one row per player per map **plus** a whole-match aggregate row per player, with
`map_number: null` on the aggregate:
```python
per_map = [s for s in stats if s["map_number"] is not None]
aggregate = [s for s in stats if s["map_number"] is None]
```
Summing everything counts each player twice. See [player match stats](/docs/cs2-data/match-stats).
## Cost comparison
| Approach | Requests |
| --- | --- |
| Match, both teams, tournament, odds separately | 5 |
| `?include=teams,tournament,odds` | **1** |
| Plus three detail layers, blind | 4 |
| Plus three detail layers, gated on `data_available` | 0–3 |
For a page rendering 20 matches that's the difference between 100 requests and 20 — 0.5% of your
month instead of 2.5%.
---
# Build a fixtures cache
Source: https://docs.esportsodds.gg/docs/how-to/fixtures-cache
Keep a local schedule in sync for a few hundred requests a month.
The fixture list changes about **6 times an hour** across the whole corpus. A cache refreshed on a
slow timer stays effectively current for a fraction of your allowance.
## The shape
Fetch a forward window, upsert by `id`, and re-read on a slow interval.
```python
import datetime as dt
def refresh_fixtures(db, days_ahead=14, days_back=2):
today = dt.date.today()
params = {
"date_from": (today - dt.timedelta(days=days_back)).isoformat(),
"date_to": (today + dt.timedelta(days=days_ahead)).isoformat(),
"limit": 500,
}
for match in paginate("/v1/cs2/matches", **params):
db.upsert_match(match) # keyed on match["id"]
```
Include a couple of days *back*: a match that finished after your last refresh needs its final score
and `winner_team_id` picked up.
## What it costs
A 16-day window is a few hundred matches — one or two pages at `limit=500`. Hourly that's ~720–1,440
requests a month, **4–7%** of a 20,000 plan. Every 6 hours it's under 1%.
Compare with polling `/matches` every 5 minutes: 8,640 requests, 43% of the plan, for a list that
changed six times an hour.
## Use the list row as-is
A list row already carries `team_a_name`, `team_b_name`, `team_a_short`, `team_b_short` and
`tournament_name`. You don't need to resolve teams separately to render a schedule — that's the main
reason to cache the list shape rather than detail rows.
It also carries `data_available`, so your cache knows which matches have round or depth data without
asking.
## Detect what changed
`updated_at` moves whenever a match row changes. Store it and you can act only on real changes:
```python
existing = db.get_match(match["id"])
if existing and existing["updated_at"] == match["updated_at"]:
continue # nothing changed; skip downstream work
```
That won't save you API requests — you fetched the page either way — but it saves recomputation and
lets you fire notifications only on genuine transitions.
## Live matches need a different cadence
A `live` match's score changes far faster than the fixture list. Two options:
**Poll just the live ones**, which is a much smaller query:
```python
live = get("/v1/cs2/matches?status=live&limit=100")["data"]
```
At one call a minute that's 43,200/month — too much on its own. Every 5 minutes it's 8,640.
**Or open a WebSocket** and subscribe to the ids you care about. One ticket mint, then score and odds
changes are pushed at no further request cost. For live data this is almost always the right answer
— see [connect a WebSocket](/docs/how-to/connect-websocket).
## Suggested cadences
| What | Interval | Requests/month |
| --- | --- | --- |
| Fixture window | Every 6 hours | ~120 |
| Live matches | WebSocket | ~30 (reconnect ticket mints) |
| Odds | Every 5 minutes, or WebSocket | 8,640 / ~0 |
That's a complete integration inside half the plan.
---
# Migrate from a scraper
Source: https://docs.esportsodds.gg/docs/how-to/migrate-from-scraping
Mapping the concepts you already have onto this API, and what changes.
If you're arriving from a scraped HTML pipeline, most of the work is mapping vocabulary. Here's the
translation.
## Concept mapping
| Scraper concept | Here |
| --- | --- |
| Match page URL | `id` (UUIDv7) or a list filter |
| Team page slug | `slug` — works directly as a path id |
| Event page | `tournament_id`, or `?tournament=` on the match list |
| Scoreboard table | `/matches/{id}/stats` |
| Round history graphic | `/matches/{id}/rounds` |
| Map veto box | `/matches/{id}/vetoes` |
| Rating column | `rating` on a stat line — **ours**, not a third party's |
| Odds comparison table | A single de-vigged line; per-book prices are not served |
## What gets easier
**No parsing, no breakage on redesign.** Field names are a versioned contract locked by tests.
**Pagination that doesn't skip rows.** Cursors are stable under concurrent inserts, unlike
`?page=N` over a table that's being written to.
**Coverage is declared.** `data_available` on each match tells you which layers exist, and
`/v1/cs2/coverage` states what the product does and doesn't have — including what's not built yet.
**Change is pushed.** A WebSocket subscription replaces a polling loop entirely.
## What's different
**No per-book odds.** The API serves one de-vigged aggregate line (`eo_market`) with a `book_count`,
never individual bookmaker prices or names. If your pipeline compared books, that's not something
this API replaces — see [the market line](/docs/concepts/market-line).
**Requests are metered.** A scraper's cost is bandwidth; here it's 20,000 requests a month. The
habit to unlearn is polling everything on one timer — see
[budget your requests](/docs/how-to/request-budget).
**`rating` is ours.** It is not the number you scraped from elsewhere and won't match it. Compare
trends, not absolute values.
**`logo_url` is always empty.** We hold no redistribution rights to team marks.
## A first migration
**Map your entities.** Walk `/v1/cs2/teams` and `/v1/cs2/players` once at `limit=500` and store the
UUIDs against whatever keys you already use. A few requests, done weekly.
**Replace your fixture scrape** with a cached `/v1/cs2/matches` window — see
[build a fixtures cache](/docs/how-to/fixtures-cache). The list row already carries team and
tournament names, so most schedule rendering needs nothing else.
**Replace per-match scrapes** with `?include=teams,tournament,odds` plus the detail layers you
actually use, gated on `data_available`.
**Replace your live poller** with a WebSocket subscription. This is usually the biggest single
saving.
## Backfilling what you already have
You probably have history you don't want to re-fetch. Match ids won't line up with your old keys, so
join on `(scheduled_at, team names)` for a one-off reconciliation, then store our UUIDs and use them
from then on. [Backfilling history](/docs/how-to/backfill-history) covers pacing the walk.
---
# Recover from disconnects
Source: https://docs.esportsodds.gg/docs/how-to/recover-from-disconnects
Sequence numbers, gap detection, and why there is no replay buffer.
Every server→client frame carries `seq`, a per-`(connection, match)` counter. A `snapshot` is `seq`
0; each following `update` increments.
## Detect a gap
```js
const lastSeq = new Map()
function onFrame(frame) {
if (frame.type === 'snapshot') {
lastSeq.set(frame.match_id, 0)
replaceState(frame.match_id, frame.data)
return
}
if (frame.type !== 'update') return
const expected = (lastSeq.get(frame.match_id) ?? 0) + 1
if (frame.seq !== expected) {
// We missed something. Re-subscribing re-baselines with a fresh snapshot.
ws.send(JSON.stringify({ type: 'subscribe', data: { match_ids: [frame.match_id] } }))
return
}
lastSeq.set(frame.match_id, frame.seq)
applyUpdate(frame.match_id, frame.data)
}
```
The recovery for a gap is always the same: **re-subscribe**. Every subscribe begins with a fresh
`snapshot`, so your state is rebuilt from scratch rather than patched.
## There is no replay buffer
You cannot resume from your last `seq`, and you don't need to. The snapshot makes a reconnect
*correct*, not merely tolerable — which is a deliberate trade: a replay buffer would add server
state, a retention window and a new class of "how far back can I go" bugs, to solve a problem a
snapshot already solves.
## Reconnect with backoff
```js
let attempt = 0
async function connect() {
const { data } = await mintTicket() // POST /v1/cs2/ws-token — one metered request
const ws = new WebSocket(`wss://api.esportsodds.gg/v1/ws?token=${data.token}`)
ws.addEventListener('open', () => {
attempt = 0
ws.send(JSON.stringify({ type: 'subscribe', data: { match_ids: [...watched] } }))
})
ws.addEventListener('close', () => {
const wait = Math.min(30_000, 2 ** attempt * 500) + Math.random() * 500
attempt += 1
setTimeout(connect, wait)
})
}
```
Cap the backoff (30s here) and add jitter, so a fleet of clients that dropped together doesn't
reconnect in lockstep.
If the close was preceded by an `error` frame with `code: "connection_limit"`, you are at your
five-connection cap. Reconnecting immediately just burns ticket mints against your quota. Wait,
and check whether an old connection is failing to close.
## Unsolicited snapshots are normal
When our internal feed reconnects, every live subscription gets a fresh `snapshot` you didn't ask
for. It is not an error and not a duplicate to suppress — handle `snapshot` as "replace this match's
state" and it costs you nothing.
## Ticket reuse
A ticket is valid for 60 seconds and is not single-use. A reconnect inside that window can reuse the
one you have; only mint again once it's expired. Each mint is a metered request, so a tight
reconnect loop that mints every time is a real cost.
---
# Budget your requests
Source: https://docs.esportsodds.gg/docs/how-to/request-budget
20,000 a month is about 28 an hour. Here is the arithmetic, not a reassurance.
Your monthly allowance is **20,000 requests across roughly 720 hours** — about **28 requests an
hour**, for everything. Design against that number; you will almost never reach the per-second rate
limit.
## The one fact that decides your budget
Fixtures and odds move at completely different speeds. Measured over a recent 7-day window of
production data:
| Endpoint | Writes per hour |
| --- | --- |
| `/v1/cs2/matches` | **~6** |
| `/v1/cs2/odds` | **~665** |
That's roughly **110×**. Polling both on the same timer spends most of your allowance re-reading a
fixture list that hasn't changed.
## Two 30-day plans
| Strategy | Requests/month | Share of plan |
| --- | --- | --- |
| Both endpoints every 5 minutes | 17,280 | **86%** |
| Odds every 5 minutes, fixtures hourly | 9,360 | **47%** |
Same odds freshness, a bit over half the cost. And note what the first row means honestly: a
5-minute poll on both consumes most of the plan before you make a single detail call.
## Work out your own number
```
requests/month = (60 / minutes_between_polls) × 24 × 30 × endpoints_polled
```
Some anchors:
| Interval | Requests/month (one endpoint) |
| --- | --- |
| Every minute | 43,200 — over budget on its own |
| Every 5 minutes | 8,640 |
| Every 15 minutes | 2,880 |
| Hourly | 720 |
| Daily | 30 |
## Spending less
**Open a WebSocket instead of polling for changes.** One ticket mint is one request; the connection
then pushes score and odds changes for as long as it stays open at no further request cost. For
anything change-driven this is strictly cheaper than a timer — see
[connect a WebSocket](/docs/how-to/connect-websocket).
**Poll fixtures slowly.** Hourly is ample at ~6 writes/hour; for many uses a daily schedule refresh
is fine.
**Filter server-side.** `?status=live`, `?date_from=`/`?date_to=`, `?tournament=` cost the same one
request as an unfiltered call and save you paging through rows you'd discard.
**Ask for detail only where it exists.** `?has=rounds,depth` filters the list to matches that
actually have those sub-resources, so you never spend a request discovering there was nothing.
**Raise `limit`.** Walking 1,000 teams at `limit=100` is 10 requests; at `limit=500` it's 2.
**Expand instead of following links.** `?include=teams,tournament,odds` turns four requests into
one — see [fetching a full match](/docs/how-to/fetch-a-full-match).
## Know where you stand
Every response carries your position, so you never have to count client-side:
```bash
curl -sS -D - -o /dev/null -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/matches?limit=1" | grep -i x-quota
```
`X-Quota-Remaining` is the number to alarm on. Wire it into your own monitoring at, say, 20% — the
dashboard also emails at 80% and 100% if you leave those notifications on.
During a trial the allowance is **1,000**, counted from when the trial started rather than by
calendar month. That's sized for evaluating the API, not for bulk-loading history.
---
# Resolve a name to an id
Source: https://docs.esportsodds.gg/docs/how-to/resolve-by-slug
Going from "NAVI" to a UUID — or skipping the lookup entirely.
You rarely start with a UUID. You start with a name.
## Skip the lookup
Teams, players and tournaments accept a **slug** wherever a path id is expected, so if you know the
slug you don't need a lookup at all:
```bash
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/natus-vincere/h2h?opponent=faze"
```
Sub-resources and the `?opponent=` parameter accept slugs too. Matches have **no** slug — a match id
is always a UUID.
## When you have a display name, not a slug
Filter the list by slug and cache the result:
```python
import functools
@functools.lru_cache(maxsize=512)
def team_id(slug: str) -> str | None:
rows = get(f"/v1/cs2/teams?slug={slug}")["data"]
return rows[0]["id"] if rows else None
```
`?slug=` returns the single matching row — or an empty page — in the normal list envelope, and never
depends on paging position.
Cache aggressively: teams and players change far more slowly than anything else in the API, and a
cached id costs you nothing.
## Building a slug from a name
Slugs are lowercase and hyphenated, but not always a mechanical transform of the display name
(`Natus Vincere` → `natus-vincere`, but `FaZe` → `faze`). Guessing works often enough to be
dangerous and fails silently, returning an empty page.
If you're matching user input, walk the list once and build your own index:
```python
index = {}
for team in paginate("/v1/cs2/teams", limit=500):
index[team["name"].lower()] = team["id"]
index[team["slug"]] = team["id"]
if team["short_name"]:
index.setdefault(team["short_name"].lower(), team["id"])
```
At `limit=500` that's a handful of requests for the whole roster, refreshable weekly.
## Store the UUID, display the slug
Slugs are stable but **not guaranteed permanent** — an organisation rename can change one. For
anything you persist, store the UUID and treat the slug as a display convenience. A UUID never
changes.
---
# Track odds movement
Source: https://docs.esportsodds.gg/docs/how-to/track-odds-movement
Store a price series correctly, given lines are only written when they change.
## Two modes, pick deliberately
```bash
# Snapshot: latest line per outcome across every open market. NOT paginated.
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/odds?limit=1000"
# History: every captured line for one match, newest first, cursor-paginated.
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/odds?match=019f3d18-c15f-7319-81a7-343e8a80a578"
```
For a live board, poll the snapshot. For analysis of one match, walk its history.
## Append-on-change
A row is written **only when the price moves**. Two consequences:
An eleven-hour-old line means the price hasn't moved in eleven hours — that's information, not
rot. A recency filter that drops "old" lines will drop valid prices for quiet markets. Judge
whether the feed is healthy by whether *any* market is updating, not by one line's age.
And your storage should be idempotent: re-polling the snapshot returns the same row until something
changes, so key on the line's `id` and ignore duplicates.
```python
for line in get("/v1/cs2/odds?limit=1000")["data"]:
db.insert_ignore(line) # primary key: line["id"]
```
## Group correctly
A match can have more than one market. Group by `(market_id, outcome_key, line)` before treating
anything as a series — otherwise you interleave a moneyline with a handicap and see movement that
isn't there.
```python
from collections import defaultdict
series = defaultdict(list)
for l in history:
series[(l["market_id"], l["outcome_key"], l["line"])].append(l)
for key, rows in series.items():
rows.sort(key=lambda r: r["captured_at"]) # history is newest-first
```
## Movement without the history
Every line already carries its own movement, computed per response:
- `open_price` — the first price in that series.
- `delta_since_open` — `price − open_price`. Positive drifted out, negative shortened.
So a single snapshot call tells you how far every market has moved since opening, without fetching
one historical row.
## Closing lines
`is_closing` marks the last price before `scheduled_at`. Settled rows also carry `is_winner`, so
collecting closing lines for completed matches gives you prediction and outcome together — the
standard basis for scoring a forecast.
```python
closing = [l for l in history if l["is_closing"]]
for l in closing:
implied = 1 / l["price"]
print(f"{l['label']:20} closed {l['price']:6.2f} p={implied:.3f} won={l['is_winner']}")
```
## Sanity check your parsing
Because the line is de-vigged, implied probabilities across a market's outcomes sum to ~1.0:
```python
p = sum(1 / l["price"] for l in one_market_latest)
assert 0.98 < p < 1.02, f"expected ~1.0, got {p}"
```
If you get ~1.05, you've probably mixed two markets into one group. See
[the market line](/docs/concepts/market-line).
## Cost
The snapshot is one request regardless of how many markets are open. At `limit=1000` every 5
minutes that's 8,640 requests a month — 43% of the plan, and the main line item in most
integrations. A WebSocket removes it entirely.
---
# Live data & WebSocket
Source: https://docs.esportsodds.gg/docs/live-data
Subscribe to CS2 score and odds updates over a WebSocket channel.
Alongside the REST API, a **WebSocket channel** pushes updates — score changes and moving odds —
so you don't have to poll `/v1/{game}/matches` and `/v1/{game}/odds` on a timer. A single
connection multiplexes many matches: you say which matches you care about with a `subscribe`
message rather than opening a socket per match.
## Connecting
Connecting is a two-step handshake so your raw API key never appears in a socket URL. First, your
backend mints a short-lived (60s) connection ticket:
```bash
curl -X POST -H "Authorization: Bearer YOUR_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/ws-token"
```
```json
{
"data": {
"token": "MDE4ZjJjNGEtOWIzMS03ZTAyLWE1ZDctM2MxZjg4YjQwZTE5LjE3ODMwMTUyNjAuaHFKSWtWNUJBV2xXeUdTV2xwTG14OFA3YTFtTUlEaWQtM0tscjhDWFNINA",
"expires_at": "2026-07-02T18:01:00Z"
}
}
```
The ticket is an opaque base64url string, **not a JWT** — don't try to parse it or read claims out
of it. Treat it as a bearer credential with a 60-second lifetime.
Then open the WebSocket to `/v1/ws` with that ticket:
```js
const ws = new WebSocket(`wss://api.esportsodds.gg/v1/ws?token=${token}`)
```
The key's status is re-checked at handshake time, so a revoked key can't connect on an old ticket.
A ticket is **not** single-use — it stays valid for its full 60 seconds, so a retried connection
attempt inside that window can reuse it. Mint a fresh one once it expires.
`Authorization: Bearer` is **not** accepted on the WebSocket handshake — browsers can't set headers
on a `WebSocket` connection, which is exactly why tickets exist. (`?apiKey=YOUR_API_KEY` on the
handshake still works as a deprecated fallback for backend-to-backend callers; it will be removed —
mint tickets instead.)
> As with REST, keep your key server-side. For browser clients, have your backend mint the ticket
> and hand only the ticket to the client — exactly what the two-step flow above is for.
## The message envelope
Every frame, in both directions, is a single JSON object with the same shape:
```json
{ "type": "…", "match_id": "…", "seq": 0, "data": { } }
```
| Field | Meaning |
| --- | --- |
| `type` | The message kind (see below). |
| `match_id` | The match the message concerns. Absent on connection-level frames (`error`, subscription acks). |
| `seq` | A per-`(connection, match)` sequence number on server→client match messages. A `snapshot` is `seq` 0; each following `update` increments (1, 2, 3…). Use it to detect a missed message — a gap means re-sync. |
| `data` | The type-specific payload. |
## Subscribing
Right after the socket opens, tell the server which matches you want:
```js
ws.send(JSON.stringify({ type: 'subscribe', data: { match_ids: ['018f...e2a1'] } }))
```
The server answers each `subscribe` with a **`snapshot`** for every match — the full current state,
the baseline every later `update` is a delta against — and then streams incremental `update`
messages as things change. Stop with `unsubscribe`:
```js
ws.send(JSON.stringify({ type: 'unsubscribe', data: { match_ids: ['018f...e2a1'] } }))
```
**Client → server messages**
| `type` | `data` | Meaning |
| --- | --- | --- |
| `subscribe` | `{ "match_ids": ["…"] }` | Begin receiving updates for these matches. The server replies with a `snapshot` per match, then one `subscribed` ack. Ids are de-duplicated; an unknown match is skipped silently rather than erroring. |
| `unsubscribe` | `{ "match_ids": ["…"] }` | Stop; the server acks with `unsubscribed`. |
| `pong` | — | Optional app-level keep-alive reply. Answering the server's native WebSocket ping (which most clients do automatically) is equally accepted — see [Keep-alive](#keep-alive). |
An unknown `type`, or malformed JSON, is **ignored** — never fatal to the connection, and never
answered with an error frame. Inbound frames are capped at **8 KiB**, which is sized for a
many-id `subscribe`; exceeding it closes the connection.
## Snapshots and updates
**Server → client messages**
| `type` | `data` | Meaning |
| --- | --- | --- |
| `snapshot` | `{ "match": { … }, "odds": [ … ] }` | The full current state for a match — sent on subscribe and on every re-sync. The baseline for later updates. Always `seq` 0. |
| `update` | `{ "odds": [ … ] }` or `{ "match": { … } }` | An incremental change: moved odds, or a score/status change. `seq` increments. |
| `subscribed` / `unsubscribed` | `{ "match_ids": ["…"] }` | Subscription acknowledgements. `seq` 0. |
| `error` | `{ "code": "…", "message": "…" }` | A coded error, followed immediately by the socket closing. Today the only code is `connection_limit`, and it can only occur at connect time — see [Metering & connection cap](#metering--connection-cap). |
A `snapshot` re-baselines your local state; apply an `update` on top of the last snapshot.
**A `snapshot` can arrive unsolicited.** When the server's internal data feed reconnects, every live
subscription is re-baselined with a fresh `snapshot` — you didn't ask for it, and it isn't an error.
Handle `snapshot` as "replace this match's state" wherever it arrives and this is free. Handle
messages like this:
```js
ws.addEventListener('message', (event) => {
const frame = JSON.parse(event.data)
switch (frame.type) {
case 'snapshot':
// frame.data.match + frame.data.odds — replace this match's local state
setMatch(frame.match_id, frame.data.match, frame.data.odds)
break
case 'update':
// frame.data.odds — moved odds; frame.data.match — a score/status change
if (frame.data.odds) applyOdds(frame.match_id, frame.data.odds)
if (frame.data.match) applyMatch(frame.match_id, frame.data.match)
break
case 'error':
// Always followed by a close. `connection_limit` means back off — don't reconnect immediately.
console.warn(frame.data.code, frame.data.message)
break
}
})
```
There is no keep-alive branch to write: the server uses **native** WebSocket pings, which browsers
and every mainstream client library answer for you.
## Sequence numbers & reconnection
`seq` exists so you never have to assume no message was missed. Track the last `seq` you saw per
match; if the next `update` skips a number, treat your local state as stale and **re-sync** — the
simplest way is to `subscribe` to that match again, since every subscribe begins with a fresh
`snapshot`.
Networks drop. On any disconnect, reconnect with exponential backoff and jitter, re-send your
`subscribe`, and rebuild state from the resulting `snapshot`. Reuse your ticket if it's still inside
its 60-second window; mint a fresh one otherwise. There is **no server-side replay buffer** — you
cannot resume from your last `seq`, and you don't need to: the snapshot makes a reconnect correct,
not merely tolerable.
## Keep-alive
The server sends a **native WebSocket ping every 54 seconds** and closes any connection it hasn't
heard from in **60 seconds**. There is no JSON `heartbeat` message — don't write a handler for one.
Browsers and mainstream client libraries reply to a native ping automatically, so in most clients
this needs no code at all. If your library doesn't, or if you're behind an intermediary that strips
pings, send `{"type":"pong"}` yourself at least once a minute — it refreshes the same read deadline.
A closed connection frees its slot against your connection cap immediately, and the ping traffic
also stops intermediaries from idling the connection out.
## What the channel carries
The `odds` a `snapshot` or `update` carries are the **derived lines only** — the `eo_market`
de-vigged aggregate (combined from multiple bookmakers and exchanges) and, once it clears
validation, the `eo_model` line — exactly as with REST. Contributing books are never named and no
per-book price is ever sent.
Updates are pushed as the underlying data changes — driven by match ingestion, not a fixed clock —
so judge a value's freshness by when you received it, not by an assumed interval. The channel
signals *that data is fresh*; it is not a countdown or a prompt to act.
To set expectations honestly: today the channel carries **ingestion-cadence** updates, at the speed
our own collection runs, which for the market line has averaged around 665 line writes an hour
across all open markets over the last week. Sub-second tick frequency depends on a separate
match-data service that ships in a later release. The connection path, the envelope and the
sequencing are all final — only the tick rate changes when that lands, so anything you build
against this protocol keeps working.
## Metering & connection cap
WebSocket access is a **tier-gated feature**, not per-message metered:
- **Concurrent-connection cap.** On the standard plan a key may hold **5 concurrent connections**.
Opening a sixth is refused with an `error` frame (`code: "connection_limit"`) followed by a close
— not a queue. This is the only `error` frame the server sends, and it only ever arrives at
connect time; an established connection is never terminated by one.
- **The WebSocket costs no request quota.** Minting a ticket (`POST /v1/{game}/ws-token`), opening
the connection, and every message pushed over it are all free of your monthly and trial
allowances. Tickets expire after 60 seconds, so a reconnecting client mints a new one each time —
that reconnection is free too. The ticket mint does still draw on the per-second **rate limit**,
which is why reconnecting with backoff matters.
- **Disconnecting frees the slot immediately**, including a keep-alive timeout close.
Reconnect with backoff rather than in a tight loop, and reuse one connection across many matches
(that's what `subscribe` is for) rather than opening one per match.
---
# CORS and browser clients
Source: https://docs.esportsodds.gg/docs/platform/cors
Which origins are allowed, which headers are readable, and why you should proxy anyway.
## The short version
**Call the API from your backend, not from your users' browsers.** Not primarily because of CORS,
but because a key in browser JavaScript is a key you have published.
## What's allowed
The API permits a small, fixed set of origins — this documentation site among them, which is what
makes the **Send** button on each reference page work. Your own site's origin is not on that list,
so a direct browser call from your app will fail its preflight.
That's deliberate. An allowlist that anyone could join would encourage exactly the pattern that
leaks keys.
## The right shape
```
Browser ──▶ your backend ──▶ api.esportsodds.gg
(holds the key)
```
Your backend holds the key, calls the API, and returns only what your UI needs. You get caching, a
place to enforce your own limits, and a key that never leaves your infrastructure.
## For live data in a browser
The WebSocket is designed for exactly this. Your backend mints a short-lived ticket; the browser
connects with the ticket, never the key:
```js
// Browser — never sees the API key
const { data } = await fetch('/api/ws-token', { method: 'POST' }).then((r) => r.json())
const ws = new WebSocket(`wss://api.esportsodds.gg/v1/ws?token=${data.token}`)
```
```js
// Your backend
app.post('/api/ws-token', async (req, res) => {
const r = await fetch('https://api.esportsodds.gg/v1/cs2/ws-token', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.ESPORTSODDS_API_KEY}` },
})
res.json(await r.json())
})
```
The ticket is valid for 60 seconds and grants only a WebSocket connection. `Authorization: Bearer`
isn't accepted on the WS handshake at all — browsers can't set headers on a `WebSocket`, which is
why tickets exist.
## Where CORS does apply
For allowed origins the API responds to a preflight `OPTIONS` with:
- `Access-Control-Allow-Origin` — the echoed origin
- `Access-Control-Allow-Methods: GET, POST, OPTIONS`
- `Access-Control-Allow-Headers: Authorization, Content-Type`
- `Access-Control-Expose-Headers` — `X-Request-Id` plus the rate-limit and quota headers, so a
browser client can read its remaining allowance, and can still quote a request id on a `401`
where the quota headers are absent
Without that last one a cross-origin `fetch` can read the body but none of the `X-Quota-*` values —
a subtle failure worth knowing about if you build a browser client against any API.
## Credentials
No cookies, no sessions, no `Access-Control-Allow-Credentials`. Authentication is a bearer token on
each request, so there is no ambient credential to leak cross-site.
---
# Versioning and deprecation policy
Source: https://docs.esportsodds.gg/docs/platform/deprecation
What we promise about /v1, how changes are announced, and what counts as breaking.
## /v1 is additive-only
Within `/v1` we may **add**: new endpoints, new optional query parameters, new fields on an existing
response. We will not **remove** or **rename** a field, change a field's type, change an endpoint's
URL, or change the meaning of an existing value.
So write clients that ignore unknown fields. A new field appearing is not a breaking change, and
strict schema validation that rejects unknown keys will break on a change we consider safe.
## What is not covered
Two things are deliberately outside the contract:
**`error.message` is prose.** Wording may change at any time. Branch on `error.code`, which is
stable.
**Ordering without an explicit `sort`.** Each endpoint documents its default order and we don't
intend to change it, but rely on `sort=` if the order is load-bearing for you.
## If a breaking change is ever needed
It ships as `/v2`, alongside `/v1` rather than replacing it. `/v1` would then get a deprecation
window with:
1. An entry in the [changelog](/docs/changelog).
2. An email to accounts using the affected surface, if you have changelog notifications on.
3. A stated sunset date, not a surprise.
Nothing has been deprecated to date.
## Recent changes worth knowing
Two changes landed on 2026-07-26 that were previously documented as planned:
- **The error envelope became coded.** It was `{"error": "message"}`; it is now
`{"error": {"code", "message", "request_id"}}`. Clients string-matching the old flat value need
updating — this is why `code` exists.
- **Slug addressing, `sort=` and `include=`** went from documented-as-target to shipped.
Both were reconciled with the reference in the same change, so nothing in this documentation
describes unshipped behaviour any more.
## Game namespacing
`/v1/{game}/{resource}` is game-agnostic by construction. A second title extends the same paths
rather than replacing them, so adding one is additive for you: your `cs2` calls keep working
unchanged.
A game that isn't onboarded returns `404 unknown_game` — a clean answer, not an error.
## Status and incidents
Availability, incident history and planned maintenance are on the
[status page](https://status.esportsodds.gg). See
[status and support](/docs/platform/status-and-support).
---
# Response headers
Source: https://docs.esportsodds.gg/docs/platform/response-headers
Every header the API sets, what it means, and which ones a browser can read.
## On every response
| Header | Meaning |
| --- | --- |
| `X-Request-Id` | A UUID identifying this request. **Log it.** It's repeated in `error.request_id` on failures, and it's what turns a support question into one log lookup. |
| `Content-Type` | Always `application/json; charset=utf-8`. |
## On every metered response
Metered means any authenticated `/v1/...` call — so everything except `/health`.
| Header | Meaning |
| --- | --- |
| `X-RateLimit-Limit` | Your token bucket's burst capacity (100 on the standard plan). |
| `X-RateLimit-Remaining` | Whole tokens left after this request. |
| `X-Quota-Limit` | Requests included in the current window (20,000/month; 1,000 on a trial). |
| `X-Quota-Remaining` | Requests left in the window, **across every key on the account**. |
| `X-Quota-Reset` | When the window ends — RFC 3339, UTC. Start of next month, or the trial's end. |
## On a 429
`Retry-After`, in **seconds**. A rate-limit 429 gives a second or two; a quota 429 can give days.
Which one you got is in `error.code` — see [errors](/docs/errors).
Always honour `Retry-After` over your own backoff curve: we know when the bucket refills, your curve
is a guess.
## Reading them from a browser
Custom headers are hidden from cross-origin `fetch` unless the server names them. The API sets:
```
Access-Control-Expose-Headers: X-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining,
X-Quota-Limit, X-Quota-Remaining, X-Quota-Reset, Retry-After
```
so all seven are readable where CORS applies — including `X-Request-Id`, which is the one you want
on a failure, since the rate-limit and quota headers are absent on a `401`. See
[CORS](/docs/platform/cors).
## Checking your position
```bash
curl -sS -D - -o /dev/null \
-H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/matches?limit=1" \
| grep -iE "x-quota|x-ratelimit|x-request-id"
```
`X-Quota-Remaining` is the one to alarm on in your own monitoring.
## What isn't set
No `ETag`, `Last-Modified` or conditional-request support, and no `Cache-Control`. Responses are not
cacheable by an intermediary — cache in your own layer with a TTL you choose, informed by
[how often data actually changes](/docs/how-to/request-budget).
No compression negotiation either. Responses are uncompressed JSON.
There is no `X-RateLimit-Reset`; the bucket refills continuously rather than at a boundary, so
`Retry-After` on a 429 is the meaningful signal.
---
# Status and support
Source: https://docs.esportsodds.gg/docs/platform/status-and-support
Checking whether it's us, and what to include when it is.
## Is the API up?
```bash
curl -sS https://api.esportsodds.gg/health
# {"status":"ok"}
```
`/health` needs no API key, isn't metered, and returns `503` with `{"status":"unavailable"}` when
the service is up but can't reach its database. It's the right thing to point a monitor at.
Note it's the one endpoint without the `{data, meta}` envelope — it's a probe, and keeping it
trivial is the point.
For history and incidents: [status.esportsodds.gg](https://status.esportsodds.gg).
## Is it me?
Work down this list before reporting a problem:
| Symptom | Most likely cause |
| --- | --- |
| Everything 401s | Key not sent as `Authorization: Bearer `, or rotated more than 24h ago |
| Everything 429s | Check `error.code` — `quota_exceeded` means out of requests, not overloaded |
| One endpoint 404s | A malformed id, or a slug on `/matches` (matches have no slug) |
| Empty arrays everywhere | Filters too narrow, or a sub-resource with no data — check `data_available` |
| `eo_model` returns nothing | Expected — the model line isn't served yet |
[Troubleshooting](/docs/help/troubleshooting) covers each of these properly.
## What data coverage exists
`/v1/{game}/coverage` states what the product actually has, per capability, with an honest status:
```bash
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/coverage"
```
`live` means available now. `pending_validation` means built but held back until it clears an
accuracy bar. `schema_ready` means the shape exists but isn't populated. `planned` means intended,
not built. `licence_required` means blocked on rights rather than engineering.
It's a deliberately unflattering endpoint — it tells you what we don't have as clearly as what we
do, so you can find out before you build rather than after.
## Reporting a problem
Include the **`X-Request-Id`**. Every response carries one, and it's repeated in `error.request_id`
on failures. With it we can find the exact request in our logs; without it we're matching on a
timestamp.
A good report is: the request id, the URL you called (minus your key), what you expected, and what
you got.
## Notifications
Your dashboard controls email for quota warnings at 80% and 100%, payment receipts, and changelog
entries for API changes. The changelog one is worth leaving on — it's how a deprecation would reach
you.
---
# Quickstart
Source: https://docs.esportsodds.gg/docs/quickstart
From an API key to live matches, odds, and a WebSocket stream in four steps.
import { TrackQuickstartView } from '@/components/TrackQuickstartView'
This walks the full path a typical integration takes: authenticate, list what's live, pull the
market odds for a match, then (optionally) stream updates instead of polling. Every call is a plain
`GET` (or one `POST` for the WebSocket ticket) over HTTPS, and every response is the same
`{ "data": ..., "meta": ... }` envelope with **snake_case** fields.
## 1. Authenticate
Every request carries your key as a Bearer token. Create a key in the dashboard, then:
```bash
export EO_KEY="eo_live_..."
curl -H "Authorization: Bearer $EO_KEY" "https://api.esportsodds.gg/v1/cs2/matches?limit=1"
```
See [Authentication](/docs/authentication) for the header, the deprecated `?apiKey=` fallback, and
how keys are stored.
## 2. List live matches
```bash
curl -H "Authorization: Bearer $EO_KEY" \
"https://api.esportsodds.gg/v1/cs2/matches?status=live"
```
```json
{
"data": [
{
"id": "018f...e2a1",
"status": "live",
"format": "bo3",
"team_a_id": "018f...aa10",
"team_b_id": "018f...bb20",
"team_a_name": "Natus Vincere",
"team_b_name": "FaZe Clan",
"tournament_name": "IEM Katowice 2026",
"score_a": 1,
"score_b": 0,
"scheduled_at": "2026-07-01T18:00:00Z"
}
],
"meta": { "count": 1, "next_cursor": null }
}
```
Narrow with `status`, `tournament`, `team`, and `date_from`/`date_to`; page with `limit` + `cursor`
(pass `meta.next_cursor` back as `?cursor=`). Keep a match `id` for the next step.
## 3. Get the market odds
```bash
curl -H "Authorization: Bearer $EO_KEY" \
"https://api.esportsodds.gg/v1/cs2/odds?match=018f...e2a1"
```
```json
{
"data": [
{ "source": "eo_market", "outcome_key": "home", "label": "Natus Vincere", "price": 1.86, "book_count": 4, "match_id": "018f...e2a1", "captured_at": "2026-07-01T18:05:00Z" },
{ "source": "eo_market", "outcome_key": "away", "label": "FaZe Clan", "price": 1.98, "book_count": 4, "match_id": "018f...e2a1", "captured_at": "2026-07-01T18:05:00Z" }
],
"meta": { "count": 2, "next_cursor": null }
}
```
Each line carries an explicit `source`: `eo_market` is the de-vigged multi-book line (with
`book_count`); `eo_model` is our modeled line once it clears validation. Add `?source=eo_market` to
request just one. Contributing books are never named — see the [methodology](/docs#odds-methodology-briefly).
## 4. Stream updates instead of polling
For a live match, open the WebSocket channel and let updates come to you rather than re-polling
steps 2–3 on a timer. It's a two-step ticket handshake so your key never appears in a socket URL:
```bash
curl -X POST -H "Authorization: Bearer $EO_KEY" \
"https://api.esportsodds.gg/v1/cs2/ws-token"
# → { "data": { "token": "eyJ...", "expires_at": "..." } }
```
Open the socket, `subscribe` to the match, and you'll get a full `snapshot` followed by incremental
`update` frames. Every frame is a `{ type, match_id, seq, data }` envelope:
```js
const ws = new WebSocket(`wss://api.esportsodds.gg/v1/ws?token=${token}`)
ws.addEventListener('open', () => {
ws.send(JSON.stringify({ type: 'subscribe', data: { match_ids: ['018f...e2a1'] } }))
})
ws.addEventListener('message', (e) => {
const frame = JSON.parse(e.data)
if (frame.type === 'snapshot') setState(frame.match_id, frame.data) // full baseline
if (frame.type === 'update') applyUpdate(frame.match_id, frame.data) // a delta
})
```
The full message types, sequence/reconnect semantics, and connection metering are covered in
[Live data & WebSocket](/docs/live-data).
## Next steps
- [Authentication](/docs/authentication) · [Rate limits](/docs/rate-limits) · [Errors](/docs/errors)
- The full interactive endpoint reference — with a "try it" panel per endpoint — is in the sidebar
under **API reference**.
---
# Rate limits
Source: https://docs.esportsodds.gg/docs/rate-limits
A per-key token bucket governs request rates; a hard monthly quota governs volume.
Two independent caps apply to every request:
1. **A rate limit** — a token bucket smooths short-term throughput: **20 requests per second
sustained, with a 100-request burst**. Applied **per API key**.
2. **A monthly quota** — the standard plan includes **20,000 requests per calendar month**,
enforced as a hard cap: requests past it are rejected until the month resets. Applied **per
account**, shared across all of that account's keys — creating a second key does not grant a
second allowance.
They bind independently: you can be well inside your monthly quota and still get a `429` for
bursting, and vice versa.
**How the bucket behaves.** The bucket holds 100 tokens and refills at 20 per second. Every request
takes one. A burst of up to 100 back-to-back requests goes straight through from a full bucket;
sustained traffic above 20/sec drains it and starts returning `429` until it refills. A short pause
restores capacity — five seconds of silence refills the bucket completely.
In practice the monthly quota binds long before the rate limit does: 20 requests per second is
1.7 million a day, and your monthly allowance is 20,000. Treat the rate limit as a guard against
runaway loops, and the quota as the number to actually design around — see
[how often to poll](#how-often-to-poll).
All keys see the same endpoints and data — the plan changes throughput and included volume, not
features.
### During a free trial
While your subscription is in its trial period there are **two** limits, counted from the start of
the trial rather than per calendar month:
| Limit | Value |
| --- | --- |
| Total trial requests | **5,000** |
| Any single endpoint | **1,000** |
The per-endpoint ceiling is what lets the total be generous. It is sized so a normal evaluation
never notices it — spreading a few thousand calls across matches, stats, odds, teams and players
stays comfortably inside it — while a script pointed at one endpoint to bulk-load history stops at
1,000 rather than draining the whole allowance.
A trial request past the **total** returns `429`:
```json
// 429 Too Many Requests
{
"error": {
"code": "trial_quota_exceeded",
"message": "trial request allowance reached — subscribe at https://esportsodds.gg/app/settings/plan to continue",
"request_id": "01a0..."
}
}
```
A request past the **per-endpoint** ceiling returns `429` with a different code, because your
overall allowance still has requests left — switch endpoints and you can keep working:
```json
// 429 Too Many Requests
{
"error": {
"code": "trial_endpoint_quota_exceeded",
"message": "this endpoint's trial allowance is used up (1000 of 1000 on /v1/{game}/odds). Your overall trial allowance still has requests left — try another endpoint, or subscribe at https://esportsodds.gg/app/settings/plan to lift both limits.",
"request_id": "01a0..."
}
}
```
Subscribing from that page ends the trial immediately, starts the paid period, lifts you to the full
20,000, and removes the per-endpoint ceiling entirely.
## What counts
Every authenticated REST request draws one token and one unit of monthly quota. When your bucket
is empty, further requests are rejected until it refills:
```json
// 429 Too Many Requests
{
"error": {
"code": "rate_limited",
"message": "rate limit exceeded",
"request_id": "01a0..."
}
}
```
Back off and retry after a short delay. Spikes are smoothed by the bucket's burst capacity; steady
throughput above your refill rate is what triggers `429`s.
When your monthly quota is used up, requests are rejected until the next calendar month starts:
```json
// 429 Too Many Requests
{
"error": {
"code": "quota_exceeded",
"message": "monthly quota exceeded",
"request_id": "01a0..."
}
}
```
## How often to poll
Your whole monthly allowance is **20,000 requests across roughly 720 hours** — about **28 requests
per hour**, for everything you do. Budget against that number, not against the per-second rate
limit, which you will almost never reach.
The single most useful thing to know is that **the fixture list and the odds move at completely
different speeds**. Measured over the last 7 days of production data:
| What you'd poll | How often it actually changes |
| --- | --- |
| `/v1/cs2/matches` — fixtures, scores, status | **~5.9 writes/hour** |
| `/v1/cs2/odds` — the market line | **~665 writes/hour** |
That's a **113×** difference, so polling them on the same timer wastes most of your allowance on an
endpoint that hasn't changed. Two 30-day examples:
| Strategy | Requests/month | Share of plan |
| --- | --- | --- |
| Both endpoints every 5 minutes | 17,280 | **86%** |
| Odds every 5 minutes, fixtures hourly | 9,360 | **47%** |
The second is the same odds freshness for a bit over half the cost. Note what the first row means
honestly: a 5-minute poll on both endpoints consumes most of the plan on its own, before any
per-match detail calls. If that's your access pattern, the numbers above are the ones to plan
around.
Some ways to spend less:
- **Open a WebSocket instead of polling for changes.** It costs **no** request quota at all — not
the ticket, not the connection, not the messages — and it pushes score and odds changes for as
long as it stays open. For anything change-driven this is not merely cheaper than a timer, it is
free. See [Live data & WebSocket](/docs/live-data).
- **Poll fixtures on a slow timer** — hourly is ample at ~6 writes/hour, and a daily schedule
refresh is fine for most uses.
- **Filter server-side.** `?status=live`, `?date_from=`/`?date_to=` and `?tournament=` cost the
same one request as an unfiltered call but save you paging through rows you'll discard.
- **Ask for detail only where it exists.** Each match row carries `data_available`, and
`?has=rounds,depth` filters the list to matches that actually have those sub-resources — so you
never spend a request discovering there was nothing to fetch.
## Response headers
Every metered response tells you where you stand — no need to count client-side:
| Header | Meaning |
| --- | --- |
| `X-RateLimit-Limit` | Your token bucket's capacity — the burst allowance (100 on the standard plan). |
| `X-RateLimit-Remaining` | Whole tokens left in the bucket after this request. |
| `X-Quota-Limit` | Your request quota (20,000/month on the standard plan; 1,000 during a trial). |
| `X-Quota-Remaining` | Requests left in the current window, across every key on the account. |
| `X-Quota-Reset` | When the window ends (RFC 3339, UTC — the start of next month; during a trial, the trial's end). |
Either kind of `429` also carries `Retry-After` — the number of seconds to wait before retrying
(a second or two for a rate-limit `429`; up to the end of the window for a quota `429`).
**Calling from a browser?** These are custom headers, so a cross-origin `fetch` can only read them
because the API names them in `Access-Control-Expose-Headers`. It does — all six above, plus
`Retry-After`. See [CORS and browser clients](/docs/authentication#calling-from-a-browser).
## Checking your usage
The headers above are authoritative per request; your dashboard shows the same request volume and
remaining allowance for each key. Rate-limit and quota enforcement live in the API itself; the
dashboard reads the same usage events the API records per request.
## WebSocket connections
The [WebSocket channel](/docs/live-data) is metered differently from REST — a persistent connection
isn't a stream of discrete requests, so the token-bucket model doesn't map cleanly onto it. It's a
**tier-gated feature**, capped by concurrent connections rather than metered per message:
- **Concurrent-connection cap.** On the standard plan a key may hold **5 concurrent connections**
(the cap is tier-based). Opening one past the cap is refused with an `error` frame
(`code: "connection_limit"`) followed by a close — not a queue.
- **Nothing about the WebSocket consumes request quota.** Neither `POST /v1/{game}/ws-token` nor
opening the connection draws on your monthly or trial allowance, and the messages pushed over an
open connection are not billed either — an open subscription accrues no per-message or per-ping
charges for its lifetime. Tickets are short-lived (60 seconds), so a client that reconnects mints
a new one each time; that reconnection is free. The concurrent-connection cap above is the only
limit that applies.
- **The ticket mint is still rate-limited.** It draws a token from the per-second bucket like any
other call, so a tight reconnect loop will still see `429`s — reconnect with backoff.
- **Disconnecting frees the slot immediately**, including a keep-alive timeout close.
> Reuse one connection across many matches (that's what `subscribe` is for) rather than opening one
> per match, and reconnect with backoff rather than in a tight loop.
---
# Versioning & game namespacing
Source: https://docs.esportsodds.gg/docs/versioning
The /v1/{game}/{resource} path convention, and how a second game extends it.
Every endpoint follows one path convention:
```
/v1/{game}/{resource}
```
- **`v1`** — the major API version. Breaking changes ship under a new version prefix; `v1` stays
stable and additive.
- **`{game}`** — the game namespace. Today the only populated value is `cs2`
(Counter-Strike 2). The API is game-agnostic by construction, so a future title slots in as its
own namespace without changing any existing `cs2` path.
- **`{resource}`** — the collection, e.g. `matches`, `odds`, `teams`, `tournaments`, `players`.
## Examples
```http
GET /v1/cs2/matches?status=live
GET /v1/cs2/matches/{id}
GET /v1/cs2/odds?match={matchId}
GET /v1/cs2/teams
GET /v1/cs2/teams/{id}
GET /v1/cs2/tournaments
GET /v1/cs2/players
```
## Unknown games
A request for a game that isn't onboarded yet returns a clean `404 Not Found`, not a server error:
```bash
curl "https://api.esportsodds.gg/v1/valorant/matches?apiKey=YOUR_API_KEY"
# 404 { "error": "unknown game" }
```
That means you can write a client against `/v1/{game}/...` today and point it at a new game the day
it's added — the shape won't change.
## Adding a second game later
When a new title is onboarded, it appears as a new `{game}` slug with the same resources under it
(`/v1/{newgame}/matches`, `/v1/{newgame}/odds`, and so on). Existing `cs2` integrations are
untouched. Watch the [changelog](/docs/changelog) for new namespaces.
---
# What's in the data
Source: https://docs.esportsodds.gg/docs/whats-in-the-data
An honest inventory of what exists, what's thin, and what isn't there at all.
Before you design around this API, here's what it actually contains.
## Ask the API itself
`/v1/{game}/coverage` is the authoritative answer, and it's deliberately unflattering:
```bash
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/coverage"
```
Each row carries a `status`:
| Status | Meaning |
| --- | --- |
| `live` | Available now |
| `pending_validation` | Built, held back until it clears an accuracy bar |
| `schema_ready` | The shape exists but isn't populated |
| `planned` | Intended, not built |
| `licence_required` | Blocked on rights, not engineering |
Read it before you build, not after.
## The layers
**Fixtures and results** — teams, tournaments, schedule, format, scores, winners. The broadest and
deepest layer.
**Map results** — per-map scores with half-by-half splits, and overtime where it happened.
**Player stats** — kills/assists/deaths, ADR and our own rating on every row; KAST, opening duels,
trades, clutches, multi-kills and utility where the source carried them.
**Round stats** — two rows per round, one per team: economy, opening duels, trades, clutches, bomb
events, utility. This is where genuine tactical analysis lives.
**Player depth** — weapons, grenades, hitgroups, killer/victim duels and flash pairs. The newest and
least universal layer.
**Odds** — one de-vigged market line per outcome, as a time series, with movement fields and closing
flags.
**Derived** — Glicko-2 ratings, leaderboards, team form, head-to-head, per-map records, playstyle
profiles, roster history.
## What's thin
Worth knowing before you filter on it:
- **`role` on players**: about 14% populated. Filtering by role excludes everyone whose role is
unknown, which is most.
- **`seed` on tournament participants**: always `null` — no upstream we ingest publishes seeding.
- **Depth**: check `data_available.depth`; it's absent on plenty of matches.
- **`tier` on tournaments**: ~98% populated, `S` through `C`.
## What isn't there
- **Per-book odds.** One aggregate line, never individual bookmaker prices or names.
- **The model line.** Empty until it clears its gate — check `/v1/cs2/model/metrics`.
- **Team logos.** `logo_url` is always empty; no redistribution rights.
- **Sub-second live ticks.** The WebSocket pushes at ingestion cadence.
[Known limitations](/docs/help/limitations) has the full list.
## Checking depth for yourself
Rather than trusting a figure in a doc that could go stale, count:
```bash
# How many completed matches have round-level data?
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/matches?status=completed&has=rounds&limit=1"
```
`?has=` accepts any subset of `maps,stats,vetoes,rounds,depth`, so you can size the population that
actually supports your analysis before committing to it.