Bet365 Historical Odds: Dedupe 230,515 Snapshots to 29,569 Moves

Historical Odds November 19, 2025

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.