guide

Read the CS2 market odds line via the API

Jul 10, 20265 min read

Bottom line: The CS2 odds endpoint serves one derived line, eo_market: a de-vigged aggregate combined from multiple bookmakers and exchanges with the margin already removed. You pull the current snapshot, or a single match's line history, and convert each decimal price into an implied probability with 1 / price. Because the margin is stripped, the two sides sum to 100% with nothing left to de-vig yourself. No individual book is ever named, and how the aggregate is built is documented in our methodology. Built on the EsportsOdds CS2 data API.

What the odds endpoint serves

The API does not resell individual bookmaker prices. It serves a de-vigged aggregate combined from multiple bookmakers and exchanges, eo_market, with the margin removed. A book_count field ships on each row, but the contributing books themselves are never named. (A separate proprietary model line, eo_model, is in the works and gated until it clears validation; this guide uses the market line.)

Two ways to read it

GET /v1/cs2/odds has two modes. Without a match parameter it returns the current snapshot, the latest line for every open market, in one unpaginated response. With ?match={id} it returns that one match's line history, newest first, which is paginated. Each row is flat: one row per outcome, not a nested outcomes array.

Reading the market line: pull the snapshot, pick a match with a line, fetch its history, then convert each price to a fair probability with one over price.

Start with the snapshot, filtering to the market source explicitly:

import os
import requests

BASE = "https://api.esportsodds.gg"
HEADERS = {"Authorization": f"Bearer {os.environ['ESPORTSODDS_API_KEY']}"}


def snapshot() -> list[dict]:
    r = requests.get(f"{BASE}/v1/cs2/odds", params={"source": "eo_market"},
                     headers=HEADERS, timeout=15)
    r.raise_for_status()
    return r.json()["data"]

One gotcha on ?source=: a made-up value is a 400, but eo_model is a valid, currently-gated source, so it returns a 200 with an empty list, not an error. Code that expects a 400 to signal "no model odds yet" will misread that empty 200. Each served row carries a label (the outcome, e.g. a team name), an outcome_key (home / away for a match winner), a decimal price, a book_count, and a captured_at timestamp.

Two read modes for GET odds: no match parameter returns an unpaginated snapshot, a match id returns paginated history, and a gated source returns an empty 200 list.

Convert price to implied probability

Decimal odds and probability are two views of the same number. The implied probability of a decimal price is its reciprocal:

def implied(price: float) -> float:
    return 1.0 / price

Here is why that is all you need. On a raw bookmaker board, the two sides of a match-winner market add up to more than 100%, and that excess is the margin. The eo_market line has it removed already, so the two implied probabilities sum to 100% by construction. It is not approximate: a live check across every open market returns exactly 100.0000%. If float rounding ever bothers you, Python's decimal module gives you controlled precision.

Because the market line is already de-vigged, the two outcomes' implied probabilities sum to 100% with no overround left to strip out.

def match_winner_probs(lines: list[dict]) -> dict[str, float]:
    winner = [ln for ln in lines if ln["outcome_key"] in ("home", "away")]
    return {ln["label"]: round(implied(ln["price"]) * 100, 1) for ln in winner}


# e.g. {"Natus Vincere": 63.2, "FaZe Clan": 36.8}  (sums to 100)

Present it as exactly what it is: "market-implied," where the aggregate sits, not a recommendation.

Track how a line moves

Pass a match ID and you get that match's history, so you can watch the line evolve as a game approaches. This mode is paginated, so use the cursor loop from the pagination guide:

def line_history(match_id: str) -> list[dict]:
    rows, params = [], {"source": "eo_market", "match": match_id, "limit": 500}
    while True:
        r = requests.get(f"{BASE}/v1/cs2/odds", params=params, headers=HEADERS, timeout=15)
        r.raise_for_status()
        payload = r.json()
        rows.extend(payload["data"])
        cursor = payload["meta"].get("next_cursor")
        if not cursor:
            return rows
        params["cursor"] = cursor


history = line_history("0191f2c8-8a1e-7c3a-9f5e-2b6d4e8a1c00")
one_side = [ln for ln in history if ln["outcome_key"] == "home"]
for ln in sorted(one_side, key=lambda x: x["captured_at"]):
    print(f"{ln['captured_at']}  {implied(ln['price']) * 100:5.1f}%")

An example line moving over time: the market-implied win probability for one team across successive snapshots, settling at the close.

Two things make this history read correctly. The captured_at field is an RFC 3339 timestamp, so it sorts lexically, which is why the plain sorted above works. And the series is append-on-change: a new row is written only when the price actually moves, never on a timer. So a gap between timestamps is the line holding steady, which is stability, not staleness. The delta_since_open and open_price fields give you the move relative to the opening line without recomputing it, and is_closing flags the final captured line.

Reading results without corrupting a backtest

If you are labelling historical lines for closing-line analysis, one field will bite you. is_winner is null until the settlement job runs, then becomes true or false. Null means "not settled yet," not "lost."

The is_winner lifecycle: null while the market is open, then true or false once settlement runs, so null must never be read as a loss.

A backtester that treats null as false silently relabels every still-open market as a loss and skews the whole dataset. Filter to is_winner is not None before you score anything.

A few things to keep straight

  • Never surface a source. The line is a de-vigged aggregate combined from multiple bookmakers and exchanges; no individual book is named, and book_count is an API field to read, not a depth claim to advertise.
  • Prices are decimal odds. Convert to probability with 1 / price; don't assume a different format.
  • Null is not a result. is_winner is null until settled; exclude those rows from any win-rate or accuracy calculation.

Pull the snapshot, convert with 1 / price, and remember that a steady line is a feature. For how the aggregate is derived, read the methodology.

Full API reference: how the market line is computed in the developer docs.