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.
An arbitrage bet, or "sure bet", exists when the best prices for every outcome of one market, taken from different bookmakers, add up to less than 100% implied probability. This guide builds a Python scanner that finds them across bookmakers with one API key and one JSON schema, then works out the stakes.
The bot pulls the upcoming soccer boards of three bookmakers in parallel, matches the same fixture on every book, checks full-time 1X2 and over/under prices and prints the stake split for every arb it finds. At the end, the same matching logic runs on live odds over the Stream API.
How does sports arbitrage work?
Every decimal price implies a probability of 1 / odds. One bookmaker's prices for a market normally add up to more than 100%, and the excess is its margin. Take the best price for each outcome from different books, though, and the total can drop below 100%. That gap is a return you lock in before the match starts.
| Outcome | Best price | Bookmaker | Implied |
|---|---|---|---|
| Home | 2.10 | Bet365 | 47.62% |
| Draw | 3.90 | Betfair Sportsbook | 25.64% |
| Away | 3.95 | PS3838 | 25.32% |
The three legs imply 98.58%, so backing all of them returns 1 / 0.9858 = 1.0144 times the total stake: +1.44% whatever the result. Each leg gets bankroll / (odds × 0.9858), which makes every outcome pay the same: stakes of 48.31, 26.01 and 25.68 out of 100 each return about 101.44.
The formula is the easy part. The hard part is the data: the same market, from several books, in the same shape, for the same match, at the same moment. For the maths behind margins and implied probability, read odds math for developers.
What do you need?
- A PulseScore API key. The free BASIC plan includes 500 requests a month at 1 req/sec per bookmaker, enough to build and test this; a scanner that runs all day needs PRO or above, which have no monthly quota (compare the plans).
- Python 3.8+ with
requests, pluswebsocketsfor the live part. - A few bookmakers. Every one of the 60+ bookmakers serves the same paths and schema under its own prefix:
/api/ps3838/soccer/events,/api/betfair-sb/soccer/eventsand, for bet365,/api/v3/bet365/soccer/events. The API docs list every endpoint.
Step 1: Fetch each bookmaker's board politely
/soccer/events returns upcoming pre-match events, soonest first, in a paginated envelope (page, limit up to 30, hasNextPage), each event with its full markets array. Five pages of 30 cover the next 150 fixtures of a book.
The rate limit is counted per bookmaker, not per key: 1 req/sec per bookmaker on PRO, 3 req/sec on MAX, 6 req/sec on ULTRA and 1 req/min on STARTER. Three books can therefore be fetched side by side, each at its own pace. Set PACE to match your plan.
import re
import time
import unicodedata
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta
from difflib import SequenceMatcher
import requests
API_KEY = "YOUR_API_KEY"
API = "https://api.pulsescore.net/api"
# Same paths and schema on every bookmaker; only the prefix changes
# (bet365 is the versioned /v3/bet365).
BOOKS = {
"bet365": f"{API}/v3/bet365",
"betfair-sb": f"{API}/betfair-sb",
"ps3838": f"{API}/ps3838",
}
PAGES = 5 # 5 pages x 30 = the next 150 fixtures per bookmaker
PACE = 1.0 # seconds between two requests to ONE bookmaker
session = requests.Session()
session.headers.update({"X-Secret": API_KEY, "Accept-Encoding": "gzip"})
def get(url, params=None):
"""GET that backs off on 429 instead of retrying straight away."""
for attempt in range(4):
r = session.get(url, params=params, timeout=20)
if r.status_code != 429:
r.raise_for_status()
return r.json()
if "quota" in r.text.lower():
r.raise_for_status() # monthly quota used up: retrying won't help
time.sleep(float(r.headers.get("Retry-After", 2 ** attempt)))
r.raise_for_status()
def fetch_board(book, base):
"""Upcoming soccer events of one bookmaker, soonest first."""
events = []
for page in range(1, PAGES + 1):
data = get(f"{base}/soccer/events", {"page": page, "limit": 30})
events.extend(e for e in data["events"] if not e.get("live"))
if not data["hasNextPage"]:
break
time.sleep(PACE)
return book, eventsBack off on the first 429 instead of retrying at once. A key that keeps going is paused: ten rejections within ten seconds on one bookmaker block it for 60 seconds, and every reply then carries a Retry-After header, which the helper honors. On BASIC and STARTER a 429 can also mean the monthly quota is used up; the message says so, and no retry fixes it until the month rolls over.
Step 2: How do you match the same fixture across bookmakers?
This is where most arbitrage scanners fail. Event IDs are per bookmaker, and so are team names: one book prints "Man City", another "Manchester City", and accents, "FC" prefixes and translated names all differ (an Italian board such as Sisal's writes "Bayern Monaco"). The matcher below normalizes names, applies an alias table, accepts close fuzzy matches and only pairs events whose kickoff times are within 15 minutes of each other.
ALIASES = { # grow this from the near-misses you log
"man city": "manchester city",
"man utd": "manchester united",
"spurs": "tottenham hotspur",
}
NOISE = {"fc", "afc", "cf", "sc", "ac", "fk", "the"}
def norm(name):
"""'1. FC Köln' -> '1 koln', 'Man City' -> 'manchester city'."""
plain = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode()
words = [w for w in re.findall(r"[a-z0-9]+", plain.lower()) if w not in NOISE]
key = " ".join(words)
return ALIASES.get(key, key)
def same_team(a, b):
a, b = norm(a), norm(b)
return a == b or SequenceMatcher(None, a, b).ratio() >= 0.85
def kickoff(event):
ts = event.get("startTime")
return datetime.fromisoformat(ts.replace("Z", "+00:00")) if ts else None
def same_time(a, b, tolerance=timedelta(minutes=15)):
ka, kb = kickoff(a), kickoff(b)
return ka is None or kb is None or abs(ka - kb) <= tolerance
def match_fixtures(boards):
"""[{book: event}, ...] for every fixture listed by at least two books."""
anchor, *others = list(boards)
groups = []
for event in boards[anchor]:
group = {anchor: event}
for book in others:
for other in boards[book]:
if (same_team(event["home"], other["home"])
and same_team(event["away"], other["away"])
and same_time(event, other)):
group[book] = other
break
if len(group) > 1:
groups.append(group)
return groups- Anchor on your widest book: a fixture that the first book does not list is never matched.
- Log the near misses (same kickoff, only one team matched) and grow
ALIASESfrom them. That table is what makes a scanner reliable over time. - Some books also carry a Betradar or Sportradar match ID on the event. The field name differs by book, so check each bookmaker's Swagger page; when two books share an ID, join on it and skip the name matching.
Step 3: Find the arbitrage and size the stakes
Because every book shares the same canonical layer, comparing prices is a dictionary lookup: canonicalMarket and period pick the market, canonicalOutcome picks the side, and line keeps over/under 2.5 apart from 3.5. Inactive markets and selections are skipped, since a suspended price cannot be bet.
def prices(event, market, period="FULL_TIME"):
"""{(line, outcome): odds} for the active selections of one market type."""
out = {}
for m in event.get("markets", []):
if m["canonicalMarket"] != market or m.get("period") != period or not m.get("isActive"):
continue
for s in m["selections"]:
odds = s.get("odds")
if s.get("isActive") and isinstance(odds, (int, float)) and odds > 1:
out[(s.get("line", m.get("line")), s["canonicalOutcome"])] = odds
return out
def find_arbs(group, market, outcomes):
"""Yield (line, {outcome: (odds, book)}, book_sum) whenever the best
price per outcome, across books, adds up to less than 100%."""
best = {}
for book, event in group.items():
for key, odds in prices(event, market).items():
if odds > best.get(key, (0.0, None))[0]:
best[key] = (odds, book)
for line in {line for line, _ in best}:
legs = {o: best.get((line, o)) for o in outcomes}
if None in legs.values():
continue
book_sum = sum(1 / odds for odds, _ in legs.values())
if book_sum < 1 and len({book for _, book in legs.values()}) > 1:
yield line, legs, book_sum
def stakes(legs, book_sum, bankroll=100.0):
"""Stake per outcome so that every outcome returns bankroll / book_sum."""
return {o: round(bankroll / (odds * book_sum), 2) for o, (odds, _) in legs.items()}Two details matter. The line of a total can sit on the selection, on the market or on both, depending on the book, so the code reads the selection first. And legs that all come from one book are ignored: a single bookmaker's own prices showing an arb is almost always stale or mis-mapped data.
Step 4: Run the scan
MARKETS = [
("MATCH_RESULT", ("HOME", "DRAW", "AWAY")), # 1X2: three outcomes
("OVER_UNDER", ("OVER", "UNDER")), # two outcomes, same line
]
def report(groups, tag=""):
for group in groups:
fixture = next(iter(group.values()))
for market, outcomes in MARKETS:
for line, legs, book_sum in find_arbs(group, market, outcomes):
edge = (1 / book_sum - 1) * 100
name = market if line is None else f"{market} {line}"
note = " <- check rawName on every leg" if edge > 5 else ""
print(f"{tag}{fixture['home']} v {fixture['away']} | {name} | +{edge:.2f}%{note}")
split = stakes(legs, book_sum)
for outcome, (odds, book) in legs.items():
print(f" {outcome:<5} {odds:>6} @ {book:<10} stake {split[outcome]}")
def scan():
# The rate limit is counted per bookmaker, so every book gets its own
# worker and its own pace, and the books are fetched side by side.
with ThreadPoolExecutor(max_workers=len(BOOKS)) as pool:
boards = dict(pool.map(lambda item: fetch_board(*item), BOOKS.items()))
report(match_fixtures(boards))
while True:
scan()
time.sleep(60)A run prints something like this:
Man City v Arsenal | MATCH_RESULT | +1.44%
HOME 2.1 @ bet365 stake 48.31
DRAW 3.9 @ betfair-sb stake 26.01
AWAY 3.95 @ ps3838 stake 25.68
Man City v Arsenal | OVER_UNDER 2.5 | +1.11%
OVER 1.95 @ betfair-sb stake 51.85
UNDER 2.1 @ bet365 stake 48.15Genuine arbs are usually small. The scanner flags anything above 5%: treat it as a mapping or stale-price problem until you have compared rawName on every leg, which is the fallback the docs recommend whenever a canonical value looks wrong.
One scan costs up to 15 requests (five pages from three books). That is why the free quota suits testing, while a scanner that runs every minute belongs on PRO or above.
Can the bot catch live arbitrage?
Live prices change every second, so polling /live-events page by page is too slow and too expensive. PRO, MAX and ULTRA include two push feeds instead:
- The live WebSocket, one socket per bookmaker:
wss://api.pulsescore.net/api/{bookmaker}/ws/live?key=YOUR_API_KEY&sport=soccerpushes every live event of the sport about once a second. - The Stream API, one socket for many bookmakers: each stream sends its board once, then only what changed, once a second. PRO's allowance (Stream API: 1 connection, 1 sport, all bookmakers) covers exactly this use case: one sport, every book you want.
The reference client apply.py, linked from the Stream API docs, keeps one board per stream and reconnects with since, so the live scanner only has to re-run the matcher on every delta:
import asyncio
from apply import StreamConnection # reference client, see the Stream API docs
def on_frame(stream, kind, client, raw):
if kind == "delta":
# Stream ids are "<bookmaker>/<kind>/<sport>": one live board per book.
boards = {sid.split("/")[0]: b.to_list() for sid, b in client.boards.items()}
report(match_fixtures(boards), tag="LIVE ")
conn = StreamConnection(
"wss://api.pulsescore.net/api/stream/ws",
key=API_KEY,
streams=[{"bookmaker": book, "kind": "live", "sport": "soccer"} for book in BOOKS],
on_frame=on_frame,
)
asyncio.run(conn.run())Keep three things in mind. Deltas are batched once a second, so a change typically reaches you 1–2 seconds after the bookmaker writes it, and live arbs often close before every leg is placed. Sockets are budgeted per bookmaker across both feeds (1 per bookmaker on PRO, 3 on MAX, 6 on ULTRA), so a stream that declares a book uses that book's socket. And the callback fires on every delta, so de-duplicate alerts by fixture, market and line before sending them anywhere. The trade-offs between polling, the WebSocket and delta streams are covered in REST vs WebSocket vs delta streams.
What can go wrong?
- Prices move between your scan and your bet: re-read each event from
/soccer/events/{eventId}on its book right before placing. - Markets get suspended. The scanner drops anything with
isActive: false, but one leg can still suspend while you place the others. - Mis-mapped markets look exactly like arbs. Compare
rawNameon every leg and report a wrong mapping so it gets fixed. - Rules differ. Books settle void legs, retirements and extra time differently, and most reserve the right to void bets struck at an obvious pricing error ("palpable error"), so a rules mismatch can turn a sure bet into a loss.
- Accounts get limited: bookmakers may restrict accounts that consistently bet arbs.
- The law comes first: check that betting, and arbitrage betting, is legal where you live and allowed by each bookmaker's terms.
The same fixture matching powers a friendlier product too: an odds comparison tool that shows every book's price side by side.
Frequently asked questions
What is an arbitrage bet?
A set of bets on every outcome of one market, placed with different bookmakers, whose best prices imply less than 100% in total. Staked in proportion to 1 / odds, every outcome returns more than the combined stake.
How do you match the same match across bookmakers?
Event IDs are per bookmaker, so match on normalized team names (accents, "FC" and similar noise removed, plus an alias table), accept close fuzzy matches and require kickoff times within about 15 minutes. Where two books share a Betradar or Sportradar match ID, join on that instead.
Can I poll several bookmakers at the same time?
Yes. The rate limit is counted per bookmaker (1 req/sec per bookmaker on PRO, 3 req/sec on MAX, 6 req/sec on ULTRA), so each book has its own budget and the books can be fetched in parallel.
Is the free plan enough for an arbitrage bot?
It is enough to build and test one: BASIC includes 500 requests a month. A scanner that runs continuously needs PRO or above, which have no monthly quota and add the live WebSocket and the Stream API.
Does PulseScore place bets?
No. PulseScore is a read-only odds data API. Bets are placed with the bookmakers themselves, under their terms and the laws where you live.
Keep reading
How to Build an Odds Comparison Tool with One API
Build an odds comparison tool in Node.js: pull one league from several bookmakers with one API key, match fixtures, show the best price and each book's margin.
Implied Probability, Overround and No-Vig Odds Explained
Convert odds to implied probability, measure a bookmaker's overround and margin, remove the vig to get fair odds, and run the math in Python on live API data.