Esports Results API: Settle 303 CS2, LoL and Dota 2 Series

Esports Results API - OddsPapi API Blog
How To Guides October 5, 2026

Looking for an esports results API? You need the final score of a CS2, League of Legends or Dota 2 series, keyed to the same fixture ID as the odds you bet into. Most esports data products sell one or the other. The stats vendors sell scores and player telemetry. The odds feeds sell prices and forget the fixture once it finishes.

OddsPapi returns both on one free key. /v4/scores gives the series score and, on LoL and Dota 2, the map-by-map result. /v4/settlements grades every Winner, Maps Handicap and Total Maps outcome. /v4/historical-odds replays the closing price on the same fixture ID. This guide runs all three over every CS2, LoL and Dota 2 fixture that finished between 13 and 23 September 2026, measured on 23 September.

The result: 303 decided series, a self-built grader that agreed with /v4/settlements on 2,520 of 2,520 outcomes, and a closing favourite that won 69.6% of the time on Pinnacle’s price. If you only need live esports prices, the esports odds API guide covers the board. This post picks up after the final map.

Why esports results and esports odds usually live in two products

The esports stats vendors built their products for broadcasters and team analysts. You get kills, rounds and player telemetry on their own fixture IDs, and you map those IDs to your odds by hand. The PandaScore comparison covers what a stats plan does and does not include. The generic odds APIs carry the price and drop the fixture once it finishes, so you grade your own bets by hand.

That gap hurts in three places: settling a bet, labelling a training set, and auditing your closing line value. Each one needs the price and the result on the same key.

Job Stats vendor plus odds feed OddsPapi, measured 23 September 2026
Series score Stats API, separate fixture IDs to map /v4/scores: 303 CS2, LoL and Dota 2 series
Map-by-map score Stats API /v4/scores p1 to p5: 68 Dota 2 and 41 LoL series
Graded bets Your own code /v4/settlements: 2,220 decided outcomes on 45 series checked
Closing price A second vendor, if it keeps history /v4/historical-odds: 266 series with a closing Winner price
Fixture ID shared with the odds No, you map two ID systems Yes, one fixtureId on all four endpoints

Step 1: pull the played fixtures

Esports sit under their own sport IDs: 16 is Dota 2, 17 is Counter-Strike and 18 is League of Legends. /v4/fixtures rejects a sportId-only range wider than ten days, so the window below is exactly ten. Treat a 404 as an empty window. The key goes in the query string, never in a header.

import os, time, requests
from datetime import datetime, timedelta, timezone

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

def get(path, **params):
    """GET with the key as a query parameter; honours retryMs on a 429."""
    params["apiKey"] = API_KEY
    for attempt in range(6):
        r = requests.get(f"{BASE}/{path}", params=params, timeout=120)
        if r.status_code == 429:
            time.sleep(r.json()["error"].get("retryMs", 1000) / 1000 + 0.5)
            continue
        time.sleep(1.1)
        return r
    return r

ESPORTS = {16: "Dota 2", 17: "CS2", 18: "LoL"}
end = datetime(2026, 9, 23, tzinfo=timezone.utc)
start = end - timedelta(days=10)          # /fixtures caps a sportId-only range at 10 days

played = []
for sport_id, name in ESPORTS.items():
    r = get("fixtures", sportId=sport_id,
            **{"from": start.strftime("%Y-%m-%d"), "to": end.strftime("%Y-%m-%d")})
    if r.status_code == 404:              # an empty window, not an error
        continue
    for f in r.json():
        if f.get("statusName") == "Finished":
            f["esport"] = name
            played.append(f)

print(len(played), "finished fixtures between", start.date(), "and", end.date())
for name in ESPORTS.values():
    print(f"  {name:7s}", sum(f["esport"] == name for f in played))
305 finished fixtures between 2026-09-13 and 2026-09-23
  Dota 2  69
  CS2     180
  LoL     56

Keep only statusName == "Finished". A series that still reads Live can carry a part-played score such as 1-0 in a best-of-three, and a grader would settle it early.

Step 2: read the score in all three shapes

/v4/scores nests everything under scores.periods. result holds the series score as maps won. What sits next to it depends on the title and the series length:

  • Dota 2 and LoL best-of-three or best-of-five: p1 to p5, one key per map played, each scored 1-0 or 0-1, plus fulltime. On all 109 series with map keys, the map scores summed exactly to result.
  • LoL best-of-one: result only, scored 1-0 or 0-1. That covers the other 15 LoL series.
  • CS2: result only, as maps won. A 2-1 tells you the series went the distance but not which map each side took.
def read_result(fixture_id):
    """Return the series score and, where the feed carries them, the map-by-map scores."""
    r = get("scores", fixtureId=fixture_id)
    if r.status_code == 404:              # no result posted yet
        return None
    periods = r.json()["scores"]["periods"]
    series = periods.get("result")
    if series is None:
        return None
    maps = [periods[k] for k in ("p1", "p2", "p3", "p4", "p5") if k in periods]
    return {
        "series": (series["participant1Score"], series["participant2Score"]),
        "maps": [(m["participant1Score"], m["participant2Score"]) for m in maps] or None,
    }

results = {}
for f in played:
    res = read_result(f["fixtureId"])
    if res and res["series"][0] != res["series"][1]:
        results[f["fixtureId"]] = res

print(len(results), "decided series")
for name in ESPORTS.values():
    ids = [f["fixtureId"] for f in played if f["esport"] == name and f["fixtureId"] in results]
    with_maps = sum(results[i]["maps"] is not None for i in ids)
    print(f"  {name:7s} {len(ids):4d} series, {with_maps:4d} with map-by-map scores")

sample = next(f for f in played if f["esport"] == "LoL" and f["fixtureId"] in results
              and results[f["fixtureId"]]["maps"])
print(sample["participant1Name"], "v", sample["participant2Name"], results[sample["fixtureId"]])
303 decided series
  Dota 2    68 series,   68 with map-by-map scores
  CS2      179 series,    0 with map-by-map scores
  LoL       56 series,   41 with map-by-map scores
Hanwha Life Esports v Gen.g Esports {'series': (1, 3), 'maps': [(1, 0), (0, 1), (0, 1), (0, 1)]}

The normalised record keeps maps as None when the payload carries no per-map keys. That one field decides what you can grade in the next step.

Step 3: grade the bets, then check against /v4/settlements

The series score settles three market families on its own: Winner, Maps Handicap and Total Maps Over Under. Map winner markets need the per-map keys. Two details trip up a hand-written grader:

  • The handicap value in the catalogue belongs to participant 1. On LoL market 1839 (Maps Handicap 2.0), participant 1 gets +2.0 and participant 2 gets -2.0.
  • Outcome names on Winner and Maps Handicap are literally "1" and "2", and the outcome array order changes from market to market. Read the name, never the position.
def grade(res, market, side, line=0.0):
    """Grade a series bet from the score. side 1/2 = participant, 'over'/'under' for totals."""
    a, b = res["series"]
    if market == "Winner":
        return "WIN" if (a > b) == (side == 1) else "LOSE"
    if market == "Maps Handicap":         # the catalogue line belongs to participant 1
        x = (a - b + line) if side == 1 else (b - a - line)
        return "PUSH" if x == 0 else ("WIN" if x > 0 else "LOSE")
    if market == "Total Maps Over Under":
        maps = a + b
        return "PUSH" if maps == line else ("WIN" if (maps > line) == (side == "over") else "LOSE")
    if market.endswith("Map Winner (incl. overtime)"):
        idx = ["First", "Second", "Third", "Fourth", "Fifth"].index(market.split()[0])
        if not res["maps"] or idx >= len(res["maps"]):
            return "UNDECIDED"            # no per-map score, or the map was never played
        m1, m2 = res["maps"][idx]
        return "WIN" if (m1 > m2) == (side == 1) else "LOSE"

catalogue = {str(m["marketId"]): m for m in get("markets").json()}

fid = sample["fixtureId"]
settled = get("settlements", fixtureId=fid).json()["markets"]
agree = total = 0
for market_id, m in settled.items():
    meta = catalogue[market_id]
    for o in meta["outcomes"]:
        theirs = m["outcomes"][str(o["outcomeId"])]["players"]["0"]["result"]
        name = meta["marketName"]
        if name == "Total Maps Over Under":
            side = "over" if o["outcomeName"].lower().startswith("over") else "under"
        else:
            side = int(o["outcomeName"])  # outcome names are literally "1" and "2"
        ours = grade(results[fid], name, side, meta.get("handicap") or 0.0)
        total += 1
        agree += ours == theirs
print(f"{agree}/{total} outcomes agree with /settlements on {sample['participant1Name']} v {sample['participant2Name']}")
56/56 outcomes agree with /settlements on Hanwha Life Esports v Gen.g Esports

One fixture proves little, so we ran the same grader over 45 series, 15 from each title, picked at random. It matched /v4/settlements on 2,520 of 2,520 outcomes: 2,220 graded WIN, LOSE or PUSH and the rest UNDECIDED. The UNDECIDED rows are map markets for maps the series never reached, or for a CS2 series where the feed carries no per-map key. Your grader should return the same thing rather than guess.

Use your own grader when you need a result the second a series ends. Use /v4/settlements when you want an independent check before you move money. For a full operator settlement pipeline, the odds feed provider guide covers the grid on football.

Step 4: join the closing price and grade the favourite

Finished fixtures ship hasOdds: false, so the live /v4/odds endpoint cannot give you a closing price. /v4/historical-odds can. It replays every snapshot a book posted, at most three books per call. The closing price is the last active snapshot before the series started, not the last snapshot in the list.

WINNER = {"Dota 2": (161, 162), "CS2": (171, 172), "LoL": (181, 182)}
BOOKS = "pinnacle,bet365,1xbet"          # /historical-odds takes at most 3 slugs a call

def closing_prices(fixture):
    """Last ACTIVE pre-match price per book on the series Winner market."""
    kickoff = datetime.fromisoformat((fixture.get("trueStartTime") or fixture["startTime"]).replace("Z", "+00:00"))
    r = get("historical-odds", fixtureId=fixture["fixtureId"], bookmakers=BOOKS)
    time.sleep(4.6)                       # /historical-odds wants a longer gap
    if r.status_code != 200:
        return {}
    m1, m2 = WINNER[fixture["esport"]]
    out = {}
    for slug, book in r.json()["bookmakers"].items():
        market = book["markets"].get(str(m1))
        if not market:
            continue
        legs = []
        for oid in (m1, m2):
            snaps = market["outcomes"].get(str(oid), {}).get("players", {}).get("0", [])
            live = [s for s in snaps if s["active"] and s["price"] > 1.0
                    and datetime.fromisoformat(s["createdAt"].replace("Z", "+00:00")) < kickoff]
            legs.append(live[-1]["price"] if live else None)
        if None not in legs:
            out[slug] = tuple(legs)
    return out

graded = []
for f in played:
    if f["fixtureId"] not in results:
        continue
    closes = closing_prices(f)
    if not closes:
        continue
    a, b = results[f["fixtureId"]]["series"]
    graded.append({"esport": f["esport"], "p1_won": a > b, "closes": closes})

def brier(rows, slug):
    """Mean squared error of the de-vigged closing probability for participant 1."""
    errs = []
    for g in rows:
        if slug in g["closes"]:
            p1, p2 = (1 / x for x in g["closes"][slug])
            p = p1 / (p1 + p2)
            errs.append((p - g["p1_won"]) ** 2)
    return sum(errs) / len(errs), len(errs)

print(len(graded), "decided series with a closing Winner price")
for slug in BOOKS.split(","):
    rows = [g for g in graded if slug in g["closes"] and g["closes"][slug][0] != g["closes"][slug][1]]
    fav_won = sum((g["closes"][slug][0] < g["closes"][slug][1]) == g["p1_won"] for g in rows)
    score, n = brier(graded, slug)
    print(f"  {slug:9s} n={n:3d}  favourite won {fav_won}/{len(rows)} ({fav_won / len(rows):.1%})  Brier {score:.4f}")

common = [g for g in graded if all(s in g["closes"] for s in BOOKS.split(","))]
print(len(common), "series priced by all three books")
for slug in BOOKS.split(","):
    print(f"  {slug:9s} Brier {brier(common, slug)[0]:.4f}")
266 decided series with a closing Winner price
  pinnacle  n=185  favourite won 128/184 (69.6%)  Brier 0.1943
  bet365    n=164  favourite won 103/158 (65.2%)  Brier 0.2143
  1xbet     n=255  favourite won 181/250 (72.4%)  Brier 0.1916
120 series priced by all three books
  pinnacle  Brier 0.2001
  bet365    Brier 0.2172
  1xbet     Brier 0.2129

266 of the 303 decided series carry a closing Winner price from at least one of the three books. The favourite at Pinnacle’s close won 128 of 184 (69.6%), at bet365’s close 103 of 158, and at 1xbet’s close 181 of 250.

Do not rank the books on the first three Brier scores. Each book priced a different set of series, and 1xbet covered the most. On the 120 series that all three priced, Pinnacle scores 0.2001 against 0.2129 for 1xbet and 0.2172 for bet365. Rank books on a common set, the same rule the bookmaker-count study applies to football.

The close also lands at different times. Pinnacle’s last active price came a median 4.4 minutes before the series started, bet365’s 15.4 minutes and 1xbet’s 24.8 minutes. Median closing overround ran 6.92% on Pinnacle, 8.97% on bet365 and 6.65% on 1xbet. If you audit your closing line value against 1xbet, you compare against a price that is almost half an hour old.

What this gives you to build

  • A bet tracker that settles itself. Poll /v4/scores on your open fixture IDs and grade with the function from Step 3.
  • A labelled esports training set. The same loop that powers the football labels study works here: price features from /v4/historical-odds, labels from /v4/scores.
  • A closing-line audit. Compare the price you took against the Step 4 close. The backtest walkthrough shows the rest of that pipeline.
  • A map-handicap checker. The CS2 map handicap guide resolves the 15 market IDs behind that ladder; this post grades them.

Esports results API FAQ

Does OddsPapi have an esports results API?

Yes. /v4/scores returns the final series score for CS2, League of Legends and Dota 2 on the free key, and the map-by-map score on LoL and Dota 2 series. On 23 September 2026 it returned a decided score for 303 series played in the previous ten days.

Can I get esports map scores through the API?

On League of Legends and Dota 2 series longer than one map, /v4/scores ships p1 to p5, one key per map played. All 109 such series in our sample summed exactly to the series score.

How do I settle an esports bet from the API?

Call /v4/settlements with the fixture ID. It returns WIN, LOSE, PUSH or UNDECIDED for every outcome on Winner, Maps Handicap, Total Maps and the map winner markets. A grader built from /v4/scores matched it on 2,520 of 2,520 outcomes across 45 series.

Which market IDs are the esports match winner?

Market 161 for Dota 2, 171 for Counter-Strike and 181 for League of Legends. The outcome names are “1” and “2”, so join the fixture back to /v4/fixtures for participant names.

How do I get esports closing odds?

Call /v4/historical-odds with the fixture ID and up to three bookmaker slugs, then take the last active snapshot before the start time. Across 185 CS2, LoL and Dota 2 series in September 2026, Pinnacle’s last active price landed a median 4.4 minutes before the start.

Grade your first series

Every call above runs on the free tier: 350+ bookmakers in the catalogue, full price history for backtests, and scores and settlements on the same fixture ID. The WebSocket feed pushes the live side when you outgrow polling. Get a free API key and run the four snippets on tonight’s slate.