RapidOddsAPIRapidOddsAPI
Home/Blog/Guide

Build an arbitrage betting scanner in Python

A working arbitrage scanner in under 70 lines of Python. Pull head to head odds from many bookmakers, find prices that beat the margin, and split the stake.

Guide ยท Updated June 2026

An arbitrage bet backs every outcome of a game at different bookmakers so you profit whichever way it lands. It exists when the implied probabilities of the two sides add up to less than 100 percent. This guide builds a scanner in Python: pull head to head odds from many books over the RapidOddsAPI REST endpoint, group each game across books, check every pair of prices for an arb, then split your stake so the return is the same on both sides.

Bookmakers price the same game slightly differently. Now and then they disagree enough that you can back one side at one book and the other side at another and lock in a profit before the result is even known. The maths is short. The real work is watching enough bookmakers to catch the gap while it is open, which is what the odds API does for you. We will build the whole thing step by step, then put it together into one script you can run.

The idea in one line

Each price has an implied probability, which is just 1 / price. Add the implied probability of the two sides together. If they come to less than 100 percent, the two books have left a gap, and that gap is your profit.

1 / price_a + 1 / price_b < 1.0 -> arbitrage

For example, 2.10 on one team at one book and 2.10 on the other team at another book gives 0.476 + 0.476 = 0.952, or about 95 percent. That is under 100 percent, so backing both sides returns about 5 percent no matter who wins.

Step 1: pull the odds

Ask the REST endpoint for head to head odds from the bookmakers you want to compare. One thing to know about Australian books: many brands share a single odds feed, so you pass the feed name (for example Sportsbet, TAB, Ladbrokes), not the individual clone brands, since books on the same feed always show the same price.

import requests API_KEY = "your_api_key" SPORT = "MLB" BOOKMAKERS = [ "Bet365", "Sportsbet", "TAB", "Pointsbet", "Ladbrokes", "Unibet", "Dabble", ] STAKE = 100 # total stake to split across the two sides resp = requests.get( f"https://api.rapidoddsapi.com/sports/{SPORT}/markets", params={ "api_key": API_KEY, "market_type": ["head_to_head"], "bookmaker": BOOKMAKERS, }, timeout=30, ) resp.raise_for_status() games = resp.json()["games"]

Step 2: group each game across books

The response returns one entry per game and bookmaker pair, so the same game appears once for every book you asked for. Before you can compare prices you need all of a game's books in one place, so merge the entries that belong to the same game.

Here we match games by team name, which is enough when each matchup happens once. If the same teams can play twice in a day, or different books list slightly different start times, you also need to match on the start time. That is its own topic, covered in the game matching guide.

def group_by_matchup(games): merged = {} for g in games: key = (g["game"]["home_team"], g["game"]["away_team"]) if key not in merged: merged[key] = {"game": g["game"], "bookmakers": []} merged[key]["bookmakers"].extend(g["bookmakers"]) return list(merged.values())

Step 3: collect every price for each team

For a single game, gather every head to head price on offer for each team, and remember which book is offering it. This gives you two lists, one per team, ready to compare.

def prices_per_team(game): prices = {} for book in game["bookmakers"]: for market in book["markets"]: if market["key"] != "head_to_head": continue for outcome in market["outcomes"]: team, price = outcome["name"], outcome["price"] prices.setdefault(team, []).append((price, book["name"])) return prices

Step 4: check every pair of books

Now try each price on one team against each price on the other. Skip pairs from the same book, since an arb needs two different bookmakers. Keep the lowest combined margin you find, because that is the most profitable arb for the game.

It is tempting to just take the single best price on each side, but that can miss an arb where one book happens to top both teams. Checking every pair is a few more lines and never misses one.

def best_arb(prices): (team_a, prices_a), (team_b, prices_b) = prices.items() best = None for price_a, book_a in prices_a: for price_b, book_b in prices_b: if book_a == book_b: continue margin = 1 / price_a + 1 / price_b if margin < 1 and (best is None or margin < best["margin"]): best = { "margin": margin, "legs": [(team_a, price_a, book_a), (team_b, price_b, book_b)], } return best

Step 5: split the stake

When an arb exists you want the same payout no matter who wins, so you stake more on the shorter price. Each side gets a share of the total stake equal to its implied probability divided by the combined margin.

stake = STAKE * (1 / price) / margin

The profit percentage is (1 / margin - 1) * 100. A margin of 0.952 is a return of about 5 percent on the total stake.

The full scanner

Put the pieces together and you have a complete scanner. It pulls the odds, groups each game, checks every pair of books, and prints any arb it finds with the stake to place on each side.

import requests API_KEY = "your_api_key" SPORT = "MLB" BOOKMAKERS = [ "Bet365", "Sportsbet", "TAB", "Pointsbet", "Ladbrokes", "Unibet", "Dabble", ] STAKE = 100 # total stake to split across the two sides def group_by_matchup(games): merged = {} for g in games: key = (g["game"]["home_team"], g["game"]["away_team"]) if key not in merged: merged[key] = {"game": g["game"], "bookmakers": []} merged[key]["bookmakers"].extend(g["bookmakers"]) return list(merged.values()) def prices_per_team(game): prices = {} for book in game["bookmakers"]: for market in book["markets"]: if market["key"] != "head_to_head": continue for outcome in market["outcomes"]: team, price = outcome["name"], outcome["price"] prices.setdefault(team, []).append((price, book["name"])) return prices def best_arb(prices): (team_a, prices_a), (team_b, prices_b) = prices.items() best = None for price_a, book_a in prices_a: for price_b, book_b in prices_b: if book_a == book_b: continue margin = 1 / price_a + 1 / price_b if margin < 1 and (best is None or margin < best["margin"]): best = { "margin": margin, "legs": [(team_a, price_a, book_a), (team_b, price_b, book_b)], } return best def main(): resp = requests.get( f"https://api.rapidoddsapi.com/sports/{SPORT}/markets", params={ "api_key": API_KEY, "market_type": ["head_to_head"], "bookmaker": BOOKMAKERS, }, timeout=30, ) resp.raise_for_status() games = group_by_matchup(resp.json()["games"]) print(f"Scanning {len(games)} {SPORT} games...\n") for game in games: prices = prices_per_team(game) if len(prices) != 2: # 2-way arb needs exactly two outcomes continue arb = best_arb(prices) if arb is None: continue profit = (1 / arb["margin"] - 1) * 100 g = game["game"] print(f"ARB: {g['away_team']} at {g['home_team']} (+{profit:.2f}%)") for team, price, book in arb["legs"]: stake = STAKE * (1 / price) / arb["margin"] print(f" {team}: {price} @ {book} -> stake ${stake:.2f}") print() if __name__ == "__main__": main()

What you get back

When the scanner finds an arb it prints the game, the profit, and the exact stake for each side. A real arb looks like this:

Scanning 13 MLB games... ARB: Toronto Blue Jays at Boston Red Sox (+4.85%) Toronto Blue Jays: 2.18 @ Bet365 -> stake $48.10 Boston Red Sox: 2.02 @ Sportsbet -> stake $51.90

Stake 48.10 on the Blue Jays at 2.18 and 51.90 on the Red Sox at 2.02. If the Blue Jays win you collect 104.85, and if the Red Sox win you collect 104.85. Either way you are up 4.85 on your 100 stake, whoever wins.

Things to know before betting real money

  • Arbs are short lived. Books correct their prices quickly, so an arb can be gone in seconds. To catch them as they appear, stream prices over the WebSocket feed instead of polling on a timer.
  • Add a margin buffer. A tiny edge can vanish before both bets are placed. Many tools only act on arbs above a minimum profit, for example 1 percent, rather than anything below 1.0.
  • Coverage matters. The more bookmakers you compare, the more arbs you see. See the full bookmaker coverage.

Taking it further

This scanner covers head to head, which is the simplest market because it has two outcomes that always oppose each other. You can point the same approach at more markets and more sports, with small changes to the logic.

  • More sports. Change the SPORT value to scan NFL, NBA, AFL, NRL, and the rest. The whole script works as is, since the response shape is the same for every sport.
  • Totals and spreads. These have a point field, so only outcomes on the same line can be paired (over 8.5 against under 8.5, not under 9.5). Group prices by their point value first, then run the same arb check within each line.
  • Player props. Same as totals and spreads, but you also match on the player_name field, so you only compare the same player on the same line across books.
  • Three way markets. Soccer head to head has home, draw and away, so you check three implied probabilities instead of two, and the same under 100 percent rule applies.

See the full list of sports and markets on the coverage page.

Next steps

You have a working 2-way arbitrage scanner. To make it production ready, stream odds over WebSocket so you react the instant a price moves, and handle games properly with the game matching guide. For other tools you can build on the same data, see what you can build with an odds API, or read the full API documentation.

Start building with RapidOddsAPI

Real-time, standardised odds from 100+ bookmakers over REST and WebSocket. Start free with 250 credits, no credit card required.

Get Your Free API KeyRead the Docs