Champions League Odds API: 17 Books on Every UEFA Tie

Champions League Odds API - OddsPapi API Blog
How To Guides August 7, 2026

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.