Champions League Odds API: 17 Books on Every UEFA Tie
UEFA does not sell a public Champions League odds API. There is no developer portal, no self-serve key, and no endpoint that returns what 17 bookmakers think about tonight’s qualifier. What exists instead is a scattering of scraped feeds, enterprise contracts that start with a sales call, and generic football APIs that refresh their odds once a day.
You can get the data anyway. This guide pulls live Champions League, Europa League and Conference League prices from 383 bookmakers through one endpoint, in Python, on a free key. Every number below came off the live API on 29 July 2026 while the qualifying round was being priced.
What the European club stack actually looks like in the feed
UEFA runs three club competitions, and OddsPapi carries all three as separate tournaments under soccer (sportId=10). Here is what the API returned for the next nine days:
| Competition | tournamentId | Fixtures | With odds |
|---|---|---|---|
| UEFA Champions League | 7 | 18 | 12 |
| UEFA Europa League | 679 | 24 | 10 |
| UEFA Conference League | 34480 | 77 | 45 |
Two details matter before you write a single parser.
First, the qualifying bracket is published before anyone knows who is in it. Of the 119 European fixtures in that window, 33 carried participant names like Winner Match 11 against Winner Match 6. They are real fixture objects with real IDs and real kick-off times, and they all return hasOdds: false. Filter them out or your fixture list fills with ties that have no teams.
Second, book depth is a function of how close kick-off is. The eight ties kicking off within hours carried 15 to 17 bookmakers each. The ties five days out carried one. Books post European qualifiers roughly two days ahead, so a census run on a quiet Tuesday will understate coverage badly.
Old way vs OddsPapi
| Job | Scraping or a generic football API | OddsPapi |
|---|---|---|
| Books per tie | One at a time, or a handful | 15 to 17 in one call |
| Sharp pricing | Rarely included | Pinnacle and SBOBet on every priced tie |
| Refresh | Often once a day | Live, with WebSocket push |
| Corners, correct score | Usually dropped | 64 corner markets, correct score to 57 scorelines |
| Price history | Paid add-on | Free tier |
| Access | Sales call | Self-serve key |
Step 1: Authenticate and find the competitions
The key rides as a query parameter on every call. It is never a header.
import time
import requests
API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"
def get(path, **params):
params["apiKey"] = API_KEY
r = requests.get(f"{BASE_URL}{path}", params=params)
r.raise_for_status()
time.sleep(1.0) # the free tier rate-limits per endpoint
return r.json()
def european_tournaments():
wanted = {"UEFA Champions League", "UEFA Europa League", "UEFA Conference League"}
return {
t["tournamentName"]: t["tournamentId"]
for t in get("/tournaments", sportId=10)
if t["tournamentName"] in wanted
}
for name, tid in european_tournaments().items():
print(f"{tid:>6} {name}")
7 UEFA Champions League
679 UEFA Europa League
34480 UEFA Conference League
Resolve the IDs at runtime rather than pasting them into a config file. Tournament IDs are stable across a season, but new competitions and women’s variants share the same name prefixes, and hardcoding is how you end up parsing the Women’s Champions League by accident.
Step 2: Pull fixtures and drop the placeholders
/fixtures takes a date range of up to ten days. Filter on hasOdds and on the bracket placeholder pattern in the same pass.
from datetime import datetime, timedelta, timezone
def priced_fixtures(tournament_name, days=9):
today = datetime.now(timezone.utc)
fixtures = get("/fixtures", sportId=10, **{
"from": today.strftime("%Y-%m-%d"),
"to": (today + timedelta(days=days)).strftime("%Y-%m-%d"),
})
return [
f for f in fixtures
if f["tournamentName"] == tournament_name
and f["hasOdds"]
and "Winner Match" not in f["participant1Name"]
]
for f in priced_fixtures("UEFA Champions League"):
print(f["fixtureId"], f["participant1Name"], "v", f["participant2Name"])
id1000000772176812 FC Kairat Almaty v AC Omonia Nicosia
id1000000772176794 FK Kauno Zalgiris v KI Klaksvik
id1000000772176798 KKS Lech Poznan v AGF Aarhus
id1000000772176808 CS Universitatea Craiova v PFC Levski Sofia
id1000000772176838 Hapoel Be`er Sheva FC v Vikingur Reykjavik
id1000000772176722 FK Crvena Zvezda Belgrade v Larne FC
id1000000772177116 Gornik Zabrze v Fenerbahce Istanbul
id1000000772176844 SK Slovan Bratislava v FC Iberia 1999
Team names live on participant1Name and participant2Name. The nested participants list on the fixture object comes back empty, so do not reach for it.
Step 3: Build the market lookup
Soccer carries 32,815 market IDs once you count every handicap and total line as its own market. Nobody should hardcode that. Pull the catalog once and resolve markets by name, handicap and period.
CATALOG = get("/markets", sportId=10)
OUTCOME_NAME = {
(m["marketId"], o["outcomeId"]): o["outcomeName"]
for m in CATALOG for o in m.get("outcomes", [])
}
def find_market(name, handicap=0, period="fulltime"):
for m in CATALOG:
if (m["marketName"] == name
and m.get("handicap") == handicap
and m.get("period") == period):
return m["marketId"]
return None
FT_RESULT = find_market("Full Time Result")
print(FT_RESULT) # 101
The 1X2 outcomes come back labelled 1, X and 2, matching the European convention rather than home/draw/away.
Step 4: Read every book on one tie
The worked example is Gornik Zabrze against Fenerbahce Istanbul (id1000000772177116), a second qualifying round tie that drew 15 bookmakers.
def read_market(fixture_id, market_id):
payload = get("/odds", fixtureId=fixture_id)
table = {}
for slug, data in payload.get("bookmakerOdds", {}).items():
market = data["markets"].get(str(market_id))
if not market:
continue
prices = {}
for outcome_id, outcome in market["outcomes"].items():
quote = outcome["players"].get("0")
if not quote or quote.get("active") is False:
continue
label = OUTCOME_NAME.get((market_id, int(outcome_id)), outcome_id)
prices[label] = quote["price"]
if prices:
table[slug] = prices
return table
board = read_market("id1000000772177116", FT_RESULT)
for slug, prices in sorted(board.items()):
print(f"{slug:20s} {prices}")
bet365 {'1': 5.75, 'X': 4.33, '2': 1.48}
betmgm {'1': 6.25, 'X': 4.5, '2': 1.49}
betparx {'1': 6.5, 'X': 4.3, '2': 1.47}
borgata {'1': 6.25, 'X': 4.5, '2': 1.49}
draftkings {'1': 6.0, 'X': 4.3, '2': 1.51}
fanduel {'1': 6.0, 'X': 4.2, '2': 1.48}
fourwinds {'1': 6.5, 'X': 4.3, '2': 1.47}
hardrockbet {'1': 6.5, 'X': 4.25, '2': 1.476}
kalshi {'1': 7.143, 'X': 4.545, '2': 1.515}
pinnacle {'1': 6.02, 'X': 4.24, '2': 1.51}
pointsbet.com.au {'1': 6.0, 'X': 4.5, '2': 1.48}
polymarket {'1': 6.667, 'X': 4.545, '2': 1.562}
sbobet {'1': 5.5, 'X': 3.93, '2': 1.45}
Filter on active is False rather than on a truthy active. The live feed ships active: null next to perfectly valid prices on pre-match fixtures, and a truthy test silently throws those away.
Dedupe before you average anything
Thirteen slugs quoted this tie. Eleven opinions came back. BetMGM and Borgata posted byte-identical prices, and so did BetParx and FourWinds, yet /v4/bookmakers reports cloneOf: null for all four. Average the raw list and you triple-count one trading desk.
def dedupe(board):
seen, unique = {}, {}
for slug, prices in board.items():
key = tuple(sorted(prices.items()))
if key in seen:
seen[key].append(slug)
continue
seen[key] = [slug]
unique[slug] = prices
clones = {v[0]: v[1:] for v in seen.values() if len(v) > 1}
return unique, clones
unique, clones = dedupe(board)
print(f"{len(board)} slugs -> {len(unique)} independent quotes")
for keeper, dupes in clones.items():
print(f" {keeper} == {', '.join(dupes)}")
13 slugs -> 11 independent quotes
betmgm == borgata
betparx == fourwinds
Step 5: De-vig Pinnacle for a fair price
Pinnacle priced every UCL qualifier in the sample. Strip its margin and you have a benchmark to grade the other ten books against.
def devig(prices):
overround = sum(1 / p for p in prices.values())
return {k: (1 / p) / overround for k, p in prices.items()}, overround - 1
fair, vig = devig(board["pinnacle"])
print(f"vig {vig * 100:.2f}%")
for label, prob in fair.items():
print(f" {label}: {prob * 100:.1f}% fair price {1 / prob:.3f}")
vig 6.42%
1: 15.6% fair price 6.407
X: 22.2% fair price 4.512
2: 62.2% fair price 1.607
That 6.42% is wide for Pinnacle. On a major-league fixture it runs closer to 2%. Qualifying ties between clubs the market barely knows carry more margin because the book is less sure, and the limit data in step 7 confirms it.
Step 6: Find the best available price
def best_price(board):
best = {}
for slug, prices in board.items():
for label, price in prices.items():
if label not in best or price > best[label][1]:
best[label] = (slug, price)
return best
for label, (slug, price) in best_price(unique).items():
delta = (price / (1 / fair[label]) - 1) * 100
print(f"{label}: {price:6.3f} @ {slug:12s} vs fair {1 / fair[label]:6.3f} ({delta:+.1f}%)")
1: 7.143 @ kalshi vs fair 6.407 (+11.5%)
X: 4.545 @ kalshi vs fair 4.512 (+0.7%)
2: 1.562 @ polymarket vs fair 1.607 (-2.8%)
Kalshi’s 7.143 on Gornik returns 30% more than SBOBet’s 5.5 for the same stake on the same outcome. That gap is the entire argument for reading more than one book.
Step 7: The honest read on that 11.5%
A price 11.5% above the sharp fair line looks like free money. Two API fields say otherwise, and both are worth checking before you stake anything.
Kalshi is an exchange, so the outcome carries an exchangeMeta ladder instead of a single number. The top rung is thin:
payload = get("/odds", fixtureId="id1000000772177116")
quote = payload["bookmakerOdds"]["kalshi"]["markets"]["101"]["outcomes"]["101"]["players"]["0"]
for level in quote["exchangeMeta"]["back"]:
print(f" price {level['price']:.3f} stake capacity ${level['limit']:,.0f}")
price 7.143 stake capacity $914
price 6.667 stake capacity $384
price 6.250 stake capacity $3,283
You can get $914 down at 7.143. After that the price drops to 6.667, then 6.25. The edge is real and it is small, which is the normal shape of a genuine one.
Pinnacle’s own limit field tells the same story from the other side. It quoted a $450 max win on Gornik and $882 on Fenerbahce. On an MLB moneyline the equivalent figure runs to $7,500. Pinnacle is holding this tie at a sixteenth of its baseball confidence, which explains the fat 6.42% margin.
Then check whether the line has been moving. Free historical odds settle it:
history = get("/historical-odds", fixtureId="id1000000772177116", bookmakers="pinnacle")
snaps = history["bookmakers"]["pinnacle"]["markets"]["101"]["outcomes"]["101"]["players"]["0"]
print(f"{len(snaps)} snapshots")
print("open ", snaps[0]["createdAt"], snaps[0]["price"])
print("last ", snaps[-1]["createdAt"], snaps[-1]["price"])
78 snapshots
open 2026-07-24T09:16:11.479Z 5.67
last 2026-07-29T13:09:09.238Z 6.02
Pinnacle opened Gornik at 5.67 and drifted to 6.02 across five days and 78 snapshots. Money has been leaving Gornik the whole time, and Kalshi sits further down that same path. A price that agrees with the direction of sharp movement is a much better bet than one that fights it. Grade every outlier this way before you trust it, because the ones that fight the drift are usually a book that has not updated.
Note that /historical-odds takes a maximum of three bookmakers per call and returns large payloads. Loop it with a longer pause than you use on /odds.
Step 8: Corners, the market European books actually compete on
US sportsbooks build deep player-prop menus. European books build corner ladders, and the Champions League feed is where that shows. This single tie carried 64 distinct corner markets: full-time totals from 4.5 up to 13.5, first-half and second-half splits, per-team counts, odd/even, a corners handicap, and a corners 1X2.
corner_lines = [m for m in CATALOG
if m["marketName"] == "Corners - Over Under Full Time"]
payload = get("/odds", fixtureId="id1000000772177116")
books = payload["bookmakerOdds"]
for m in sorted(corner_lines, key=lambda m: m["handicap"]):
quotes = {}
for slug, data in books.items():
market = data["markets"].get(str(m["marketId"]))
if not market:
continue
sides = {}
for outcome_id, outcome in market["outcomes"].items():
q = outcome["players"].get("0")
if not q or q.get("active") is False:
continue
sides[OUTCOME_NAME[(m["marketId"], int(outcome_id))]] = q["price"]
if sides:
quotes[slug] = sides
if quotes:
both = sum(1 for s in quotes.values() if len(s) == 2)
print(f"corners {m['handicap']}: {len(quotes)} books ({both} two-sided)")
corners 7.5: 6 books (5 two-sided)
corners 8.5: 6 books (5 two-sided)
corners 9.5: 9 books (8 two-sided)
corners 10.5: 7 books (6 two-sided)
corners 11.5: 6 books (5 two-sided)
corners 12.5: 4 books (3 two-sided)
corners 13.5: 2 books (1 two-sided)
The main line at 9.5 drew nine books including both sharps:
| Book | Over 9.5 | Under 9.5 |
|---|---|---|
| pinnacle | 1.680 | 2.050 |
| sbobet | 1.725 | 2.020 |
| polymarket | 1.786 | 2.128 |
| bet365 | 1.800 | 1.900 |
| betmgm | 1.880 | 1.800 |
| betparx | 1.760 | 1.920 |
| fanduel | 1.780 | (no price) |
Count the two-sided books separately, as the snippet above does. FanDuel posted an Over and no Under on this line, and any de-vig routine that assumes both sides exist will divide by a broken overround.
Corners also cost more than the main market. De-vigging Pinnacle’s 1.68 and 2.05 gives an 8.30% margin against 6.42% on the 1X2. Books charge for the markets fewer people shop.
Step 9: Correct score, the other native market
The same tie carried correct score priced by 14 books, and the depth varies enormously: FanDuel listed 57 scorelines, BetMGM 44, Pinnacle 20, bet365 14. Market 10336 holds full-time correct score, and each outcome is a scoreline label rather than a player entry.
Depth matters here more than price. A book quoting 57 scorelines has an opinion about 4-2; a book quoting 14 is covering the obvious ones and sending everything else to a catch-all. If you are fitting a goals model, pull from the deep menus and treat the shallow ones as unusable.
Where this fits
The same eight steps work on the Europa League and the Conference League by changing one string. Conference League had 45 priced fixtures in the sample window against the Champions League’s 12, so it is the better place to test a scanner while the bigger competition is still in qualifying.
If you want the wider soccer picture rather than the European competitions specifically, start with our football odds API guide. For the price-comparison logic in step 6 applied across every sport, see line shopping in Python. The de-vig in step 5 has two more methods worth knowing in the no-vig odds guide, and consensus odds covers blending books once you have deduped them. The limit and ladder analysis in step 7 goes much deeper in the betting limits guide. Asian handicap ladders, which European books price alongside corners, are covered in the Asian handicap API post.
FAQ
Is there an official UEFA Champions League odds API?
No. UEFA licenses data through commercial partners and does not publish odds. Bookmakers price the matches, so an aggregator that reads many books is the practical route to Champions League odds.
How many bookmakers price a Champions League match?
Between 15 and 17 in the qualifying ties measured on 29 July 2026, including Pinnacle and SBOBet. Deduping identical feeds cut 13 slugs to 11 independent quotes on the worked example, so count opinions rather than slugs.
Why do some Champions League fixtures have no teams?
The qualifying bracket is published in advance, so unresolved ties appear with participant names like “Winner Match 11”. They return hasOdds: false until the previous round finishes. Filter on that flag and on the name pattern.
Can I get corner odds through the API?
Yes. One qualifying tie carried 64 corner markets, with nine books on the main over/under 9.5 line. Resolve them by name from /v4/markets?sportId=10 and check for one-sided quotes before de-vigging.
Is historical Champions League odds data free?
Yes, on the free tier. The worked example returned 78 Pinnacle snapshots going back five days, enough to see the line drift from 5.67 to 6.02. Requests take up to three bookmakers each.
Get a key
Stop scraping bookmaker pages that break every time a site ships a redesign. Grab a free OddsPapi key, run the eight steps above against tonight’s qualifiers, and see what 383 books think before you back anything.