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 decimalpriceinto an implied probability with1 / 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.
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.
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.
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}%")
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."
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_countis 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_winnerisnulluntil 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.