TutorialPythonBet365

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.

By the PulseScore teamPublished Updated 7 min read

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_KEY environment 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.

python
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.

python
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.

python
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 += 1

Step 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:

python
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:

python
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:

python
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:

python
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.

python
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?

StatusMeaningRetry?
401Missing, invalid or revoked key, or an inactive subscriptionNo: fix the key
403Access deniedNo
404Unknown path; an unknown event id answers 200 with data: nullNo
429Per-bookmaker rate, paused key or monthly quotaPer step 2
5xxServer or network troubleYes, 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.