Bet365 Historical Odds: Dedupe 230,515 Snapshots to 29,569 Moves
Bet365 runs no public API, so every developer who wants its price history ends up at an aggregator. That part is settled. The part nobody writes about is what the history actually looks like once you pull it, and it does not look the way a backtest expects.
On one fixture, Manchester United v Manchester City on 13 September 2026, the /historical-odds endpoint returned 266 bet365 markets and 230,515 timestamped price snapshots in a single 23.4 MB response. Dedupe those snapshots on the price and 29,569 of them are real moves. Filter down to pre-match prices that were live and tradeable, and 147,362 snapshots collapse to 3,473. That is a 42x gap between how often the feed wrote a row and how often bet365 changed its mind.
Pinnacle, on the same fixture, collapses 1.7x. If you count rows instead of moves, you will rank bet365 as the busiest book on the board when it is one of the stillest. This guide shows how to pull Bet365 historical odds, how to dedupe them before you model anything, and how far back the window runs. Every number below came from a live call made while writing it. The historical odds documentation has the full parameter list.
Does Bet365 Have a Public API?
No. Bet365 publishes no developer documentation, no self-serve key and no public endpoint. Searching for “Bet365 API documentation” or “Bet365 API key” turns up nothing official, because nothing official exists for third-party developers.
Bet365 keeps its feeds inside three channels: affiliate partners on revenue-share agreements, negotiated enterprise licensing, and its own apps. None of the three has a sign-up page.
That leaves three routes to the data, and two of them are dead ends.
| Method | Standing | Reliability | Verdict |
|---|---|---|---|
| Web scraping | Violates Bet365 terms of service | Breaks on every markup change | Avoid |
| Official partnership | Legitimate | Negotiated, enterprise only | Closed to most developers |
| Odds aggregator API | Legitimate | Documented REST and WebSocket | The working route |
An aggregator collects bet365 prices alongside the rest of the market and hands them back under one schema. OddsPapi carries 300+ bookmaker slugs as of 16 September 2026, of which 285 carry live in-play odds and 117 are flagged as clones of another feed. Bet365 is one of them, and its history is free on the free plan. For the live side of the same feed, see real-time odds for in-play markets; for why direct access stays shut, see the book everyone wants and nobody can query, and for bet365 alongside the rest of the board, the sportsbook API guide.
Pull Live Bet365 Prices First
Start on the live endpoint. It confirms your key works and shows you the market structure you will meet again in the history.
import os, requests, time
from datetime import date, timedelta
API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE_URL = "https://api.oddspapi.io/v4"
def call(path, **params):
"""One call with a 429 retry. A 429 body is valid JSON, so check the status first."""
params["apiKey"] = API_KEY
for _ in range(5):
r = requests.get(f"{BASE_URL}/{path}", params=params, timeout=120)
if r.status_code == 429:
time.sleep(float(r.json()["error"].get("retryMs", 3000)) / 1000 + 1)
continue
return r
return r
today = date.today()
fixtures = call(
"fixtures",
sportId=10, # soccer
tournamentId=17, # Premier League
**{"from": today.isoformat(), "to": (today + timedelta(days=8)).isoformat()},
).json()
fixture = fixtures[0]
time.sleep(1.0)
odds = call("odds", fixtureId=fixture["fixtureId"], bookmakers="bet365").json()
book = odds["bookmakerOdds"]["bet365"]
market = book["markets"]["101"] # Full Time Result
print(f"{fixture['participant1Name']} v {fixture['participant2Name']}")
print(f"bet365 markets on this fixture: {len(book['markets'])}")
NAMES = {"101": "home", "102": "draw", "103": "away"}
implied = 0.0
for outcome_id, outcome in market["outcomes"].items():
price = outcome["players"]["0"]
if not price["active"]:
continue
print(f" {NAMES[outcome_id]:5} {price['price']:>6} changed {price['changedAt']}")
implied += 1 / price["price"]
print(f" overround {(implied - 1) * 100:.2f}%")
Run against Brentford v Chelsea on 16 September 2026, three days before kick-off:
Brentford FC v Chelsea FC
bet365 markets on this fixture: 225
draw 3.7 changed 2026-09-16T10:46:57.799Z
home 2.87 changed 2026-09-16T10:46:57.799Z
away 2.3 changed 2026-09-16T10:46:57.799Z
overround 5.35%
Two things in that output catch people out. The outcome dictionary came back in the order draw, home, away, so position tells you nothing and you must key on the outcome ID. And tournamentId is undocumented on /fixtures, works, cuts the payload roughly twentyfold, and lifts the ten-day range cap that sportId alone imposes.
Ten Bet365 Slugs, Three Different Prices
The catalogue holds ten bet365 feeds: bet365, bet365-nj, bet365.bet.ar, bet365.bet.br, bet365.de, bet365.es, bet365.fr, bet365.gr, bet365.it and bet365.nl. All ten priced 225 markets on Brentford v Chelsea. They did not all quote the same number.
| Slugs | Home / Draw / Away | Overround |
|---|---|---|
bet365, bet365-nj, bet365.de, bet365.it |
2.87 / 3.70 / 2.30 | 5.35% |
bet365.bet.ar, bet365.bet.br, bet365.es, bet365.gr, bet365.nl |
2.82 / 3.70 / 2.25 | 6.93% |
bet365.fr |
2.79 / 3.60 / 2.24 | 8.26% |
Ten feeds, three prices. The French skin charges 2.9 points more margin than the flagship on the same match. Only bet365-nj carries cloneOf: "bet365", so the .de and .it feeds quote byte-identical prices while the catalogue calls them independent. Dedupe on the price tuple before you count books or build a consensus, and never lean on cloneOf to do it for you. The method is in calculating fair odds from 300+ bookmakers.
Snapshots Are Not Line Moves
Now the history. The endpoint swaps two things on you: the top-level key becomes bookmakers instead of bookmakerOdds, and players["0"] becomes a list of timestamped snapshots instead of a single price object.
Test it on a fixture that has finished. A live /odds call outside the market-open window reports an empty board and reads as missing coverage when the real cause is the clock.
import os, requests
from datetime import datetime, timezone
API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE_URL = "https://api.oddspapi.io/v4"
FIXTURE = "id1000001772221266" # Manchester United v Manchester City, 13 Sep 2026
KICKOFF = datetime(2026, 9, 13, 15, 30, tzinfo=timezone.utc)
r = requests.get(
f"{BASE_URL}/historical-odds",
params={"apiKey": API_KEY, "fixtureId": FIXTURE, "bookmakers": "bet365"},
timeout=300,
)
r.raise_for_status()
market = r.json()["bookmakers"]["bet365"]["markets"]["101"]
def stamp(s):
return datetime.fromisoformat(s.replace("Z", "+00:00"))
NAMES = {"101": "home", "102": "draw", "103": "away"}
for outcome_id, outcome in market["outcomes"].items():
snapshots = outcome["players"]["0"]
pre_match = [s for s in snapshots
if stamp(s["createdAt"]) < KICKOFF and s["active"]]
# The feed writes a row whether or not the price moved. Collapse the repeats
# before you count a line move, or you measure the poller, not the book.
moves, previous = [], None
for s in pre_match:
if s["price"] != previous:
moves.append(s)
previous = s["price"]
print(
f"{NAMES[outcome_id]:5} {len(snapshots):>5} snapshots "
f"{len(pre_match):>4} pre-match {len(moves):>3} price moves "
f"open {moves[0]['price']} -> close {moves[-1]['price']}"
)
print(f" opened {moves[0]['createdAt']}")
home 666 snapshots 476 pre-match 9 price moves open 3.1 -> close 3.0
opened 2026-08-30T20:49:13.579Z
draw 629 snapshots 471 pre-match 5 price moves open 3.6 -> close 3.75
opened 2026-08-30T20:49:13.579Z
away 676 snapshots 507 pre-match 42 price moves open 2.05 -> close 2.2
opened 2026-08-30T20:49:13.579Z
Read the draw leg. bet365 wrote 471 pre-match rows on it and changed the price five times in fourteen days. The away leg carried the action: 42 moves, walking City from 2.05 out to 2.20.
Scale that up and the shape holds across the whole book. Here is the same collapse run over all 266 markets and all 1,058 price legs, next to Pinnacle and DraftKings on the identical fixture.
| Book | Markets | Snapshots | Price moves | Ratio | Pre-match active ratio |
|---|---|---|---|---|---|
bet365 |
266 | 230,515 | 29,569 | 7.8x | 42.4x |
pinnacle |
171 | 91,704 | 61,161 | 1.5x | 1.7x |
draftkings |
175 | 92,757 | 35,687 | 2.6x | 2.6x |
bet365 shipped the widest menu and the stillest prices. Pinnacle shipped fewer markets and changed almost every one of them on almost every row. Any model that treats snapshot volume as a proxy for line movement inverts that ranking. Collapse first, then count.
Opening and Closing Lines Across Three Books
/historical-odds accepts a maximum of three bookmakers per call, and one unreadable slug fails the whole batch. Pull one slug at a time and merge in your own code.
import os, requests, time
from datetime import datetime, timezone
API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE_URL = "https://api.oddspapi.io/v4"
FIXTURE = "id1000001772221266"
KICKOFF = datetime(2026, 9, 13, 15, 30, tzinfo=timezone.utc)
def stamp(s):
return datetime.fromisoformat(s.replace("Z", "+00:00"))
def opening_and_closing(slug):
r = requests.get(
f"{BASE_URL}/historical-odds",
params={"apiKey": API_KEY, "fixtureId": FIXTURE, "bookmakers": slug},
timeout=300,
)
if r.status_code != 200:
return None
market = r.json()["bookmakers"][slug]["markets"]["101"]
row = {}
for outcome_id, outcome in market["outcomes"].items():
# The close is the last ACTIVE snapshot BEFORE kick-off. Snapshots keep
# running past kick-off, so filter on both conditions.
pre = [s for s in outcome["players"]["0"]
if stamp(s["createdAt"]) < KICKOFF and s["active"]]
row[outcome_id] = (pre[0], pre[-1])
return row
for slug in ["bet365", "pinnacle", "draftkings"]:
row = opening_and_closing(slug)
opens = [row[o][0]["price"] for o in ("101", "102", "103")]
closes = [row[o][1]["price"] for o in ("101", "102", "103")]
open_margin = (sum(1 / p for p in opens) - 1) * 100
close_margin = (sum(1 / p for p in closes) - 1) * 100
print(f"{slug:11} opened {row['101'][0]['createdAt'][:16]} "
f"{open_margin:5.2f}% -> {close_margin:5.2f}% "
f"closing limit {row['101'][1]['limit']}")
time.sleep(4.6) # /historical-odds wants 4.6 s between calls
| Book | Opened | Opening 1X2 | Margin | Closing 1X2 | Margin | Closing limit |
|---|---|---|---|---|---|---|
bet365 |
30 Aug 20:49Z | 3.10 / 3.60 / 2.05 | 8.82% | 3.00 / 3.75 / 2.20 | 5.45% | null |
pinnacle |
30 Aug 20:05Z | 3.11 / 3.65 / 2.14 | 6.28% | 3.07 / 3.69 / 2.32 | 2.78% | 10,000 |
draftkings |
10 Sep 14:06Z | 3.05 / 3.80 / 2.15 | 5.61% | 3.00 / 3.75 / 2.25 | 4.44% | null |
bet365 and Pinnacle opened the derby 44 minutes apart, both on 30 August, fourteen days out. DraftKings waited until 10 September. bet365 shaved 3.4 points of margin between open and close and still finished 2.7 points wider than Pinnacle.
The limit column matters more than it looks. Pinnacle populates it and bet365 leaves it null, so null-check before any arithmetic. Pinnacle’s home leg opened with a limit of 250 and closed at 10,000: the same price carries a different meaning at each end of that range. A historical price without its limit tells you half the story. For the wider lag picture, 1xBet follows Pinnacle in 6 minutes and bet365 in 67.
How Far Back the Window Runs
Check the window before you scope a backtest. The endpoint holds a rolling window rather than a permanent archive, and the answer is a single probe.
import os, requests, time
from datetime import date, timedelta
API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE_URL = "https://api.oddspapi.io/v4"
def call(path, **params):
params["apiKey"] = API_KEY
for _ in range(5):
r = requests.get(f"{BASE_URL}/{path}", params=params, timeout=300)
if r.status_code == 429:
time.sleep(float(r.json()["error"].get("retryMs", 3000)) / 1000 + 1)
continue
return r
return r
def first_fixture(day):
r = call("fixtures", sportId=10, tournamentId=17,
**{"from": day.isoformat(), "to": (day + timedelta(days=3)).isoformat()})
return r.json()[0] if r.status_code == 200 and r.json() else None
probe = date(2026, 9, 13)
for _ in range(12):
fixture = first_fixture(probe)
time.sleep(1.0)
if fixture is None: # international break, no fixtures
probe -= timedelta(days=30)
continue
r = call("historical-odds", fixtureId=fixture["fixtureId"], bookmakers="bet365")
if r.status_code == 200:
markets = r.json()["bookmakers"]["bet365"]["markets"]
snaps = sum(len(s) for m in markets.values()
for o in m["outcomes"].values() for s in o["players"].values())
print(f"{fixture['startTime'][:10]} {len(markets):>3} markets {snaps:>7,} snapshots")
else:
print(f"{fixture['startTime'][:10]} {r.status_code} "
f"{r.json()['error']['code']} <- edge of the window")
break
time.sleep(4.6)
probe -= timedelta(days=30)
2026-09-13 259 markets 379,820 snapshots
2026-05-17 12 markets 125 snapshots
2026-04-18 10 markets 226 snapshots
2026-01-17 111 markets 6,050 snapshots
2025-10-18 404 NOT_FOUND <- edge of the window
Run on 16 September 2026, Premier League history reached back to a 17 January 2026 fixture, roughly eight months. The same walk returns 404 NOT_FOUND on fixtures from 3 January 2026 and earlier. That boundary belongs to the fixture, not to bet365: repeat the call with pinnacle or draftkings on the same fixture IDs and you get the identical 404, so read it as the edge of the collection window.
Depth inside the window varies by fixture as well. The derby held 266 bet365 markets and a May fixture held twelve. Probe the exact fixtures your model needs before you plan around a number. Once you have the window, backtesting a model on free historical odds and exporting to CSV or Excel pick up where this leaves off.
Two Bet365 Flags That Break Readers
Every bet365 slug carries liveOdds: false in the catalogue. All ten of them. Read that as a pre-match capability flag rather than a promise about in-play rows, because the in-play prices keep arriving regardless. A reader that skips a book on liveOdds drops bet365 from your in-play data entirely.
A suspended bet365 price reads "price": 1.0, "active": false. On the derby, each 1X2 leg carried 63 suspended pre-match snapshots, and the final row of the fixture was a 1.0 at 17:22Z. Feed a 1.0 into an implied-probability calculation and you get 100% on a single leg. Filter on active at price level before any maths.
The bookmakers filter answers with typed errors, which tells you which problem you have:
| Call | Response | Meaning |
|---|---|---|
bookmakers=bet365 |
200 with the slug present | Priced and readable |
bookmakers=bet365-uk |
400 INVALID_PARAMETER |
No such slug; the body lists the valid ones |
bookmakers=vbet |
403 RESTRICTED_ACCESS |
Real slug, gated on your plan |
| 200 with the slug absent | 200 | Readable, and this book has no price up |
Four slugs on /historical-odds |
400 TOO_MANY_BOOKMAKERS |
Three per call is the cap |
The Old Way and the Aggregator Way
| Task | Scraping bet365 yourself | OddsPapi |
|---|---|---|
| Getting a price | Headless browser, rotating proxies, ToS breach | One GET with a query-string key |
| Price history | Whatever you captured, starting today | Timestamped snapshots on the free plan |
| Other books | One scraper per book | 300+ slugs under one schema |
| Asian handicaps and player props | Reverse-engineer each layout | Native market structures with stable IDs |
| Real-time delivery | Poll and hope | WebSocket streaming |
| Cost to start | Your weekends | Free plan, 250 requests a month |
The four things worth the switch: 300+ bookmakers under one schema, free historical odds rather than a sales call, WebSocket streaming for push delivery, and native market structures so an Asian handicap grades as an Asian handicap. Pricing across the providers sits in the 2026 odds API pricing comparison, and the push side in the WebSocket odds API guide.
Frequently Asked Questions
Does Bet365 have a public API?
No. Bet365 publishes no developer documentation and no self-serve key. Its feeds go to affiliate partners, negotiated enterprise licences and its own apps. Developers read bet365 prices through an aggregator, which normalises them alongside 300+ other bookmakers under one schema.
Is Bet365 historical odds data free?
On OddsPapi, yes. The /historical-odds endpoint sits on the free plan, so you can pull timestamped bet365 price snapshots and backtest against them before you pay anything. Most competitors put historical data behind a paid tier or a sales call.
How far back does Bet365 historical odds data go?
The endpoint serves a rolling window. Measured on 16 September 2026, Premier League history reached back to a 17 January 2026 fixture, about eight months. The edge belongs to the fixture rather than to bet365, since Pinnacle and DraftKings return the same boundary on the same fixture IDs. Walk your target dates with a probe call before you scope a backtest.
Why do I get so many snapshots per fixture?
The feed writes a row on every observation whether or not the price moved. One bet365 fixture returned 230,515 snapshots that dedupe to 29,569 actual price changes, and 42 to 1 on pre-match active prices alone. Collapse consecutive identical prices before you count line movement, or you will measure the polling rate instead of the book.
Which bet365 slug should I query?
The catalogue carries ten bet365 feeds. On a Premier League fixture in September 2026 they quoted three distinct 1X2 prices: bet365, bet365-nj, bet365.de and bet365.it at 5.35% overround, five regional skins at 6.93%, and bet365.fr at 8.26%. Query bet365 for the flagship line, and dedupe on the price tuple if you pull several, because cloneOf flags only one of the three duplicate feeds.
Can I get live Bet365 odds as well as history?
Yes. The /odds endpoint returns current bet365 prices under the same fixture and market IDs, so code written against one endpoint mostly carries to the other. Two differences: the live response nests under bookmakerOdds and the historical one under bookmakers, and players["0"] holds a single price object live and a list of snapshots historically. WebSocket streaming covers push delivery.
How do I export Bet365 historical odds to CSV?
The API returns JSON, which preserves the nested market structure. Flatten the snapshot lists into rows and write them with pandas: pd.DataFrame(rows).to_csv("bet365.csv", index=False). Dedupe on the price before you export, or your CSV inherits the 42 to 1 snapshot inflation.
How many bookmakers can I pull per historical call?
Three. A fourth returns 400 TOO_MANY_BOOKMAKERS, and one unreadable slug fails the whole batch, so pull one slug per call and merge the results yourself. Sleep 4.6 seconds between historical calls to stay inside the rate limit.