Skip to main content

Oddsportal API Alternative: Football Odds History in Python

Oddsportal API Alternative: Football Odds History in Python

Oddsportal is where many people go to look up past football odds, with opening and closing prices from a long list of bookmakers. Oddschecker does the same job for today's prices, with a grid of UK bookmakers and the best price for each outcome highlighted. If you want either view inside your own code, you soon find that neither site offers a self-serve public API.

This guide builds both views in Python with the Odds-API.io v3 API: closing odds for the 2026/27 Premier League and LaLiga seasons so far in a pandas DataFrame, a best-price grid across UK bookmakers for the next round of fixtures, and the price history of a Bet365 market. Every script was run against the live API on 5 November 2026, and the output shown is what came back.

Why developers look for an Oddsportal API

People searching for an Oddsportal API, an Oddschecker API or an Oddsportal parser usually want one of three things. Each maps to one endpoint:

What you wantSite you know it fromEndpoint
Closing odds per bookmaker for past matchesOddsportal results pages/historical/closing-lines
Best current price per outcome across UK booksOddschecker odds grid/odds/multi
How one bookmaker's price moved before kick-offOdds movement charts/odds/movements

Without an API, the usual route is a parser for the web pages, which needs fixing when the page markup changes. An odds API returns the data as JSON, with event ids, bookmaker names and market names that do not depend on any page layout.

Setup

You need Python 3, two packages and an API key on a paid plan, which the bulk closing-lines endpoint needs. Install the packages with pip install requests pandas and set your key in the ODDS_API_KEY environment variable. Responses are gzip-compressed, which requests handles for you. If you test with curl, add --compressed.

Markets use the API's own names: ML is the 1X2 match result (home, draw, away), Totals is goals over or under a line, and Spread is the Asian handicap, with the line in the hdp field.

Historical odds API: Premier League and LaLiga closing lines

The closing-lines endpoint returns the settled matches for up to 10 leagues in a date range, with the final score and each bookmaker's last price before kick-off where one was recorded. One request can span up to 366 days, so a full season fits in one date range, and you page through it 100 matches at a time with limit and skip. The script below pulls the 2026/27 Premier League and LaLiga seasons so far from 16 UK bookmakers, flattens them into one row per price, and works out each bookmaker's median margin on the match result market. Save it as closing_lines.py, and save the next two scripts as best_prices.py and line_history.py in the same folder, since they import from it.

import os
import requests
import pandas as pd

API = "https://api.odds-api.io/v3"
KEY = os.environ["ODDS_API_KEY"]
UK_BOOKS = [
    "Bet365", "William Hill", "Skybet", "Paddy Power", "Betfair Exchange",
    "Betfair Sportsbook", "Ladbrokes", "Coral", "BoyleSports", "BetVictor",
    "Unibet UK", "Virgin Bet", "888Sport", "Betway", "10BET", "BetMGM UK",
]


def get(path, **params):
    r = requests.get(f"{API}{path}", params={"apiKey": KEY, **params}, timeout=120)
    r.raise_for_status()
    return r.json()


def closing_lines(leagues, start, end, markets="ML,Totals,Spread"):
    events, skip = [], 0
    while True:
        page = get(
            "/historical/closing-lines",
            sport="football",
            leagues=leagues,
            markets=markets,
            bookmakers=",".join(UK_BOOKS),
            limit=100,
            skip=skip,
            **{"from": start, "to": end},
        )
        events += page
        if len(page) < 100:
            return events
        skip += 100


def to_frame(events):
    rows = []
    for e in events:
        for book, markets in (e.get("bookmakers") or {}).items():
            for m in markets:
                for line in m["odds"]:
                    for side in ("home", "draw", "away", "over", "under"):
                        if side in line:
                            rows.append({
                                "event_id": e["id"],
                                "date": e["date"][:10],
                                "league": e["league"]["slug"],
                                "match": f'{e["home"]} v {e["away"]}',
                                "score": f'{e["scores"]["home"]}-{e["scores"]["away"]}',
                                "book": book,
                                "market": m["name"],
                                "line": line.get("hdp"),
                                "side": side,
                                "price": float(line[side]),
                            })
    return pd.DataFrame(rows)


if __name__ == "__main__":
    events = closing_lines(
        "england-premier-league,spain-laliga",
        "2026-08-01T00:00:00Z",
        "2026-11-05T00:00:00Z",
    )
    df = to_frame(events)
    print(len(events), "matches,", len(df), "closing prices")
    print(df.groupby("market")["book"].nunique().rename("books"), "\n")

    ml = df[df.market == "ML"].pivot_table(
        index=["event_id", "book"], columns="side", values="price"
    ).dropna()
    ml["margin"] = 1 / ml.home + 1 / ml.draw + 1 / ml.away - 1
    margins = ml.groupby("book")["margin"].agg(["count", "median"])
    print(margins.sort_values("median").round(3).to_string())

The output on 5 November:

200 matches, 65295 closing prices
market
ML        16
Spread    10
Totals    13
Name: books, dtype: int64 

                    count  median
book                             
Betfair Exchange      194   0.006
10BET                 192   0.048
Unibet UK             196   0.054
Bet365                197   0.055
BetVictor             184   0.056
Coral                 195   0.059
Skybet                100   0.065
Ladbrokes             178   0.065
Betway                197   0.066
Betfair Sportsbook    194   0.067
Paddy Power           194   0.074
William Hill          176   0.074
Virgin Bet             80   0.074
BetMGM UK             106   0.077
888Sport              194   0.079
BoyleSports           100   0.082

That is 200 matches, 90 from the Premier League and 110 from LaLiga, and 65,295 closing prices across the three markets. The margin column is the bookmaker's overround on the closing 1X2 price, closely related to the payout percentage odds comparison sites show. Betfair Exchange has the lowest figure in this sample, and the calculation leaves out exchange commission. The count column shows how many matches have a complete recorded closing 1X2 price for each bookmaker.

Here is one match from the raw response, trimmed to one bookmaker and one market:

{
  "id": 72221368,
  "home": "Newcastle United",
  "away": "Everton FC",
  "date": "2026-11-02T20:00:00Z",
  "status": "settled",
  "league": {
    "name": "England - Premier League",
    "slug": "england-premier-league"
  },
  "scores": {
    "home": 0,
    "away": 1
  },
  "bookmakers": {
    "Bet365": [
      {
        "name": "ML",
        "updatedAt": "2026-11-02T19:59:54.073Z",
        "odds": [
          {
            "home": "2.150",
            "draw": "3.400",
            "away": "3.400"
          }
        ]
      }
    ]
  }
}

Prices arrive as strings, so the script converts them with float(). Swap the league slugs for any of the football leagues the API lists at /v3/leagues?sport=football, for example england-championship or italy-serie-a. The historical data guide covers the per-event endpoints as well.

An Oddschecker-style best-price grid across UK bookmakers

For current prices, /odds/multi returns the odds for up to 10 events in one request, with up to 30 bookmakers. The script takes the next 10 Premier League fixtures, builds a grid with one column per bookmaker, then finds the best price for each outcome and every book that offers it.

import pandas as pd
from closing_lines import get

SPORTSBOOKS = [
    "Bet365", "William Hill", "Skybet", "Paddy Power", "Betfair Sportsbook",
    "Ladbrokes", "Coral", "BoyleSports", "BetVictor", "Unibet UK",
    "Virgin Bet", "888Sport", "10BET", "BetMGM UK",
]


def best_price_grid(league, books=SPORTSBOOKS):
    events = sorted(
        get("/events", sport="football", league=league, status="pending"),
        key=lambda e: e["date"],
    )[:10]
    if not events:
        return pd.DataFrame()
    ids = ",".join(str(e["id"]) for e in events)
    rows = []
    for e in get("/odds/multi", eventIds=ids, bookmakers=",".join(books)):
        for book, markets in e.get("bookmakers", {}).items():
            if book not in books:
                continue
            for m in markets:
                if m["name"] != "ML":
                    continue
                for side in ("home", "draw", "away"):
                    rows.append({
                        "match": f'{e["home"]} v {e["away"]}',
                        "side": side,
                        "book": book,
                        "price": float(m["odds"][0][side]),
                    })
    return pd.DataFrame(rows)


if __name__ == "__main__":
    df = best_price_grid("england-premier-league")
    if df.empty:
        raise SystemExit("No upcoming fixtures with prices yet")
    grid = df.pivot_table(index=["match", "side"], columns="book", values="price")
    grid = grid.reindex(["home", "draw", "away"], level="side")

    match, _ = grid.notna().sum(axis=1).idxmax()
    print(match)
    print(grid.loc[match].T.to_string(), "\n")

    best = grid.max(axis=1).rename("best")
    at = grid.apply(lambda r: ", ".join(r[r == r.max()].index), axis=1).rename("at")
    print(pd.concat([best, at], axis=1).head(9).to_string())

The output on 5 November, a few days before the weekend's fixtures:

Crystal Palace v Liverpool FC
side                home   draw  away
book                                 
10BET               5.00  4.200  1.58
888Sport            5.00  4.000  1.57
Bet365              5.25  4.333  1.60
BetMGM UK           4.80  4.200  1.57
BetVictor           5.00  4.200  1.57
Betfair Sportsbook  5.50  4.330  1.60
BoyleSports         4.75  4.000  1.60
Coral               4.80  4.330  1.61
Ladbrokes           4.80  4.200  1.61
Paddy Power         5.00  4.200  1.57
Skybet              5.25  4.200  1.57
Unibet UK           5.00  4.350  1.60
Virgin Bet          4.70  4.100  1.60
William Hill        5.00  4.000  1.57 

                                             best                          at
match                                 side                                   
Arsenal FC v Hull City                home   1.18                       Coral
                                      draw   8.50          Betfair Sportsbook
                                      away  19.00                       10BET
Brighton & Hove Albion v Brentford FC home   1.95          Betfair Sportsbook
                                      draw   3.90            Coral, Unibet UK
                                      away   3.80  Bet365, Betfair Sportsbook
Crystal Palace v Liverpool FC         home   5.50          Betfair Sportsbook
                                      draw   4.35                   Unibet UK
                                      away   1.61            Coral, Ladbrokes

The first table is the Oddschecker view of a single match: the script picks the fixture priced by the most books, here Crystal Palace v Liverpool with all 14 UK sportsbooks side by side. The second shows the best price per outcome and every book offering it, here for three fixtures in alphabetical order. A bookmaker with no price for a match in the response is missing from that match, and its cell in the grid stays empty. If there are no upcoming fixtures with prices, the script says so and stops.

Betfair Exchange is left out of the sportsbook list because exchange prices come with lay prices and liquidity, and comparing them with fixed odds needs a different layout. The Betfair API guide shows how to read back, lay and depth. For more on comparing books, see Odds Comparison API: 30 Bookmakers, One Request, and the fetching odds guide covers the odds endpoints in detail.

Line history for a Bet365 market

Closing lines tell you where a price ended. The odds movements endpoint tells you how it got there: the opening price, the recorded price changes and the latest price, for one market of one event at one bookmaker. Movement history is recorded for Bet365 and a set of other books. Requesting a book without history returns a 404.

import pandas as pd
from closing_lines import get


def movements(event_id, bookmaker="Bet365", market="ML", line=None):
    params = {"eventId": event_id, "bookmaker": bookmaker, "market": market}
    if line is not None:
        params["marketLine"] = line
    data = get("/odds/movements", **params)
    df = pd.DataFrame(data["movements"])
    df["time"] = pd.to_datetime(df.pop("timestamp"), unit="ms", utc=True)
    return df.set_index("time")


if __name__ == "__main__":
    ml = movements(72221368)
    print(len(ml), "price records")
    print(ml.iloc[[0, -1]].to_string(), "\n")
    print(ml.tail(6).to_string())
24 price records
                               home  draw  away
time                                           
2026-10-17 17:40:36.650+00:00  2.10   3.5   3.2
2026-11-02 19:34:50.628+00:00  2.15   3.4   3.4 

                               home  draw  away
time                                           
2026-11-02 17:44:05.717+00:00  2.15   3.5   3.2
2026-11-02 18:57:34.307+00:00  2.10   3.5   3.3
2026-11-02 19:02:34.608+00:00  2.10   3.4   3.4
2026-11-02 19:15:36.708+00:00  2.05   3.5   3.5
2026-11-02 19:32:49.095+00:00  2.10   3.4   3.4
2026-11-02 19:34:50.628+00:00  2.15   3.4   3.4

This is Newcastle United v Everton from 2 November. Bet365 opened on 17 October at 2.10, 3.50 and 3.20 and the response holds 24 price records: the opening price and 23 changes before kick-off. The last change, at 19:34 UTC, matches the Bet365 closing price in the closing-lines response above. Timestamps come back as Unix milliseconds, which the script converts to UTC times.

For a market with a line, pass it as marketLine, for example market=Totals with marketLine=2.5. In Totals history the over price is reported as home and the under price as away. The Bet365 API tutorial goes further into Bet365 markets and in-play prices.

Putting the pieces together

The three scripts share one request helper, so they combine easily. A common pattern is to store the closing lines in a database after each round, keep a best-price grid for the next fixtures, and pull movement history for the matches you want to look at closely. If you track closing lines against your own prices, the closing line value tutorial shows one way to do it in Python. For in-play prices, the odds feed over WebSocket pushes each change as it happens.

Frequently asked questions

Does Oddsportal have an API?

Oddsportal does not offer a public developer API. The closing-lines endpoint above returns the same kind of data, closing odds per bookmaker with final scores, as JSON.

Does Oddschecker have an API?

Oddschecker does not offer a self-serve public API with documentation and keys you can sign up for. For a best-price grid across UK bookmakers in code, /odds/multi returns current prices for up to 10 events per request.

How far back does the historical odds data go?

For the Premier League, LaLiga, Serie A and the Championship, closing odds from the UK books in this guide start in December 2025, which covers the second half of the 2025/26 season and all of 2026/27 so far. Earlier matches are listed with their results but without closing odds. One request can cover up to 366 days.

Which UK bookmakers are covered?

The examples use Bet365, William Hill, Sky Bet, Paddy Power, Betfair Exchange, Betfair Sportsbook, Ladbrokes, Coral, BoyleSports, BetVictor, Unibet UK, Virgin Bet, 888Sport, Betway, 10BET and BetMGM UK. Coverage differs by match and market, and the UK sportsbooks page lists the UK books the API covers.

Getting started

See the pricing page for plans and how many bookmakers each includes, and the football page for league coverage. The code in this guide runs as written once ODDS_API_KEY is set and your bookmaker selection includes the books in the lists.

Get live and pre-match odds from 450+ bookmakers

OddsNotifier and OddsHub run on the same real-time feed you get with the API.

See what's built with Odds API →