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 want | Site you know it from | Endpoint |
|---|---|---|
| Closing odds per bookmaker for past matches | Oddsportal results pages | /historical/closing-lines |
| Best current price per outcome across UK books | Oddschecker odds grid | /odds/multi |
| How one bookmaker's price moved before kick-off | Odds 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.082That 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, LadbrokesThe 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.4This 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.





