Player Props Value Scanner: Find +EV Outliers in Python (Free API)
Player props are the fastest-growing market in US sports betting — and the most mispriced. FanDuel, DraftKings, Caesars, and Bovada all publish their own home-run lines, anytime-touchdown prices, and first-basket odds. They don’t talk to each other. They don’t post a “consensus” price. And on any given Tuesday slate, the spread between the best book and the worst book on the same player can be 10–15% — pure edge for anyone willing to scan.
This guide walks through building a player props value scanner in Python. It pulls live odds from four US books, computes a consensus median per player + outcome, and flags every line that’s ≥5% better than the median — in <100 lines of code. Real example output included from tonight’s MLB slate.
Why player props are a value-betting goldmine
Mainline markets (moneylines, spreads, totals) are tight. Pinnacle, Singbet, and Polymarket all benchmark each other, so the across-book spread on an NFL spread is usually <2–3%. Player props are different:
- No sharp anchor. Pinnacle doesn’t price US player props. Singbet doesn’t either. The benchmark is whatever 3–5 US soft books decide to post.
- Traders cover hundreds of players per game. Each MLB fixture has 30+ players priced on home runs alone. Books shortcut with model outputs and patch the obvious mistakes only after the line moves.
- Limits are low. A book that gets hit on a stale prop will let you keep firing for $50–$200 before tightening. That’s why sharps shop, scan, and scale — not chase one max bet.
The result: a 5–15% edge over consensus on player props is common. You just need a scanner to find them across books.
What this scanner does
| Manual approach | OddsPapi scanner |
|---|---|
| Open 4 sportsbook apps | 1 API call per fixture |
| Click through each player, each market | Walk JSON, ~50 lines of Python |
| Eyeball prices vs “feels right” | Median consensus, ≥5% threshold |
| Cover one game in 10–15 min | Cover the full MLB slate in <1 minute |
| Miss 80% of outliers | Catch every ≥5% gap above median |
OddsPapi aggregates 350+ bookmakers behind one query parameter. For player props specifically, FanDuel, DraftKings, Caesars, and William Hill all return data on the same nested JSON shape — so building a cross-book scanner is just walking that shape and aggregating prices.
Step 1: Authenticate and find tonight’s fixtures
The OddsPapi API uses a query parameter for auth, not a header. Grab a free API key here — the entire scanner runs on the free tier.
import requests
from datetime import datetime, timedelta, timezone
API = "https://api.oddspapi.io/v4"
KEY = "YOUR_API_KEY" # https://oddspapi.io/signup
today = datetime.now(timezone.utc).date()
end = today + timedelta(days=1)
# sportId 13 = MLB (full list at /v4/sports)
r = requests.get(f"{API}/fixtures", params={
"apiKey": KEY,
"sportId": 13,
"from": today.isoformat(),
"to": end.isoformat(),
})
fixtures = [f for f in r.json() if f.get("hasOdds")]
print(f"{len(fixtures)} MLB fixtures with odds tonight")
# Filter to MLB only (the response also includes NPB, KBO, minor leagues)
mlb_teams = {"Yankees","Red Sox","Dodgers","Mets","Phillies","Astros","Braves",
"Cubs","Blue Jays","Orioles","Rangers","Royals","Tigers","Twins",
"Guardians","Marlins","Rays","Rockies","Diamondbacks","Angels",
"Padres","Giants","Athletics","Mariners","Brewers","Reds",
"Cardinals","Pirates","White Sox","Nationals"}
mlb = [f for f in fixtures
if any(t in f["participant1Name"] for t in mlb_teams)
or any(t in f["participant2Name"] for t in mlb_teams)]
print(f"MLB only: {len(mlb)} games")
Step 2: Pull props for one fixture, 4 books at a time
Player props live under their own market IDs. For MLB, the headline market is 131663 — Home Runs (incl. extra innings), Yes/No. Each player has two outcomes: 131664 (1+ HR) and 131665 (2+ HR).
Same query, four bookmakers passed as a comma-separated list. (CLAUDE.md note: as of May 2026, FanDuel / DraftKings / Caesars / William Hill cover MLB home run props on OddsPapi. BetMGM ships mainlines + game props but not HR yet.)
def fetch_props(fixture_id, market_id="131663",
books="fanduel,draftkings,caesars,williamhill"):
r = requests.get(f"{API}/odds", params={
"apiKey": KEY,
"fixtureId": fixture_id,
"bookmakers": books,
})
data = r.json()
if "bookmakerOdds" not in data:
return {}
# Walk the nested shape:
# bookmakerOdds[book]['markets'][market_id]['outcomes'][outcome_id]
# ['players'][player_id] -> { price, playerName, active, ... }
props = {} # (outcome_id, player_id, player_name) -> { book: price }
for book, bdata in data["bookmakerOdds"].items():
if market_id not in bdata["markets"]:
continue
outcomes = bdata["markets"][market_id]["outcomes"]
for out_id, out_data in outcomes.items():
for player_id, pdata in out_data["players"].items():
if not pdata.get("active"):
continue
name = pdata.get("playerName") or "Unknown"
key = (out_id, player_id, name)
props.setdefault(key, {})[book] = pdata["price"]
return props
Two defensive checks here are non-obvious:
if not pdata.get("active")— books occasionally ship inactive props with a stale price field. Filter those out or you’ll “find value” on a market that’s been suspended.players[player_id]is a dict on the live/oddsendpoint (the price is at['players']['0']['price']). On/historical-oddsit’s a list of price snapshots. Don’t mix the two shapes — the parser will silently break.
Step 3: Compute consensus and find outliers
For each (outcome, player) combo with at least 2 books, compute the median price across books. Any single book quoting ≥5% above the median is a value flag — you’re getting better implied probability than the consensus pricing model says you should.
from statistics import median
def find_outliers(props, edge_threshold=0.05):
outliers = []
for (out_id, pid, name), prices in props.items():
if len(prices) < 2:
continue # need 2+ books to anchor consensus
med = median(prices.values())
for book, price in prices.items():
edge = (price - med) / med # positive = better for bettor
if edge >= edge_threshold:
outliers.append({
"name": name,
"outcome": "1+ HR" if out_id == "131664" else "2+ HR",
"book": book,
"price": price,
"median": med,
"edge_pct": edge * 100,
"all_prices": prices,
})
return sorted(outliers, key=lambda o: o["edge_pct"], reverse=True)
Quick sanity check: higher decimal odds = better for the bettor. A book quoting 5.54 on a player to hit a home run is paying more than a book quoting 4.80 on the same player — same outcome, better price.
Step 4: Real scan output (Dodgers @ Rockies, tonight)
Run the scanner against Dodgers vs Rockies at Coors Field — HR-friendly park, deep slate:
fx_id = "id1300010963300251" # Dodgers @ Rockies tonight
props = fetch_props(fx_id)
print(f"Total (outcome, player) combos: {len(props)}")
multi = {k: v for k, v in props.items() if len(v) >= 2}
print(f"With 2+ books: {len(multi)}")
outliers = find_outliers(props, edge_threshold=0.05)
for o in outliers[:10]:
print(f"+{o['edge_pct']:5.1f}% {o['name']} {o['outcome']}: "
f"{o['book']} @ {o['price']} vs median {o['median']:.2f}")
Live API output (UTC 2026-05-25, pre-game):
| Edge | Player | Market | Best Book | Best Price | Median |
|---|---|---|---|---|---|
| +15.4% | Teoscar Hernández | 1+ HR | DraftKings | 5.54 | 4.80 |
| +11.9% | Andy Pages | 1+ HR | DraftKings | 6.10 | 5.45 |
| +11.4% | Kyle Karros | 1+ HR | DraftKings | 12.20 | 10.95 |
| +11.3% | Miguel Rojas | 1+ HR | FanDuel | 8.90 | 8.00 |
| +10.3% | Ezequiel Tovar | 1+ HR | DraftKings | 8.30 | 7.53 |
| +10.2% | Hunter Goodman | 1+ HR | DraftKings | 4.49 | 4.05 |
Six 1+ HR props with ≥10% edge over consensus in a single fixture, all four books agreeing on the consensus and one book paying significantly more. Total: 38 (player, outcome) combos in the game, 30 multi-book, 22 outliers ≥5% above median.
Teoscar Hernández at DraftKings 5.54 vs the FanDuel/Caesars/William Hill consensus of 4.80 isn’t a typo — it’s a 15.4% pricing miss DraftKings hasn’t repriced yet. That’s the signal sharps fire on.
Step 5: Scan the full slate
The same loop, wrapped around every MLB fixture tonight. Built-in cooldown (time.sleep(0.3)) keeps you under the free-tier rate limit:
import time
def scan_slate(fixtures, edge_threshold=0.05):
all_hits = []
for f in fixtures:
props = fetch_props(f["fixtureId"])
outliers = find_outliers(props, edge_threshold)
for o in outliers:
o["fixture"] = f"{f['participant1Name']} @ {f['participant2Name']}"
o["starts_at"] = f["startTime"]
all_hits.append(o)
time.sleep(0.3) # rate-limit buffer
return sorted(all_hits, key=lambda o: o["edge_pct"], reverse=True)
hits = scan_slate(mlb, edge_threshold=0.05)
print(f"Total outliers across {len(mlb)} games: {len(hits)}")
for h in hits[:20]:
print(f"+{h['edge_pct']:5.1f}% {h['name']:25s} {h['outcome']:6s} "
f"{h['book']:12s} @ {h['price']:6.2f} [{h['fixture']}]")
On a typical 15-game MLB slate that scans ~600 props across 4 books and surfaces 200–400 outliers above the 5% threshold — far too many to bet manually, which is the point. You filter from there.
Extending the scanner: NBA, NFL, NHL props
The same shape works across every US sport — you just swap the market ID. Verified May 2026 against /v4/markets:
| Sport | Market | Market ID | Books with coverage (May 2026) |
|---|---|---|---|
| MLB | Home Runs (1+ / 2+) Yes/No | 131663 |
FanDuel, DraftKings, Caesars, William Hill |
| NBA | First Basket Scorer | 112604 |
FanDuel, DraftKings, BetMGM, Bovada |
| NFL | Anytime Touchdown | 14388 |
FanDuel, DraftKings, BetMGM |
| NFL | First Touchdown | 14390 |
FanDuel, DraftKings, BetMGM |
| NHL | Anytime Goal Scorer | 152401 |
FanDuel, DraftKings, BetMGM |
Don’t hardcode market IDs blindly — query the live catalogue:
# Build a name lookup for any sport
r = requests.get(f"{API}/markets", params={"apiKey": KEY, "sportId": 13})
catalog = r.json()
market_names = {m["marketId"]: m["marketName"] for m in catalog}
print(market_names[131663]) # "Home Runs (incl. extra innings)"
Why this works: the data advantage
Most aggregators stop at ~40 bookmakers. SportsGameOdds advertises ~85. OddsJam and OpticOdds cover ~200. OddsPapi aggregates 350+ bookmakers including Pinnacle, Singbet, SBOBet, Polymarket, and every major US soft book (FanDuel, DraftKings, BetMGM, Caesars, BetRivers, William Hill). For player props specifically, that means you get the full US consensus picture — and the rare overlap with sharp pricing on the moneyline market that anchors the implied team total.
Two more kill shots worth noting for prop scanners:
- Free historical odds. Backtest your scanner against free historical data — query
/v4/historical-oddsfor any closed fixture and replay the scanner to validate your edge threshold before risking real money. - Real-time WebSockets. Live in-game props move fast. The REST polling pattern above works on the free tier; if you need sub-second updates for live betting, the WebSocket feed pushes updates as the books reprice.
Caveats — what this scanner won’t do
Three things to understand before you fire bets off this:
- Soft consensus, not Pinnacle consensus. US books don’t have a sharp anchor for most player props. If FanDuel, DraftKings, Caesars, and William Hill all mis-price a player the same way (because they share model inputs from the same data vendors), the “outlier” might still be a bad bet against the true probability. The Pinnacle no-vig benchmark doesn’t apply here.
- De-vig for fair odds, not just median. A median of 4.80 across four books already bakes in ~6–8% vig. For true +EV math, de-vig each book’s prop pair first (Yes + No) and then median the fair prices. The consensus odds post walks the math.
- Limits. Hit a soft book on a +15% outlier and you’ll get $50–$200 through before they tighten. Don’t size like it’s a Pinnacle mainline. Use fractional Kelly staking and accept that prop scanners are volume games, not single-bet shots.
Putting it together
The full scanner — ~80 lines, runs on the free tier:
import requests, time
from statistics import median
from datetime import datetime, timedelta, timezone
API = "https://api.oddspapi.io/v4"
KEY = "YOUR_API_KEY"
BOOKS = "fanduel,draftkings,caesars,williamhill"
def get_fixtures(sport_id):
today = datetime.now(timezone.utc).date()
end = today + timedelta(days=1)
r = requests.get(f"{API}/fixtures", params={
"apiKey": KEY, "sportId": sport_id,
"from": today.isoformat(), "to": end.isoformat(),
})
return [f for f in r.json() if f.get("hasOdds")]
def fetch_props(fixture_id, market_id):
r = requests.get(f"{API}/odds", params={
"apiKey": KEY, "fixtureId": fixture_id, "bookmakers": BOOKS,
})
data = r.json()
if "bookmakerOdds" not in data:
return {}
props = {}
for book, bdata in data["bookmakerOdds"].items():
if market_id not in bdata["markets"]:
continue
for out_id, out_data in bdata["markets"][market_id]["outcomes"].items():
for player_id, pdata in out_data["players"].items():
if not pdata.get("active"):
continue
name = pdata.get("playerName") or "Unknown"
props.setdefault((out_id, player_id, name), {})[book] = pdata["price"]
return props
def find_outliers(props, edge=0.05):
out = []
for (oid, pid, name), prices in props.items():
if len(prices) < 2:
continue
med = median(prices.values())
for book, price in prices.items():
if (price - med) / med >= edge:
out.append((round((price - med) / med * 100, 1),
name, book, price, med, oid))
return sorted(out, reverse=True)
# Scan tonight's MLB slate for HR props
for f in get_fixtures(sport_id=13)[:20]:
props = fetch_props(f["fixtureId"], "131663")
for e, name, book, price, med, oid in find_outliers(props):
market = "1+ HR" if oid == "131664" else "2+ HR"
print(f"+{e}% {name} {market} {book} @ {price} med={med:.2f} "
f"[{f['participant1Name']} @ {f['participant2Name']}]")
time.sleep(0.3)
Frequently Asked Questions
Which bookmakers does OddsPapi cover for player props?
As of May 2026, FanDuel, DraftKings, BetMGM, Caesars, William Hill, and Bovada return player prop data on OddsPapi for US sports. Coverage varies by sport — FanDuel and DraftKings have the broadest market depth across MLB / NBA / NFL / NHL, BetMGM is strong on NFL/NHL, Caesars and William Hill cover MLB home runs. BetRivers and theScore Bet currently return mainlines + game props only.
Why use a median instead of a mean or de-vigged consensus?
Median is the simplest robust estimator across 3–4 books — it ignores one rogue outlier in either direction. Mean over-weights extremes; de-vig consensus is theoretically correct but requires pairing Yes/No outcomes and is sensitive to book-specific overround. Start with median, then move to a de-vigged fair price once your scanner is validated.
How much edge do I actually need to bet a prop?
5% above median is a starting threshold — aggressive, but justified by the fact that US prop pricing isn’t anchored to a sharp benchmark. Conservative bettors raise the threshold to 8–10%. The right number is whatever your backtest against free historical data says clears CLV.
Does this work for first-touchdown and anytime-goalscorer props?
Yes — same code, different market ID. NFL Anytime Touchdown is market 14388, First Touchdown is 14390, NHL Anytime Goal Scorer is 152401, NBA First Basket is 112604. Query /v4/markets?sportId=14 (NFL) or ?sportId=11 (NBA) for the full catalogue.
Is the free tier enough to run this in production?
For a single-sport scanner running once before each slate, yes. The free tier covers fixture and odds endpoints with a ~0.88s cooldown per endpoint. For multi-sport real-time scanning across every prop type, you’ll want either tighter polling or the WebSocket feed on a paid tier.
Start scanning
Player prop scanning isn’t a black-box arbitrage exotic — it’s median consensus + a threshold filter, built in 80 lines on the free tier. The scanner does what manual line-shopping does, 100× faster, across every player on every game.
Get your free OddsPapi API key, drop in the scanner above, and you’re catching outliers before the books reprice them.