EuroLeague Odds API: 205 Books, Two Result Markets, One Draw Leg

EuroLeague Odds API featured image
How To Guides September 19, 2026

EuroLeague opening night is 24 September, and the board is already open. One call to the OddsPapi EuroLeague odds API on 9 September returned 205 bookmakers, 2,933 market IDs and 46,720 prices on Dubai Basketball v Real Madrid, fifteen days before tip-off.

Then the parsing problem starts. Basketball ships the match result under two different market IDs, and they are two different bets. Market 111 is the two-way winner including overtime. Market 113 is the three-way regular-time result, and it carries a draw leg priced between 9.0 and 21.0. Read one and you miss the other. Merge them and you have mixed two settlement rules into one number.

The Two Result Markets, Measured

Every number below comes from live calls made on 9 September 2026 against four EuroLeague fixtures on the 24 September slate.

Market ID Legs Books quoting it Median margin
Winner (incl. overtime) 111 2 (111 home, 112 away) 188 to 192 5.95% to 6.49%
Regular Time Result 113 3 (113 home, 114 draw, 115 away) 69 on all four fixtures 9.12% to 9.41%

The three-way costs about 2.8 percentage points more margin than the two-way on the same fixture. That gap is the price of the draw leg, and a regulation tie is a real outcome in a EuroLeague game.

No sharp book and no exchange quotes market 113. On Puerto Rico v China at the FIBA Women’s World Cup, hours before tip-off, the board ran 248 books deep and the sharp menus looked like this:

Bookmaker Markets on the fixture Quotes market 113? Market types carried
pinnacle 63 No spreads, totals, moneyline
betfair-ex 213 No totals, spreads, moneyline
kalshi 18 No spreads, moneyline, totals
polymarket 1 No moneyline
bet365 204 No team totals, totals, spreads, moneyline
draftkings 140 No spreads, totals, team totals, moneyline

144 of the 248 books quoted the three-way. Not one of them was a sharp. Pinnacle carried 364 markets on last season’s EuroLeague final and market 113 was absent from all of them. If you want a fair price on the regular-time draw, you build it from the two-way sharp line and a tie model. There is no sharp anchor to copy.

Old Way vs OddsPapi

Task The old way OddsPapi
Find EuroLeague fixtures Scrape a schedule page, map team names by hand /v4/fixtures?sportId=11&tournamentId=138
Get the winner price One book, one moneyline 205 books in one call, both result markets
Price the regular-time draw Not available from any sharp feed 69 books on market 113, draw leg included
Backtest last season Paid history, or none Free /v4/historical-odds, snapshot by snapshot
Grade the bet Match a box score to a market by hand /v4/settlements returns WIN, LOSE, PUSH per outcome

Step 1: Find the Fixtures

Basketball is sportId 11. EuroLeague is tournamentId 138, which you read once from /v4/tournaments and then cache. Auth is a query parameter, never a header.

import os, requests

API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE = "https://api.oddspapi.io/v4"
EUROLEAGUE = 138          # tournamentId, from /v4/tournaments?sportId=11

def euroleague_fixtures(start, end):
    r = requests.get(f"{BASE}/fixtures", params={
        "apiKey": API_KEY, "sportId": 11,
        "tournamentId": EUROLEAGUE, "from": start, "to": end,
    })
    r.raise_for_status()
    return r.json()

fixtures = euroleague_fixtures("2026-09-09", "2026-11-30")
print(f"{len(fixtures)} fixtures, {sum(f['hasOdds'] for f in fixtures)} flagged hasOdds")
for f in sorted(fixtures, key=lambda x: x["startTime"])[:3]:
    print(f["fixtureId"], f["startTime"][:16],
          f["participant1Name"], "v", f["participant2Name"])
140 fixtures, 140 flagged hasOdds
id1100013873262938 2026-09-24T16:00 Dubai Basketball v Real Madrid
id1100013873262940 2026-09-24T17:00 Hapoel Tel-Aviv v Bayern Munich
id1100013873262942 2026-09-24T18:00 KK Crvena zvezda Belgrade v BC Zalgiris Kaunas

Adding tournamentId also lifts the ten-day cap that /fixtures applies when sportId is the only filter. That is how the call above reaches into November.

hasOdds Is a Flag, Not a Board

All 140 EuroLeague fixtures come back with hasOdds: true. Fewer carry a live board today, because the books open the European competitions on their own schedule. Probe /odds before you commit a fixture to a pipeline.

import os, time, requests

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

def board(fixture_id):
    r = requests.get(f"{BASE}/odds",
                     params={"apiKey": API_KEY, "fixtureId": fixture_id})
    r.raise_for_status()
    return r.json().get("bookmakerOdds")   # absent when no book has opened

for fixture_id, label in [
    ("id1100013873262938", "EuroLeague  Dubai v Real Madrid"),
    ("id1100014172902422", "EuroCup     Neptunas v Trento"),
    ("id1100026473713458", "Liga ACB    Baskonia v Valencia"),
]:
    books = board(fixture_id)
    time.sleep(1.2)          # /odds is rate limited; pace the loop
    if not books:
        print(f"{label}: no book has opened this fixture yet")
        continue
    prices = active = 0
    markets = set()
    for feed in books.values():
        for market_id, market in feed["markets"].items():
            markets.add(market_id)
            for outcome in market["outcomes"].values():
                for price in outcome["players"].values():
                    prices += 1
                    active += 1 if price["active"] else 0
    print(f"{label}: {len(books)} books, {len(markets)} market IDs, "
          f"{prices} prices, {active/prices:.1%} active")
EuroLeague  Dubai v Real Madrid: 205 books, 2933 market IDs, 46720 prices, 17.1% active
EuroCup     Neptunas v Trento: 49 books, 35 market IDs, 796 prices, 100.0% active
Liga ACB    Baskonia v Valencia: no book has opened this fixture yet

Three competitions, three answers, one API call each. EuroLeague runs 205 books deep. The EuroCup opener five days later runs 49 books and 35 market IDs. Liga ACB, the Basketball Champions League, the Adriatic League and the German BBL all return HTTP 200 with bookmakerOdds absent, because their boards had not opened when we measured.

Look at the active rate in that output. The 205-book EuroLeague board is 17.1% active. The 49-book EuroCup board is 100% active. A wide board is mostly parked rungs waiting to be turned on, so filter on the price-level active flag or your depth count is three to six times too high.

Step 2: Read Both Result Markets

Now the core of it. One function reads a market and returns its active legs. The caller checks the leg count before it trusts the margin.

import os, statistics, requests

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

WINNER_OT = "111"    # Winner (incl. overtime)  -> outcomes 111 home, 112 away
REG_TIME  = "113"    # Regular Time Result      -> outcomes 113 home, 114 draw, 115 away

def legs(feed, market_id):
    """Return {outcomeId: price} for one market, or None if the book skips it."""
    market = feed["markets"].get(market_id)
    if not market:
        return None
    out = {}
    for outcome_id, outcome in market["outcomes"].items():
        price = outcome["players"].get("0")
        if price and price["active"] and price["price"]:
            out[outcome_id] = price["price"]
    return out

def read_both(fixture_id):
    r = requests.get(f"{BASE}/odds",
                     params={"apiKey": API_KEY, "fixtureId": fixture_id})
    r.raise_for_status()
    books = r.json()["bookmakerOdds"]
    two_way, three_way = {}, {}
    for slug, feed in books.items():
        ml = legs(feed, WINNER_OT)
        if ml and len(ml) == 2:                 # both legs, or the margin lies
            two_way[slug] = ml
        x2 = legs(feed, REG_TIME)
        if x2 and len(x2) == 3:                 # home, draw, away
            three_way[slug] = x2
    return books, two_way, three_way

def margin(prices):
    return sum(1 / p for p in prices.values()) - 1

books, two_way, three_way = read_both("id1100013873262938")
print(f"{len(books)} books on the board")
print(f"  market 111 complete and active: {len(two_way)}  "
      f"median margin {statistics.median(map(margin, two_way.values())):.2%}")
print(f"  market 113 complete and active: {len(three_way)}  "
      f"median margin {statistics.median(map(margin, three_way.values())):.2%}")
draws = sorted(p["114"] for p in three_way.values())
print(f"  draw leg: {draws[0]} to {draws[-1]}, median {statistics.median(draws)}")
205 books on the board
  market 111 complete and active: 179  median margin 6.49%
  market 113 complete and active: 67  median margin 9.12%
  draw leg: 9.0 to 21.0, median 14.0

A draw leg spanning 9.0 to 21.0 across 67 books is a 6.4 point spread in implied probability on a single outcome. Nobody is competing on the regulation tie, which is what you would expect from a leg no sharp book prices.

Count the Legs Before You Compute a Margin

The len(ml) == 2 and len(x2) == 3 checks in that function are load-bearing. Three examples from the same four fixtures:

  • tipico ships market 113 with the draw leg absent, and skips market 111 entirely. Its two remaining legs sum to a 6.4% overround, which reads like the best three-way on the board until you count the outcomes.
  • stake shipped market 111 with one leg, an away price of 2.08 and no home price. Sum that pair and you get an overround of minus 52%.
  • sportwetten.de did the same on two of the four fixtures.

An unguarded margin ranking puts all three at the top of the leaderboard. The same trap shows up on the AFL board, where 15 books ship the three-way without a draw, and on boxing, where one fight ships under three market IDs.

Step 3: Dedupe, Then Shop the Line

205 slugs is not 205 opinions. Distinct bookmaker slugs quote byte-identical prices, and cloneOf does not catch all of them, so collapse on the price tuple before you count anything.

import collections, statistics

tuples = collections.Counter(
    tuple(sorted(quote.items())) for quote in two_way.values())
print(f"{len(two_way)} quotes collapse to {len(tuples)} distinct prices "
      f"({1 - len(tuples)/len(two_way):.1%})")
print("largest cluster:", tuples.most_common(1)[0][1], "slugs at",
      dict(tuples.most_common(1)[0][0]))

for outcome_id, label in [("111", "home"), ("112", "away")]:
    prices = [q[outcome_id] for q in two_way.values()]
    mid = statistics.median(prices)
    sane = {slug: q[outcome_id] for slug, q in two_way.items()
            if 0.5 * mid <= q[outcome_id] <= 1.5 * mid}   # outlier band
    best = max(sane, key=sane.get)
    print(f"{label}: median {mid}, best {sane[best]} at {best}, "
          f"{len(prices) - len(sane)} price(s) outside the band")
179 quotes collapse to 48 distinct prices (73.2%)
largest cluster: 22 slugs at {'111': 1.74, '112': 2.04}
home: median 1.74, best 1.812 at 1xbet, 0 price(s) outside the band
away: median 2.05, best 2.2 at hardrockbet, 0 price(s) outside the band

Collapse ran 71.9% to 75.9% across the four EuroLeague fixtures and 82.9% to 85.4% on the EuroCup, where 41 quotes fold into six or seven prices. Twenty-two slugs sitting on 1.74 / 2.04 is one price wearing 22 badges. Treat it as one vote when you build a consensus, or your fair-odds calculation inherits whichever feed happens to have the most skins.

The outlier band matters even when it catches nothing today. A EuroLeague price of 80.0 on a two-way leg is a data error, and an unguarded max() hands it to you as free money. Keep the band. For the full walkthrough, see line shopping across 350+ books.

Step 4: Pull Last Season for Free

/v4/historical-odds returns every snapshot the feed recorded, at no cost on the free tier. Cap it at three bookmakers per call. Here is Pinnacle on the 2026 EuroLeague final, Olympiakos v Real Madrid.

import os, requests

API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE = "https://api.oddspapi.io/v4"
FINAL = "id1100013870449608"        # Olympiakos v Real Madrid, 24 May 2026
TIP_OFF = "2026-05-24T18:02:26"

r = requests.get(f"{BASE}/historical-odds", params={
    "apiKey": API_KEY, "fixtureId": FINAL, "bookmakers": "pinnacle",
})                                   # max 3 bookmakers per call
r.raise_for_status()
markets = r.json()["bookmakers"]["pinnacle"]["markets"]
print(f"pinnacle carried {len(markets)} markets on this fixture; "
      f"market 113 present: {'113' in markets}")

for outcome_id, label in [("111", "Olympiakos"), ("112", "Real Madrid")]:
    snaps = [s for s in markets["111"]["outcomes"][outcome_id]["players"]["0"]
             if s["active"] and s["createdAt"][:19] < TIP_OFF]
    print(f"{label:12s} {len(snaps):4d} pre-tip snapshots  "
          f"open {snaps[0]['price']} -> close {snaps[-1]['price']}  "
          f"limit {snaps[0]['limit']:.0f} -> {snaps[-1]['limit']:.0f}")
pinnacle carried 364 markets on this fixture; market 113 present: False
Olympiakos    110 pre-tip snapshots  open 1.347 -> close 1.342  limit 864 -> 29239
Real Madrid   108 pre-tip snapshots  open 3.22 -> close 3.41  limit 300 -> 10000

Pinnacle’s margin tightened from 5.29% at open to 3.84% at the close, which is the standard sharpening curve you want as a backtest anchor. And the limit field climbed from 864 to 29,239 on the Olympiakos leg, peaking at 51,813. Pinnacle raises its maximum stake as the line firms, so a historical price without its limit tells you half the story.

Filter on createdAt < startTime if you want a true closing line, because snapshots keep landing after tip-off. Before you decide how many books your study needs, read the 176-book study on where the consensus curve flattens.

Step 5: Grade the Bet

/v4/scores returns the period breakdown and /v4/settlements returns a graded result per outcome. Together they close the loop on both result markets and on all four quarter markets.

import os, collections, time, requests

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

def call(path):
    for _ in range(4):
        r = requests.get(f"{BASE}{path}",
                         params={"apiKey": API_KEY, "fixtureId": FINAL})
        if r.status_code == 200:
            return r.json()
        time.sleep(2.5)                # /settlements throttles harder than /odds
    r.raise_for_status()

periods = call("/scores")["scores"]["periods"]
for key in ["p1", "p2", "p3", "p4", "result"]:
    p = periods[key]
    print(f"{key:7s} {p['participant1Score']}-{p['participant2Score']}")

markets = call("/settlements")["markets"]
results = collections.Counter(
    price["result"]
    for market in markets.values()
    for outcome in market["outcomes"].values()
    for price in outcome["players"].values())
graded = sum(1 for m in markets.values()
             if all(p["result"] != "UNDECIDED"
                    for o in m["outcomes"].values()
                    for p in o["players"].values()))
print(f"{len(markets)} markets, {graded} fully graded, {dict(results)}")

for market_id, label in [("111", "Winner (incl. overtime)"),
                         ("113", "Regular Time Result"),
                         ("11341", "Fourth Quarter Result")]:
    grades = {outcome_id: list(o["players"].values())[0]["result"]
              for outcome_id, o in markets[market_id]["outcomes"].items()}
    print(f"{market_id:6s} {label:24s} {grades}")
p1      19-26
p2      27-18
p3      15-21
p4      31-20
result  92-85
4902 markets, 3160 fully graded, {'WIN': 3146, 'LOSE': 3153, 'PUSH': 28, 'UNDECIDED': 3804}
111    Winner (incl. overtime)  {'111': 'WIN', '112': 'LOSE'}
113    Regular Time Result      {'113': 'WIN', '114': 'LOSE', '115': 'LOSE'}
11341  Fourth Quarter Result    {'11341': 'WIN', '11342': 'LOSE', '11343': 'LOSE'}

3,160 of the 4,902 basketball markets graded on that fixture, with 28 PUSH results on the whole-number handicaps. The 3,804 UNDECIDED outcomes are rungs no book ever priced, so they never opened and never resolved. Olympiakos led at the end of regulation and won 92-85, so market 111 and market 113 graded the same way here. They would not have if the game had gone to overtime tied.

The Rest of the Basketball Menu

The sport catalogue carries 4,902 market IDs for basketball. Beyond the two result markets, the useful families are:

Family Example market ID Periods available
Handicap (incl. overtime) 11450 at -1.5 result, halves, each quarter
Over / Under (incl. overtime) 11144 at 169.5 result, halves, each quarter
Team totals 111408, 111586 result, halves, each quarter
Quarter and half 1X2 11332, 11326 p1 to p4, first and second half
Player points, rebounds, assists 112254, 112305, 112389 result, first quarter

On the EuroLeague opener the deepest secondary markets were the 169.5 total (156 books) and the -1.5 handicap (147 books). Player props run as ladders of 10+, 11+, 12+ outcomes rather than a single over/under line, and the player is keyed by player ID inside the players dictionary. The mechanics are the same as NFL, NBA and MLB props.

One catalogue note that will save you a debugging session: /v4/markets ignores the sportId parameter and returns all 33,115 market definitions. Filter on m["sportId"] == 11 in your own code.

Why OddsPapi

The catalogue holds 357 bookmaker slugs across 69 sports, and a single EuroLeague fixture pulls 205 of them in one call. Historical odds are free, snapshot by snapshot, with Pinnacle’s stake limits attached. Native market structures mean the Asian handicap ladder and the quarter markets arrive as real market IDs rather than a text blob you have to parse. And a WebSocket feed pushes in-play moves instead of making you poll.

Start on the free tier, which covers every endpoint in this post. Get a key and run the fixture call against tonight’s board.

FAQ

What is the market ID for the EuroLeague match winner?

Market 111, “Winner (incl. overtime)”, with outcome 111 for the home participant and 112 for the away participant. 192 of the 205 books on the 24 September opener quoted it.

Why does a basketball fixture have a draw market?

Market 113 is the “Regular Time Result”, which settles on the score at the end of the fourth quarter and before any overtime. A tie at that point is a real outcome, so the market carries three legs. 69 books quoted it on each of the four EuroLeague openers we measured, at a median 9.12% margin against 6.49% for the two-way.

Does Pinnacle price EuroLeague basketball?

Yes. Pinnacle carried 364 markets on the 2026 EuroLeague final, with 110 pre-tip price changes on the favourite and stake limits rising from 864 to 29,239. Its menu is moneyline, spreads and totals, so the three-way regular-time market is one you build from soft-book prices.

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

All of it. /v4/historical-odds is free and returns every recorded snapshot. On the 2026 final the Pinnacle history opened 35 hours before tip-off and ran to 708 snapshots on the home leg. Request up to three bookmakers per call.

How do I grade a EuroLeague bet after the game?

/v4/settlements returns WIN, LOSE, PUSH, HALFWIN, HALFLOSS or UNDECIDED per outcome, and /v4/scores returns the quarter-by-quarter breakdown. On the 2026 final that graded 3,160 markets, including both result markets and all four quarter results.