EuroLeague Odds API: 205 Books, Two Result Markets, One Draw Leg
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:
tipicoships 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.stakeshipped 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.dedid 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.