RapidOddsAPIRapidOddsAPI
Home/Blog/Guide

Build a middles scanner in Python

Find middle bets on totals across bookmakers. Back the Over at a low line and the Under at a high line, and win both if the result lands in the gap.

Guide ยท Updated June 2026

A middle bet backs the Over on a low line at one bookmaker and the Under on a high line at another. If the final total lands between the two lines, both bets win. If it lands outside, one wins and one loses, so the cost of holding the middle is small. This guide builds a totals middle scanner in Python: pull over/under lines from many books over the RapidOddsAPI REST endpoint, pair every low Over against every high Under at different books, and work out the profit, the stakes, and the cost if it misses.

Middles do not work on head to head, because there is no gap to land in, you either win or lose. They work on lines: totals, spreads, and anything with a point value. The classic case is a total. Back over 9.5 runs at one book and under 10.5 at another, and if the game lands on exactly 10, both bets cash. Even when it does not middle you usually lose very little, so the upside is lopsided in your favour.

The idea in one line

Find the same game at two books where one offers a low Over line and the other a high Under line. The gap between the lines is the range where both bets win.

back Over 9.5 + back Under 10.5 -> both win if the total is 10

The wider the gap, the more likely it lands inside, but books price wide gaps short, so the trade off is how much you can win against how often it hits. We will measure both.

Step 1: pull the totals lines

Ask the REST endpoint for the over/under market. The market key depends on the sport. For MLB it is alternate_total_runs (NBA and NFL use alternate_total_points, NHL and soccer use alternate_total_goals). The full list is on the coverage page. The alternate markets matter here because they return several lines per book, which is what gives you different lines to middle.

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

Step 2: group each game across books

As with any cross book tool, the response returns one entry per game and bookmaker, so merge the entries for the same game first. We match by team name, which is enough when each matchup happens once. For doubleheaders or mismatched start times, see 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 Over and Under line

For one game, gather every Over and every Under on offer, each tagged with its line and its bookmaker. Each outcome carries a point field, which is the line value.

def collect_totals(game): overs, unders = [], [] for book in game["bookmakers"]: for market in book["markets"]: if market["key"] != MARKET: continue for o in market["outcomes"]: line, price, side = o.get("point"), o["price"], o["name"] if line is None: continue if side == "Over": overs.append((line, price, book["name"])) elif side == "Under": unders.append((line, price, book["name"])) return overs, unders

Step 4: test a pair of lines for a middle

Given one Over and one Under, this checks whether they form a valid middle, then works out the numbers. Three rules decide validity:

  • The Over line must be below the Under line, at different books. That is what creates the gap.
  • The gap must be at least 1, or there is no room for the total to land between the lines.
  • Not both whole numbers. Two whole lines, like over 9 and under 10, leave no whole number in between, so it can push instead of middle.

The stakes are split by implied probability, exactly like an arbitrage, so the payout is the same whichever single bet wins.

def middle(over, under): over_line, over_price, over_book = over under_line, under_price, under_book = under if over_book == under_book or over_line >= under_line: return None gap = under_line - over_line both_whole = over_line % 1 == 0 and under_line % 1 == 0 if gap < 1 or both_whole: return None # Split the stake by implied probability. margin = 1 / over_price + 1 / under_price over_stake = STAKE * (1 / over_price) / margin under_stake = STAKE * (1 / under_price) / margin # Both bets win if the total lands in the gap. profit_if_hit = over_stake * over_price + under_stake * under_price - STAKE # Otherwise one wins and one loses. This small loss is the cost of holding # the middle, often called the qualifying loss, or QL. cost_if_miss = min(over_stake * over_price - STAKE, under_stake * under_price - STAKE) return { "gap": gap, "profit_if_hit": profit_if_hit, "cost_if_miss": cost_if_miss, "ql_pct": cost_if_miss / STAKE * 100, "over": (over_line, over_price, over_book, over_stake), "under": (under_line, under_price, under_book, under_stake), }

The full scanner

Put it together. Pull the lines, group each game, then pair every Over against every higher Under and print the middles found.

import requests API_KEY = "your_api_key" SPORT = "MLB" MARKET = "alternate_total_runs" BOOKMAKERS = [ "Bet365", "Sportsbet", "TAB", "Pointsbet", "Ladbrokes", "Unibet", "Dabble", ] STAKE = 100 # total stake to split across the two bets 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 collect_totals(game): overs, unders = [], [] for book in game["bookmakers"]: for market in book["markets"]: if market["key"] != MARKET: continue for o in market["outcomes"]: line, price, side = o.get("point"), o["price"], o["name"] if line is None: continue if side == "Over": overs.append((line, price, book["name"])) elif side == "Under": unders.append((line, price, book["name"])) return overs, unders def middle(over, under): over_line, over_price, over_book = over under_line, under_price, under_book = under if over_book == under_book or over_line >= under_line: return None gap = under_line - over_line both_whole = over_line % 1 == 0 and under_line % 1 == 0 if gap < 1 or both_whole: return None margin = 1 / over_price + 1 / under_price over_stake = STAKE * (1 / over_price) / margin under_stake = STAKE * (1 / under_price) / margin profit_if_hit = over_stake * over_price + under_stake * under_price - STAKE cost_if_miss = min(over_stake * over_price - STAKE, under_stake * under_price - STAKE) return { "gap": gap, "profit_if_hit": profit_if_hit, "cost_if_miss": cost_if_miss, "ql_pct": cost_if_miss / STAKE * 100, "over": (over_line, over_price, over_book, over_stake), "under": (under_line, under_price, under_book, under_stake), } def main(): resp = requests.get( f"https://api.rapidoddsapi.com/sports/{SPORT}/markets", params={ "api_key": API_KEY, "market_type": [MARKET], "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: overs, unders = collect_totals(game) g = game["game"] for over in overs: for under in unders: m = middle(over, under) if m is None: continue ol, op, ob, ostake = m["over"] ul, up, ub, ustake = m["under"] print(f"MIDDLE: {g['away_team']} at {g['home_team']} " f"(gap {m['gap']:.1f}, QL {m['ql_pct']:.1f}%)") print(f" Over {ol} @ {op} ({ob}) -> stake ${ostake:.2f}") print(f" Under {ul} @ {up} ({ub}) -> stake ${ustake:.2f}") print(f" profit if it middles: ${m['profit_if_hit']:.2f}") print() if __name__ == "__main__": main()

What you get back

Each middle prints the game, the gap, the cost of holding it, and the stake for each bet. A tight one run middle looks like this:

MIDDLE: New York Mets at Cincinnati Reds (gap 1.0, QL -14.3%) Over 9.5 @ 2.0 (GenWeb) -> stake $42.86 Under 10.5 @ 1.5 (Dabble) -> stake $57.14 profit if it middles: $71.43

Stake 42.86 on over 9.5 at 2.0 and 57.14 on under 10.5 at 1.5. If the game lands on exactly 10 runs, both bets win and you make 71.43 on your 100. If it lands anywhere else, one bet wins and one loses, costing about 14.29, the qualifying loss. So you risk a small, known loss for a shot at a large win.

Filtering down to the good ones

Run this and you will get a lot of results, because every Over pairs with every higher Under. Most are not worth taking. Take a 9.5 to 16.5 middle. A wide gap like that lands inside often, since the total only has to be 10 through 16, but that is exactly why the book prices the high Under so short, often near 1.05. Almost all your stake goes on the Under, the win if it middles shrinks, and the loss if it misses grows. So the wider the gap, the bigger the qualifying loss. The value is in the tight gaps, a one run middle is rare to hit but pays a lot when it does, for a small QL.

So you rank and filter. The two numbers that matter most are:

  • Qualifying loss (QL). How much it costs when it does not middle. Smaller is better. Filtering to a small QL, say within a few percent, keeps the cheap to hold middles and drops the expensive wide ones.
  • Effective odds. The profit if it middles divided by the qualifying loss, which is the true price of the bet. The higher it is, the more you win for each dollar at risk.

In a real tool you would set a maximum acceptable QL and a minimum effective odds, then only surface middles that clear both. A tight one run middle with a small QL and good effective odds is the kind worth placing. Add those two thresholds and the flood of results becomes a short, useful list.

Taking it further

  • Spreads. Middles work the same on spreads. Back one side at a generous line and the other side at a generous line, and win both if the result lands between them.
  • More sports. Change SPORT and MARKET to scan NBA, NFL, NHL, and the rest. Use the right totals key for the sport.
  • Catch them live. Lines move, and a middle opens when one book is slow to follow the rest. Stream prices over the WebSocket feed to spot a new middle the moment a line moves.

Next steps

You have a working totals middle scanner. For the closely related guaranteed profit play, see the arbitrage scanner guide. For other tools 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