CS2 Odds API: Resolve the Map Handicap Across 15 Market IDs

CS2 Odds API header
How To Guides September 21, 2026

Pull one Counter-Strike 2 match off the OddsPapi CS2 odds API and you get a wide board. Magic v Alliance at FISSURE Playground on 10 September 2026 returned 155 bookmakers. Another PGL Masters Bucharest fixture returned 149. Across 41 CS2 fixtures with odds in a nine-day window, the median was 91 books.

Then you parse it, and the same market name comes back fifteen times. Maps Handicap is fifteen separate market IDs, one per rung from -3.5 to +3.5. Total Maps Over Under is another seven, from 2.5 to 8.5. The line that separates them sits in the handicap field of /v4/markets, not in the price object. Key your parser on the market name and you merge fifteen different bets into one.

What the CS2 Catalogue Holds

Counter-Strike 2 is sportId 17. Its catalogue is 28 market IDs, and 22 of them belong to the two ladders. Every number in this post comes from live calls made on 10 September 2026.

Market name Market IDs Lines Period
Winner 171 0.0 series
Total Maps Over Under 173, 175, 177, 179, 1711, 1713, 1715 2.5 to 8.5 series
Maps Handicap 1717 to 1745, odd numbers -3.5 to +3.5 in 0.5 steps series
First to Fifth Map Winner (incl. overtime) 1747, 1749, 1751, 1753, 1755 0.0 p1 to p5

This is the mirror image of the trap in our boxing odds API post. There, three market IDs carry the same bet and you merge them. Here, fifteen IDs share one name and carry fifteen different bets, so you split them on handicap and keep them apart.

Old Way vs OddsPapi

Task Scraping two or three sportsbooks OddsPapi /v4
Books on a tier-1 CS2 match 2 to 3 Up to 155, median 91 across 41 fixtures
Map handicap ladder Reverse-engineer each site’s HTML 15 market IDs with a typed handicap value
Sharp anchor Pinnacle, if the page loads Pinnacle, Kalshi, Polymarket and Betfair Exchange in one payload
Grading a finished Bo3 Write your own rules per market /v4/settlements returns WIN, LOSE or PUSH per outcome
Historical prices Log it yourself from today Free, every recorded snapshot, three books per call
Cost to start Your weekends Free tier, no card

Step 1: Find the Circuits That Are Running

Do not discover CS2 events from /v4/tournaments. The endpoint returned 350 CS2 tournament rows on 10 September and marked four of them with futureFixtures > 0. Eleven circuits had fixtures on the nine-day window, and ten of those eleven reported futureFixtures as zero, PGL Masters Bucharest among them. Group /v4/fixtures on tournamentId instead.

import os, requests, collections, datetime as dt

API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE = "https://api.oddspapi.io/v4"
CS2 = 17

today = dt.date.today()
r = requests.get(f"{BASE}/fixtures", params={
    "apiKey": API_KEY, "sportId": CS2,
    "from": today.isoformat(),
    "to": (today + dt.timedelta(days=9)).isoformat(),
})
r.raise_for_status()
fixtures = r.json()

circuits = collections.Counter(
    (f["tournamentId"], f["tournamentName"]) for f in fixtures
)
print(f"{len(fixtures)} fixtures, {sum(1 for f in fixtures if f['hasOdds'])} with odds")
for (tid, name), n in circuits.most_common():
    print(f"  {n:>3}  {name}  ({tid})")

Output on 10 September 2026:

50 fixtures, 41 with odds
   11  PGL Masters Bucharest  (49212)
    8  FISSURE Playground  (46097)
    8  Thunderpick World Championship  (41296)
    6  CCT European Series  (51122)
    4  United21  (44609)
    4  CCT Contenders Europe  (51054)
    3  CCT Europe Challengers  (51210)
    2  1win Private Club  (52934)
    2  ESEA League Europe  (51004)
    1  NODWIN Clutch Series  (49568)
    1  NSTLGA League  (51976)

Two constraints on that call. Without a tournamentId, a from/to range wider than ten days returns 400 INVALID_PARAMETER. And to is a midnight-UTC instant, so set it to the day after the last day you want.

Step 2: Build the Ladder Map

The sportId parameter on /v4/markets is a no-op. The endpoint returns all 33,115 definitions and you filter client-side. Each row carries marketName, handicap, period and the outcome list, and the pair (marketName, handicap) is what identifies the bet.

catalogue = requests.get(f"{BASE}/markets", params={"apiKey": API_KEY}).json()
cs2 = {str(m["marketId"]): m for m in catalogue if m["sportId"] == CS2}

for mid, m in sorted(cs2.items(), key=lambda kv: (kv[1]["marketName"], kv[1]["handicap"])):
    print(f"  {mid:>5}  {m['marketName']:<34} line {m['handicap']:>5}  period {m['period']}")

handicaps = {m["handicap"]: mid for mid, m in cs2.items()
             if m["marketName"] == "Maps Handicap"}
totals = {m["handicap"]: mid for mid, m in cs2.items()
          if m["marketName"] == "Total Maps Over Under"}
print("Maps Handicap rungs:", sorted(handicaps))
print("Total Maps rungs:   ", sorted(totals))
28 Counter-Strike 2 market IDs
   1717  Maps Handicap                      line  -3.5  period result
   1719  Maps Handicap                      line  -3.0  period result
   ...
   1745  Maps Handicap                      line   3.5  period result
    173  Total Maps Over Under              line   2.5  period result
    ...
    171  Winner                             line   0.0  period result

Maps Handicap rungs: [-3.5, -3.0, -2.5, -2.0, -1.5, -1.0, -0.5, 0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5]
Total Maps rungs:    [2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5]

Read the outcome names from the catalogue too. The two legs of Maps Handicap are named 1 and 2, and their order inside the outcomes array flips between rungs. Market 1717 lists outcome 1718 first; market 1725 lists outcome 1725 first. Match on outcomeName and never on array position.

Step 3: Read the Board

One /v4/odds call returns every book on the fixture. Three filters turn the raw payload into quotes you can price against: the book-level suspended flag, the market-level marketActive flag, and the price-level active flag. Drop any slug containing + and the demo slug as well, because those are internal test feeds.

import statistics

def read_board(fixture_id, catalogue):
    """Return {market_id: {slug: {outcome_id: price}}} for two-sided active quotes."""
    r = requests.get(f"{BASE}/odds",
                     params={"apiKey": API_KEY, "fixtureId": fixture_id})
    r.raise_for_status()
    board = {}
    for slug, book in (r.json().get("bookmakerOdds") or {}).items():
        if "+" in slug or slug == "demo":
            continue
        if book.get("suspended"):
            continue
        for mid, market in (book.get("markets") or {}).items():
            if not market.get("marketActive"):
                continue
            legs = {}
            for oid, outcome in (market.get("outcomes") or {}).items():
                price = (outcome.get("players") or {}).get("0")
                if price and price.get("active") and price.get("price"):
                    legs[oid] = price["price"]
            if len(legs) == len(catalogue[mid]["outcomes"]):
                board.setdefault(mid, {})[slug] = legs
    return board

board = read_board("id1704921274528890", cs2)   # Fluxo v Dendele, PGL Masters Bucharest

for mid, quotes in sorted(board.items(), key=lambda kv: -len(kv[1])):
    m = cs2[mid]
    tuples = {tuple(sorted(q.items())) for q in quotes.values()}
    margins = [sum(1 / p for p in q.values()) - 1 for q in quotes.values()]
    print(f"{mid:>5} {m['marketName']:<34} line {m['handicap']:>5} | "
          f"{len(quotes):>3} books -> {len(tuples):>3} distinct | "
          f"median margin {100 * statistics.median(margins):.2f}%")

Fluxo v Dendele carried 134 books and 26 market IDs. After the three filters, seven markets survive:

  171 Winner                             line   0.0 | 132 books ->  21 distinct | median margin 7.96%
 1749 Second Map Winner (incl. overtime) line   0.0 | 124 books ->  22 distinct | median margin 6.99%
 1747 First Map Winner (incl. overtime)  line   0.0 | 124 books ->  21 distinct | median margin 6.99%
  173 Total Maps Over Under              line   2.5 | 120 books ->  22 distinct | median margin 7.19%
 1737 Maps Handicap                      line   1.5 | 118 books ->  21 distinct | median margin 7.93%
 1725 Maps Handicap                      line  -1.5 |  81 books ->  14 distinct | median margin 8.15%
 1751 Third Map Winner (incl. overtime)  line   0.0 |  65 books ->  13 distinct | median margin 7.07%

Those seven are the series markets a best-of-three can settle: the winner, the 2.5 map total, the +/-1.5 map handicap, and a winner for each of the three maps. Across 18 pre-match CS2 fixtures measured on 10 September, they ran 97.1% active, or 12,266 live prices out of 12,634. The other nineteen rungs of the two ladders carried 5,258 posted prices across those same 18 fixtures and none of them was flagged active. The active filter is what separates a tradeable board from a menu. Our NFL alternate lines post runs the same measurement on a 1,679-market American football board.

Note the dedupe line. 132 distinct slugs quoted the Fluxo v Dendele winner and they collapse to 21 distinct price tuples, an 84% collapse. Across 14 deep boards the median collapse was 72%. Count books before you dedupe and a consensus price counts one trading desk twenty times. The cloneOf field does not catch this, so compare the price tuple itself.

Step 4: Find the Tight Prices

Margin ranking works on any two-legged market and does not care which leg is which: 1/a + 1/b - 1. Run it across 14 deep CS2 boards and the prediction markets sit at the top.

Bookmaker Fixtures quoted (of 14) Median margin on market 171
polymarket.us 11 1.00%
polymarket 10 1.01%
kalshi 9 1.03%
duel 12 3.49%
1xbet 14 5.41%
Board median, 63 books – 6.46%

Polymarket and Kalshi appeared on all 14 boards, Pinnacle on 7 and Betfair Exchange on 3. On esports the prediction markets are the anchor you can rely on being there, which is the same pattern our League of Legends odds API post found on the LEC and LCK board. De-vig the deduped consensus rather than any single book, and use the consensus odds method to get there.

Step 5: Grade the Ladder

/v4/scores returns the map score of a finished series, and /v4/settlements returns WIN, LOSE or PUSH for every outcome. Together they let you check your own handicap logic against the feed before you trust it on money.

The rule for Maps Handicap is one line. Add the market’s handicap value to participant 1’s map margin. Positive is a win, negative is a loss, zero is a push.

import time

def grade_handicap(map_margin, line):
    """map_margin = participant1 maps - participant2 maps."""
    edge = map_margin + line
    return "PUSH" if edge == 0 else ("WIN" if edge > 0 else "LOSE")

FIXTURE = "id1704609774517440"   # G2 Esports v 9Z Team, FISSURE Playground

scores = requests.get(f"{BASE}/scores",
                      params={"apiKey": API_KEY, "fixtureId": FIXTURE}).json()
result = scores["scores"]["periods"]["result"]
margin = result["participant1Score"] - result["participant2Score"]
print(f"map score {result['participant1Score']}-{result['participant2Score']}, margin {margin:+d}")

time.sleep(2)
settled = requests.get(f"{BASE}/settlements",
                       params={"apiKey": API_KEY, "fixtureId": FIXTURE}).json()

for mid, market in settled["markets"].items():
    meta = cs2[mid]
    if meta["marketName"] != "Maps Handicap":
        continue
    leg1 = next(o for o in meta["outcomes"] if o["outcomeName"] == "1")
    feed = market["outcomes"][str(leg1["outcomeId"])]["players"]["0"]["result"]
    mine = grade_handicap(margin, meta["handicap"])
    print(f"  {mid:>5}  line {meta['handicap']:>5}  feed {feed:<5}  mine {mine:<5}  "
          f"{'ok' if feed == mine else 'MISMATCH'}")

G2 Esports beat 9Z Team 2-1, a margin of +1:

map score 2-1, margin +1
   1717  line  -3.5  feed LOSE   mine LOSE   ok
   1721  line  -2.5  feed LOSE   mine LOSE   ok
   1725  line  -1.5  feed LOSE   mine LOSE   ok
   1727  line  -1.0  feed PUSH   mine PUSH   ok
   1729  line  -0.5  feed WIN    mine WIN    ok
   1737  line   1.5  feed WIN    mine WIN    ok
   1745  line   3.5  feed WIN    mine WIN    ok
15 handicap rungs checked

All fifteen rungs matched. The same script on Magic v Parivision, a 2-0 series, moved the push to 1723 at -2.0 and matched all fifteen again. Wrap the /settlements call in a retry: it returns a transient 429 under about two seconds of spacing.

Step 6: Pull the History for Free

/v4/historical-odds is on the free tier and returns every recorded snapshot. It takes at most three bookmakers per call, and one unreadable slug fails the whole batch, so loop three at a time. Polymarket and Betfair Exchange need one bookmaker plus exactly one outcomeId.

import datetime as dt

START = dt.datetime(2026, 9, 10, 3, 0, tzinfo=dt.timezone.utc)

r = requests.get(f"{BASE}/historical-odds", params={
    "apiKey": API_KEY, "fixtureId": FIXTURE,
    "bookmakers": "pinnacle,1xbet,stake",
})
r.raise_for_status()

for slug, book in sorted(r.json()["bookmakers"].items()):
    winner = book["markets"].get("171")
    if not winner:
        continue
    for oid, outcome in sorted(winner["outcomes"].items()):
        snaps = outcome["players"]["0"]
        pre = [s for s in snaps
               if dt.datetime.fromisoformat(s["createdAt"].replace("Z", "+00:00")) < START
               and s["active"]]
        if not pre:
            continue
        moves = sum(1 for a, b in zip(pre, pre[1:]) if a["price"] != b["price"])
        print(f"{slug:<10} leg {oid}  open {pre[0]['price']:<6} close {pre[-1]['price']:<6} "
              f"{len(pre):>3} pre-match snapshots, {moves:>2} price changes, "
              f"limit {pre[-1]['limit']}")
1xbet      leg 171  open 1.38   close 1.34    25 pre-match snapshots,  4 price changes, limit None
1xbet      leg 172  open 3.145  close 3.36    25 pre-match snapshots,  7 price changes, limit None
pinnacle   leg 171  open 1.375  close 1.336   36 pre-match snapshots, 30 price changes, limit 6696.0
pinnacle   leg 172  open 3.11   close 3.34    36 pre-match snapshots, 31 price changes, limit 2250.0
stake      leg 171  open 1.35   close 1.28    62 pre-match snapshots, 13 price changes, limit None
stake      leg 172  open 2.9    close 3.3     62 pre-match snapshots, 13 price changes, limit None

Two things to read there. Pinnacle moved its favourite 30 times in eleven hours and closed at a 6,696 limit, while 1xBet moved four times. And Stake closed the favourite at 1.28 against Pinnacle’s 1.336, a 4.2% shorter price on the same match. Filter on createdAt < startTime, because snapshots keep arriving after the series begins, and take the last active snapshot as the close. The limit field is populated by Pinnacle and the exchanges, so null-check it before any arithmetic. A season of this feeds straight into a closing-line backtest.

Where to Go Next

The same 28-market shape covers every Counter-Strike circuit on the feed, from PGL Masters down to a CCT Contenders qualifier with nine books. For Dota 2, League of Legends and Valorant, swap the sportId and re-read the catalogue, because each title carries its own ladder. Start with the esports odds API guide for the cross-title view.

If you need the board to push rather than poll, /v4 has a WebSocket feed that streams price changes as the books make them. Polling a 155-book CS2 payload every few seconds burns requests you do not need to spend.

Start Pulling CS2 Odds

The catalogue held 357 bookmaker slugs on 10 September 2026, and 170 of them quoted at least one of the fourteen deep CS2 boards we measured. The free tier covers all of them, plus every historical snapshot, with no card and no sales call. Create a key, run the first script in this post, and you will have the whole PGL slate in JSON inside a minute. The free odds API guide covers the rate limits and the endpoint list.

FAQ

What is the market ID for the CS2 match winner?

Market 171, named “Winner”, with outcome 171 for participant 1 and 172 for participant 2. On Fluxo v Dendele at PGL Masters Bucharest, 132 of the 134 books on the fixture quoted it with two active legs.

Why does one CS2 fixture return fifteen “Maps Handicap” markets?

Each rung of the ladder is its own market ID. IDs 1717 through 1745 cover -3.5 to +3.5 maps in 0.5 steps, and they all carry the same marketName. Read the handicap field from /v4/markets to tell them apart, and key your parser on the pair rather than the name.

How do I settle a CS2 map handicap bet?

Add the market’s handicap value to participant 1’s map margin. Positive settles as a win, negative as a loss, zero as a push. Checked against /v4/settlements on two played best-of-threes, that rule matched the feed on all fifteen rungs both times.

Which bookmakers price Counter-Strike 2 the tightest?

Across 14 deep boards measured on 10 September 2026, Polymarket ran a 1.00% median margin on the series winner, Kalshi 1.03% and Duel 3.49%, against a 6.46% board median over 63 books. Polymarket and Kalshi appeared on all 14 fixtures.

How much CS2 history can I pull on the free tier?

Every recorded snapshot. On one FISSURE Playground series, Pinnacle history opened eleven hours before the first map and held 36 pre-match active snapshots on the favourite with 30 price changes. Request up to three bookmakers per call.