bet365 odds in Python
Three short scripts: live match prices, every market for one match, and a live stream of changes.
Everything here uses the free MoviOdds API. The odds are exclusively bet365's, there's no sign-up and no key, and each script below was run against the live API before it went on this page.
Setup
pip install requests websockets
Python 3.9 or newer. The base URL for everything is https://moviodds.com.
1. Live match prices
Ask /odds for in-play matches and just the Full Time Result market. That keeps the response to a few kilobytes instead of the whole board.
import requests
# Full Time Result for every match in play (group 40 pre-match, 1777 in play).
r = requests.get(
"https://moviodds.com/odds",
params={"in_play": "1", "groups": "40,1777"},
timeout=30,
)
r.raise_for_status()
for m in r.json()["matches"]:
prices = {q["label"]: q["odds_dec"] for q in m["quotes"] if not q.get("suspended")}
if prices:
fmt = lambda x: f"{x:.2f}" if x else "-"
print(f'{m["league"]}: {m["home"]} {fmt(prices.get(m["home"]))} | '
f'Draw {fmt(prices.get("Draw"))} | {m["away"]} {fmt(prices.get(m["away"]))}')
Output looks like:
UEFA Nations League D: Azerbaijan 1.57 | Draw 2.60 | Liechtenstein 15.00 Ghana Premier League: Asante Kotoko 1.12 | Draw 6.50 | Karela United FC 17.00
Full Time Result is market 40 before kickoff and 1777 once a match is in play, so ask for both. Every market's id is stable across matches; see the market list.
2. Every market for a match
Find a match in /catalog, then get all of its prices from /snapshot. Big matches often have 40 or more markets.
from collections import defaultdict
from datetime import datetime, timezone
import requests
BASE = "https://moviodds.com"
# Pick the next match that hasn't kicked off yet.
catalog = requests.get(f"{BASE}/catalog", timeout=30).json()["fixtures"]
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
upcoming = sorted((f for f in catalog if not f["in_play"] and f["kickoff_utc"] > now), key=lambda f: f["kickoff_utc"])
match = upcoming[0]
# Every market bet365 prices for it.
snap = requests.get(f"{BASE}/snapshot", params={"fi": match["id"]}, timeout=30).json()
markets = defaultdict(list)
for q in snap["quotes"] or []:
markets[q["group_name"]].append(q)
print(f'{match["home"]} v {match["away"]} ({match["league"]}): {len(markets)} markets')
for name, quotes in sorted(markets.items()):
first = quotes[0]
pick = " ".join(dict.fromkeys(x for x in (first.get("label"), first.get("side"), first.get("line")) if x))
print(f" {name}: {len(quotes)} prices, e.g. {pick} @ {first['odds_dec']:.2f}")
Output looks like:
UD Santa Marta v Salamanca CF UDS (Spain Copa Federacion): 11 markets Correct Score: 24 prices, e.g. 1-0 UD Santa Marta @ 11.00 Double Chance: 3 prices, e.g. Draw or Salamanca CF UDS @ 1.22 Goals Over/Under: 2 prices, e.g. Over 2.5 @ 2.15
3. Stream price changes
Load the board once with /odds, then keep it current over one WebSocket. You get a snapshot per match, then a delta each time prices move. In-play prices arrive the moment bet365 changes them.
import asyncio
import json
import requests
import websockets # pip install websockets
BASE = "https://moviodds.com"
board = {} # match id -> {"match": {...}, "quotes": {(group_id, market_id, sel_id): quote}}
def key(q):
return (q["group_id"], q.get("market_id", ""), q["sel_id"])
def load_board():
data = requests.get(f"{BASE}/odds", timeout=60).json()
for m in data["matches"]:
board[m["id"]] = {"match": m, "quotes": {key(q): q for q in m["quotes"]}}
def apply(msg):
entry = board.setdefault(msg["fixture"], {"match": None, "quotes": {}})
if msg["type"] == "snapshot":
entry["quotes"] = {} # a snapshot replaces the match's odds
for q in msg["quotes"]:
entry["quotes"][key(q)] = q # a delta only carries what changed
return entry
async def stream():
delay = 1
while True:
try:
load_board()
async with websockets.connect(BASE.replace("https", "wss") + "/ws", max_size=None) as ws:
await ws.send(json.dumps({"op": "subscribe"}))
delay = 1
async for raw in ws:
msg = json.loads(raw)
if msg.get("type") == "lagged":
break # we fell behind: reload the board and reconnect
if msg.get("type") not in ("snapshot", "delta"):
continue
entry = apply(msg)
m = entry["match"]
if msg["type"] == "delta" and m:
for q in msg["quotes"]:
pick = " ".join(dict.fromkeys(x for x in (q.get("label"), q.get("side"), q.get("line")) if x))
price = "suspended" if q.get("suspended") else f'{q["odds_dec"]:.2f}'
print(f'{m["home"]} v {m["away"]} | {q.get("group_name")} | {pick} -> {price}')
except (websockets.ConnectionClosed, OSError, requests.RequestException) as e:
print("connection lost, retrying:", e)
await asyncio.sleep(delay)
delay = min(delay * 2, 30)
asyncio.run(stream())
Output looks like:
Hungary U21 v Lithuania U21 | 1st Half Goal Line (0-1) | Under 1.5 -> 2.05 Liverpool (llulle) v Real Madrid (Cira) | Asian Handicap (0-0) | Liverpool (llulle) -1 -> 1.80
To follow only some markets or matches, subscribe with lists: {"op": "subscribe", "groups": ["40", "1777"]} or {"op": "subscribe", "fixtures": ["<match id>"]}.
Tips
- Identify a price by
(group_id, market_id, sel_id)within a match. A delta replaces that price; a snapshot replaces the match's whole list. - Treat
suspended: trueas unavailable. Suspended prices can showodds_decas 0. /oddsallows 30 requests a minute per IP. For anything live, use the WebSocket instead of polling.- If the socket drops or you get a
laggedmessage, reload/oddsand reconnect, as the stream script does. Missed updates aren't replayed. - Right after a server restart
/oddsmay return"warming": truewith few matches. Wait about 30 seconds and try again.
Next
- API docs: the full message format.
- Every market in the feed.
- Bet Builder API: bet365's real combined price for several legs on one match.
- OpenAPI spec for generating a client.
Questions? Message @Movisu on Discord.