# 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.