Esports Odds API: Compare Lines Without Guessing Market Scope

Updated · Published

Quick answer: An esports odds API lets you compare lines across books only when every line says what it measures. A 21.5 can be total maps, total rounds, or rounds on one map, so KashRock labels each market with market_type, market_scope, included_maps and a per-book overtime flag, and marks a line whose scope a book never stated as unknown instead of guessing.

What the number could beField that tells youReal value
Total maps in the seriesmarket_type, scope_basismaps, line_within_series_map_range
Total rounds, same market namescope, scope_basisrounds, line_outside_series_map_range
Rounds on map 1 onlymarket_scope, included_mapsmap_1, [1]
The whole seriesmarket_scopeseries
A book never saidmarket_scope, dims_basisunknown, unsuffixed_key_scope_not_stated

One market name, three different numbers

Open the closing-line history for a finished best-of-three and one market name can carry numbers that measure different things. In the history for Liquid vs Wildcard on 27 September 2026, the market total_maps has a 2.5 line quoted by nine books, and a 20.5 and a 21.5 line quoted by one book each. Nobody plays 21.5 maps. Those two lines are total rounds filed under a maps name.

GET /v6/esports/cs2/matches/liquid-vs-wildcard-gaming-27-09-2026/lines/history
{
  "markets": [
    {
      "market": "total_maps",
      "market_type": "maps",
      "scope": "maps",
      "scope_basis": "line_within_series_map_range",
      "line": 2.5
    },
    {
      "market": "total_maps",
      "market_type": "maps",
      "scope": "rounds",
      "scope_basis": "line_outside_series_map_range",
      "line": 21.5
    }
  ]
}

If you key a model on total_maps and the line, you will average a 2.5 maps market with a 21.5 rounds market and call the result consensus. KashRock decides which one a line is and says why. 2.5 sits inside the possible map range of a best-of-three, so its scope_basis is line_within_series_map_range and its scope is maps. 21.5 sits outside that range, so it is line_outside_series_map_range and its scope is rounds.

The fields that say what a line measures

Every market carries the same small set of labels. These are the live counts from one capture of the CS2 board on 30 September 2026: 132 events and 1,198 markets.

FieldWhat it tells youValues on the live board
market_typeThe kind of numberrounds 873, winner 253, maps 72
market_scopeHow much of the match it coversmap_1 415, series 312, map_2 210, map_3 189, unknown 72
included_mapsWhich maps it counts[1] for map 1, [] for the series
dims_basisHow the scope was establishedstat_suffix 873, market_definition 253, unsuffixed_key_scope_not_stated 72

A per-map line says so in two places. This is a real round handicap on map 1: the scope is map_1, included_maps is [1], and dims_basis shows the scope came from the stat's own suffix rather than a guess.

GET /v6/esports/cs2/lines
{
  "market": "round_handicap",
  "market_type": "rounds",
  "market_scope": "map_1",
  "included_maps": [
    1
  ],
  "dims_basis": "stat_suffix",
  "line": -2.5,
  "consensus_confidence": 0.991113,
  "sources_used": [
    "…",
    "…"
  ],
  "outcomes": [
    {
      "name": "b8",
      "consensus_probability": 0.471165
    }
  ]
}

A series market is the simple case. The match winner has no map in it, so included_maps is empty and the basis is the market definition itself.

GET /v6/esports/cs2/lines
{
  "market": "match_winner",
  "market_type": "winner",
  "market_scope": "series",
  "included_maps": [],
  "dims_basis": "market_definition",
  "consensus_confidence": 0.9527,
  "sources_used": [
    "…",
    "…",
    "…",
    "…",
    "…",
    "…",
    "…"
  ],
  "outcomes": [
    {
      "name": "aim",
      "consensus_probability": 0.676543
    },
    {
      "name": "abt",
      "consensus_probability": 0.323457
    }
  ]
}

Captured live on 2026-09-30. Book names in sources_used are replaced with …, and outcomes are trimmed to name and consensus_probability. Every other value is unchanged.

When a book never says, KashRock says so

Not every feed states what its lines cover. On the same capture, 72 of 1,198 markets, about 6 percent, have market_scope: unknown and a dims_basis of unsuffixed_key_scope_not_stated. That is a statement of fact about the source, not a gap in the data. All 36 total_maps markets on that board are 2.5 lines, and this is one of them:

GET /v6/esports/cs2/lines
{
  "market": "total_maps",
  "market_type": "maps",
  "market_scope": "unknown",
  "included_maps": [],
  "dims_basis": "unsuffixed_key_scope_not_stated",
  "line": 2.5,
  "consensus_confidence": 0.791754,
  "sources_used": [
    "…",
    "…",
    "…",
    "…",
    "…",
    "…"
  ],
  "outcomes": [
    {
      "name": "Under",
      "consensus_probability": 0.573931
    },
    {
      "name": "Over",
      "consensus_probability": 0.426069
    }
  ]
}

The line itself is still useful, and it still has a consensus. What you must not do is mix it into a map-scoped comparison. Filter on market_scope before you group, and treat unknown as its own bucket. The same applies to 849 of the 1,198 markets that have a single source: a consensus needs more than one book, and those markets say so in their quality block with low_confidence: true.

Overtime is the second thing that changes a number

A rounds total can include overtime rounds or not, and books do not always say. Each book's quote in the history carries ot_included. Across the 67 book quotes on the Liquid vs Wildcard history, 17 state that overtime is included and 50 are unknown. Each quote also carries settlement_rules, with a verified flag: 17 are verified and 50 are not.

When nothing is proven, the answer is unknown, not a default. That is why the consensus board refuses some groups. The reason string it returns when it has nothing to say is specific: a consensus needs two books on the same line, a proven scope and the same overtime class. The Esports Consensus API page covers how that board is built, and the Esports Betting Glossary explains the market terms.

Align by team, never by side

There is a second trap that has nothing to do with scope. In the closing history, each book's home and away are that book's own. In the same match winner market, six of the ten books list Team Liquid as home and four list Wildcard as home. The names differ too: one book says Liquid where another says Team Liquid, and Wildcard where another says Wildcard Gaming.

Read the home side across all ten books and the closing price runs from -400 to +285, which is two different teams. Align each side to a team by name first, using the match's canonical name and its alias, and the same data says Team Liquid closed between -400 and -300. The match record gives you both names, which is the reason it carries an alias field. The fuller story is in Why Esports Data Breaks Your App.

Code: check scope, align by team, compare inside one scope

This runs against the live API. Step one counts how many live markets state their scope. Step two aligns every book's side to the team and compares closing prices inside the series scope.

Python

import os
import requests

BASE = "https://kashrock.up.railway.app/v6/esports/cs2"
HEADERS = {"X-API-Key": os.environ["KASHROCK_API_KEY"]}
KR_ID = "kr_cs2_liquid-vs-wildcard-gaming-27-09-2026"
SLUG = KR_ID.removeprefix("kr_cs2_")

# 1. How many live markets say what they measure?
board = requests.get(f"{BASE}/lines", headers=HEADERS, timeout=120).json()
if board.get("available") is False:
    raise SystemExit(board["reason"])
markets = [m for e in board["events"] for m in e["markets"]]
stated = [m for m in markets if m["market_scope"] != "unknown"]
print(f"{len(stated)} of {len(markets)} live markets state their scope")

# 2. Compare closing prices across books. A book's "home" is its own home, so align
#    each side to a team by name, using the match's canonical name and alias.
match = requests.get(f"{BASE}/matches/id/{KR_ID}", headers=HEADERS, timeout=60).json()["match"]
team1 = {n for n in (match["team1"], match.get("team1_alias")) if n}

history = requests.get(f"{BASE}/matches/{SLUG}/lines/history", headers=HEADERS, timeout=120).json()
if history.get("available") is False:
    raise SystemExit(history["reason"])
for m in history["markets"]:
    if m["market"] != "match_winner":
        continue
    closes = []
    for book in m["books"]:
        for side in (book.get("close") or {}).get("sides", []):
            if side["name"] in team1:
                closes.append(side["american"])
    print(f"{match['team1']} closed between {min(closes)} and {max(closes)} across {len(closes)} books ({m['market_scope']})")

JavaScript (Node 18+)

const BASE = "https://kashrock.up.railway.app/v6/esports/cs2"
const headers = { "X-API-Key": process.env.KASHROCK_API_KEY }
const KR_ID = "kr_cs2_liquid-vs-wildcard-gaming-27-09-2026"
const SLUG = KR_ID.replace("kr_cs2_", "")

// 1. How many live markets say what they measure?
const board = await (await fetch(`${BASE}/lines`, { headers })).json()
if (board.available === false) throw new Error(board.reason)
const markets = board.events.flatMap((e) => e.markets)
const stated = markets.filter((m) => m.market_scope !== "unknown")
console.log(`${stated.length} of ${markets.length} live markets state their scope`)

// 2. Compare closing prices across books. A book's "home" is its own home, so align
//    each side to a team by name, using the match's canonical name and alias.
const { match } = await (await fetch(`${BASE}/matches/id/${KR_ID}`, { headers })).json()
const team1 = new Set([match.team1, match.team1_alias].filter(Boolean))

const history = await (await fetch(`${BASE}/matches/${SLUG}/lines/history`, { headers })).json()
if (history.available === false) throw new Error(history.reason)
for (const m of history.markets.filter((x) => x.market === "match_winner")) {
  const closes = m.books.flatMap((b) =>
    (b.close?.sides ?? []).filter((s) => team1.has(s.name)).map((s) => s.american),
  )
  console.log(`${match.team1} closed between ${Math.min(...closes)} and ${Math.max(...closes)} across ${closes.length} books (${m.market_scope})`)
}

Both versions printed the same two lines on the capture used for this guide: 1126 of 1198 live markets state their scope and Team Liquid closed between -400 and -300 across 10 books (series). The first count moves as the board moves. The second is a finished match, so it does not.

Opening and closing prices, per book

Models need to know where a line closed, so every market in history carries an open and a close per book, each with an observed_at timestamp. This is one book's series winner for the same match. The home side, Team Liquid, moved from -260 to -350 between open and close, and the away side moved from +190 to +250.

GET /v6/esports/cs2/matches/liquid-vs-wildcard-gaming-27-09-2026/lines/history
{
  "market": "match_winner",
  "market_type": "winner",
  "market_scope": "series",
  "books": [
    {
      "ot_included": "unknown",
      "open": {
        "observed_at": "2026-09-27T06:25:22Z",
        "sides": [
          {
            "side": "away",
            "american": 190
          },
          {
            "side": "home",
            "american": -260
          }
        ]
      },
      "close": {
        "observed_at": "2026-09-27T22:45:00Z",
        "sides": [
          {
            "side": "away",
            "american": 250
          },
          {
            "side": "home",
            "american": -350
          }
        ]
      }
    }
  ]
}

The Historical Esports Data API page covers how far back the tape goes, and the Line Gaps API shows where books disagree on the same scope. For the odds board itself, see the Esports Odds API.

How to test any esports odds API

  • Pull a total with a line near 20. Does the response say whether it is maps or rounds, and why?
  • Pull a per-map line. Does it name the map it counts?
  • Find a line a book never labelled. Does the API admit that, or pick a scope for you?
  • Compare overtime across books. Is it stated, or silently assumed?
  • Read the home side across books. Do you get one team, or two?

If an API fails these, your comparison will be wrong in ways you will not see until a bet settles badly. The Esports API developer guide covers the rest of the schema, and the Quickstart gets you a key.

FAQ

How do I know whether a line is maps or rounds?

Read market_type, market_scope and included_maps on the market. In history each market also has scope and scope_basis, which says how it was decided, for example line_within_series_map_range for a 2.5 total in a best-of-three.

What does market_scope unknown mean?

The book did not state what the line covers. KashRock records that as unknown with dims_basis: unsuffixed_key_scope_not_stated instead of guessing. On the capture used here that was 72 of 1,198 markets.

Does overtime count in a rounds total?

It depends on the book. Each book's quote carries ot_included, which is true when overtime is included and unknown when that is not proven. On the Liquid vs Wildcard history, 17 of 67 quotes said true and 50 said unknown.

Why can't I compare the home side across books?

Each book uses its own home and away. In the Liquid vs Wildcard match winner, four of ten books listed Wildcard as home. Align each side to a team by name, using the match's canonical name and alias, then compare.

How many books price each market?

It varies by market. On the capture used here, 849 of 1,198 markets had a single source and the rest had two to ten. A consensus needs two books on the same line, a proven scope and the same overtime class.

Written by

Javieon, Founder of KashRock

Javieon founded KashRock, the esports data API that gives every match, player, team and prop one permanent ID.