How to Get Real-Time Bet365 Odds in Python
Fetch live and pre-match Bet365 odds in Python: a paced requests client, pagination, market picking, 429 handling and a reconnecting WebSocket for in-play odds.
To get Bet365 odds in Python, call the PulseScore API from a requests session: send your key in the X-Secret header to https://api.pulsescore.net/api/v3/bet365 and read live and pre-match markets as normalized JSON. This tutorial builds a small client step by step (pacing, paging, markets, 429 handling) and then a reconnecting WebSocket for in-play odds.
What you need
- Python 3.9 or later, plus
pip install requests websockets. - A PulseScore API key in the
PULSESCORE_API_KEYenvironment variable. - The free BASIC plan covers the REST steps with 500 requests a month; the WebSocket in step 7 needs PRO, MAX or ULTRA.
The Bet365 API page summarizes what the Bet365 feed covers, and the getting started guide shows the same endpoints with curl.
Step 1: Create a session
A requests.Session reuses one connection and sends your key and Accept-Encoding: gzip on every call. requests inflates the gzip reply for you, and boards are 6–14× smaller on the wire that way.
import os
import time
from itertools import islice
from urllib.parse import quote
import requests
BASE_URL = "https://api.pulsescore.net/api/v3/bet365"
session = requests.Session()
session.headers.update({
"X-Secret": os.environ["PULSESCORE_API_KEY"],
"Accept-Encoding": "gzip",
})Step 2: Stay inside the rate limit
The rate limit is counted per bookmaker and depends on your plan: BASIC allows 1 req/sec, STARTER 1 req/min, PRO 1 req/sec, MAX 3 req/sec and ULTRA 6 req/sec. Spacing requests slightly wider than the limit means you will rarely see a 429. When one does arrive, its message says what happened: a plain rate 429 means wait one window, a paused key (ten 429s within ten seconds) comes with a Retry-After header, and a monthly quota message means retrying won't help.
PLAN = "basic" # basic, starter, pro, max or ultra
# Requests allowed per window (seconds), counted per bookmaker
PLAN_RATES = {
"basic": (1, 1),
"starter": (1, 60),
"pro": (1, 1),
"max": (3, 1),
"ultra": (6, 1),
}
limit, window = PLAN_RATES[PLAN]
MIN_INTERVAL = window / limit * 1.05 # a little headroom under the limit
_last_request = 0.0
class QuotaExhausted(Exception):
pass
def _message(resp):
try:
return str(resp.json().get("message", ""))
except ValueError:
return resp.text
def get(path, **params):
"""GET one endpoint, paced to your plan's per-bookmaker rate."""
global _last_request
for _ in range(5):
wait = _last_request + MIN_INTERVAL - time.monotonic()
if wait > 0:
time.sleep(wait)
_last_request = time.monotonic()
resp = session.get(BASE_URL + path, params=params, timeout=15)
if resp.status_code != 429:
resp.raise_for_status()
return resp.json()
message = _message(resp)
if "Monthly quota" in message:
raise QuotaExhausted(message)
# A paused key gets Retry-After; a plain 429 waits one window.
time.sleep(float(resp.headers.get("Retry-After", window)))
raise RuntimeError(f"Still rate limited on {path}")Set PLAN to your plan. get() raises QuotaExhausted when the monthly quota is used up and requests.HTTPError for any other error status.
Step 3: Page through any list
List endpoints return total, page, limit, totalPages, hasNextPage and hasPrevPage plus the items. This generator asks for the maximum of 30 per page and stops when hasNextPage is false. Boards move while you page, so it skips ids it has already yielded.
def paginate(path, items_key="events", id_key="eventId", **params):
"""Yield every item of a paginated list once, 30 per request."""
seen = set()
page = 1
while True:
body = get(path, page=page, limit=30, **params)
items = body.get(items_key) or []
for item in items:
item_id = item.get(id_key)
if item_id in seen:
continue # the board moved while we paged
seen.add(item_id)
yield item
if not items or not body.get("hasNextPage"):
return
page += 1Step 4: Get live Bet365 odds
/live-events/sports lists the sports in play right now with an event count each, and /live-events returns the in-play events with the current score and full markets, filtered by sport:
for sport in get("/live-events/sports")["sports"]:
print(sport["name"], sport["eventCount"])
for event in paginate("/live-events", sport="soccer"):
score = event.get("score") or {}
print(f"{event['home']} {score.get('home', '-')}-{score.get('away', '-')} {event['away']}")Step 5: Get pre-match leagues and events
On Bet365, soccer answers at both /soccer/leagues and the root /leagues; every other sport has its own prefix, such as /tennis/leagues or /basketball/events. League names are Country||League values and must be URL-encoded in the path:
leagues = list(paginate("/soccer/leagues", items_key="leagues", id_key="name"))
print(len(leagues), "soccer leagues")
league = "United Kingdom||England Premier League"
events = get(f"/soccer/leagues/{quote(league, safe='')}/events")["events"]
for event in events:
print(event["startTime"], event["home"], "v", event["away"], event["eventId"])
# The 10 soonest pre-match events: one request, not the whole board
for event in islice(paginate("/soccer/events"), 10):
print(event["startTime"], event["home"], "v", event["away"])/soccer/leagues embeds each league's events, including ones already in play (live: true), while /soccer/leagues/:league/events returns the league's pre-match events in one response. Watch your quota on BASIC: a full pre-match soccer board runs to dozens of pages, which is why the last loop uses islice and stops after a single request.
Step 6: Read one event and pick a market
/soccer/events/:eventId returns one pre-match event with every market inside data, and /live-events/events/:eventId does the same for an in-play event. An unknown id answers {"data": null} rather than a 404, and the live route returns null once the event is over.
Match markets on canonicalMarket and period, and keep rawName, Bet365's own label, as a fallback:
def find_market(event, canonical, period="FULL_TIME", raw_name=None):
"""The market with this canonicalMarket and period, else by rawName."""
markets = event.get("markets") or []
for market in markets:
if market.get("canonicalMarket") == canonical and market.get("period") == period:
return market
for market in markets:
if raw_name and (market.get("rawName") or "").lower() == raw_name.lower():
return market
return None
event = get(f"/soccer/events/{events[0]['eventId']}")["data"]
if event is None:
print("Unknown event id")
else:
market = find_market(event, "MATCH_RESULT", raw_name="Full Time Result")
if market:
for sel in market["selections"]:
print(sel.get("canonicalOutcome") or sel["rawName"], sel["odds"])Markets with a handicap or a total carry it in line, and Bet365 publishes several totals per match. This prints every full-time over/under line with its prices:
for market in event["markets"]:
if market.get("canonicalMarket") != "OVER_UNDER" or market.get("period") != "FULL_TIME":
continue
line = next((s["line"] for s in market["selections"] if "line" in s), None)
prices = {s.get("canonicalOutcome") or s["rawName"]: s["odds"] for s in market["selections"]}
print(line, prices)Step 7: Stream in-play odds over WebSocket
With a PRO, MAX or ULTRA key you can replace the live polling loop with a socket. The server sends a connected message first, then about once a second a frame with every live event of the sport: sport, timestamp, count and data, where each event has the same schema as the REST responses.
import asyncio
import json
import os
import websockets
WS_URL = "wss://api.pulsescore.net/api/v3/bet365/ws/live"
# No point reconnecting: bad key, plan without WebSocket, unknown sport,
# expired subscription, or a newer connection of yours took the slot.
FATAL_CODES = {4001, 4003, 4004, 4010, 4012}
async def stream(sport):
url = f"{WS_URL}?key={os.environ['PULSESCORE_API_KEY']}&sport={sport}"
# A frame carries every live event of the sport: lift the 1 MB default.
async with websockets.connect(url, max_size=None) as ws:
async for raw in ws:
msg = json.loads(raw)
if msg.get("type") == "connected":
print(f"Connected: {msg['bookmaker']} {msg['sport']} ({msg['plan']})")
elif msg.get("type") == "error":
print("Error:", msg.get("message"))
else:
print(f"{msg['count']} live {msg['sport']} events")
for event in msg["data"][:3]:
print(" ", event["home"], "v", event["away"])
async def main(sport="soccer"):
loop = asyncio.get_running_loop()
delay = 1
while True:
started = loop.time()
try:
await stream(sport)
except websockets.ConnectionClosed as exc:
code = exc.rcvd.code if exc.rcvd else None
if code in FATAL_CODES:
print(f"Closed with {code}: {exc.rcvd.reason}")
return
except websockets.InvalidStatus as exc:
status = exc.response.status_code
if status not in (429, 502, 503):
print(f"Handshake refused: HTTP {status}") # 401 key, 402 plan
return
delay = max(delay, int(exc.response.headers.get("Retry-After", 0)))
except OSError as exc:
print("Network error:", exc)
if loop.time() - started > 60:
delay = 1 # the last connection was healthy
await asyncio.sleep(delay)
delay = min(delay * 2, 30)
asyncio.run(main())The loop reconnects with backoff after a network drop or a server restart, and honors Retry-After when the handshake is refused with 429. It stops on the close codes a retry cannot fix: 4001 (bad key), 4003 (plan without WebSocket access), 4004 (a sport this bookmaker doesn't stream), 4010 (subscription expired) and 4012 (a newer connection of yours took the slot). A handshake refused with 401, 402 or 403 is about the key, the plan or the account, so it stops there too. Each plan has a socket budget per bookmaker: 1 on PRO, 3 on MAX and 6 on ULTRA.
Which errors should you retry?
| Status | Meaning | Retry? |
|---|---|---|
| 401 | Missing, invalid or revoked key, or an inactive subscription | No: fix the key |
| 403 | Access denied | No |
| 404 | Unknown path; an unknown event id answers 200 with data: null | No |
| 429 | Per-bookmaker rate, paused key or monthly quota | Per step 2 |
| 5xx | Server or network trouble | Yes, with backoff |
The JSON body of every error carries a message that says what went wrong, so log it. The full list is in the error codes section of the docs.
Where to go next
The same client works for every bookmaker: change BASE_URL to https://api.pulsescore.net/api/{bookmaker}, and the paths and the normalized schema stay the same. That makes it a short step to comparing Bet365 with other bookmakers to find arbitrage. To turn prices into probabilities and margins, read implied probability, overround and no-vig odds.
Frequently asked questions
Do I need a Bet365 account to get its odds?
No. The odds come from the PulseScore API, so all you need is a PulseScore API key, sent in the X-Secret header. No Bet365 account or credentials are involved.
How often are Bet365 live odds updated?
The live WebSocket pushes a frame with every in-play event of the subscribed sport about once a second. Over REST, each request to /live-events returns the current board.
Which Bet365 sports can I get?
Pre-match boards cover soccer, tennis, basketball, ice hockey, American football, baseball, cricket, rugby league, rugby union, volleyball, handball and esports, and horse racing and greyhounds come as race cards. The Bet365 Swagger page lists the current set.
How many requests does the free plan allow?
BASIC is free with 500 requests a month at 1 req/sec per bookmaker. A full Bet365 pre-match soccer board takes dozens of paginated requests, so fetch only the pages you need.
Can I use the same Python code for other bookmakers?
Yes. Change BASE_URL to https://api.pulsescore.net/api/{bookmaker}: the paths and the normalized schema are the same everywhere. Only Bet365 lives under /api/v3, and on other bookmakers soccer always has its own /soccer prefix.
Keep reading
Getting Started with the PulseScore API
Make your first PulseScore API call: get a free key, fetch live and pre-match odds as normalized JSON, page through boards, use the WebSocket and handle 429s.
Build an Arbitrage Betting Bot Across Bookmakers
Find sure bets across bookmakers in Python: match the same fixture on every book, compare 1X2 and over/under prices, size the stakes, respect rate limits.