Build a Value Betting Scanner with Python (Free Odds API)

Value Betting Scanner - OddsPapi API Blog
How To Guides April 2, 2026

What If You Could Scan 350+ Bookmakers for Value Bets in Seconds?

Value betting is the simplest profitable strategy in sports betting: find odds that are higher than the “true” probability of an outcome. The problem? Manually comparing odds across bookmakers is slow and error-prone. By the time you find value, the line has moved.

This tutorial builds a Python value betting scanner that uses Pinnacle’s closing line as the “true price” benchmark and scans soft bookmakers for mispriced odds — automatically. Every line of code is tested against the OddsPapi API with real data.

What Is Value Betting? (The 60-Second Version)

A bet has positive expected value (+EV) when the odds offered are higher than the true probability of the outcome. The question is: how do you know the “true” probability?

Professional bettors use Pinnacle’s closing line as the benchmark. Pinnacle accepts the highest limits from sharps, so their closing odds reflect the most efficient price in the market. If a soft bookmaker (Bet365, DraftKings, 1xBet) offers higher odds than Pinnacle on the same outcome, that’s value.

Concept Example
Pinnacle price Arsenal to win: 1.85 (implied prob: 54.1%)
Soft book price 1xBet Arsenal to win: 1.90 (implied prob: 52.6%)
Edge (1.90 / 1.85) – 1 = +2.7%
Verdict +EV bet — the soft book is offering more than the true price

A 2-3% edge per bet doesn’t sound like much. But over 1,000 bets, it compounds. That’s how sharps make money — volume + edge, not lucky parlays.

Why Pinnacle? The Sharp Benchmark

Not all bookmakers are created equal. Pinnacle is the sharpest book in the world because:

  • Lowest margins: 2-3% vig vs 5-8% at soft books
  • Highest limits: They don’t limit or ban winning players
  • Market-driven pricing: Their odds reflect the collective wisdom of the sharpest bettors

Academic research consistently shows that Pinnacle’s closing line is the single best predictor of match outcomes. If you’re not benchmarking against Pinnacle, you’re not value betting — you’re guessing.

The catch? Pinnacle doesn’t have a public API. OddsPapi aggregates Pinnacle odds alongside 350+ other bookmakers through a single API — including sharps like Singbet and SBOBet that are even harder to access directly.

Old Way vs OddsPapi

Approach Scraping Odds Sites The Odds API OddsPapi
Bookmakers 2-3 you scrape ~40 350+
Pinnacle included No (blocked) No Yes
Other sharps No No Singbet, SBOBet
Latency Minutes (scrape delay) Seconds Real-time (WebSocket available)
Rate limits IP bans Credit-based Free tier: 250 req/month
Historical data No $79/mo add-on Free tier included

Build the Value Betting Scanner: 5 Steps

Step 1: Setup and Configuration

import requests
import time
from datetime import datetime

API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"

# Configuration
SHARP_BOOK = "pinnacle"          # Benchmark bookmaker
SOFT_BOOKS = ["bet365", "1xbet", "draftkings", "fanduel", "betmgm"]
MIN_EDGE = 2.0                   # Minimum edge % to flag as value
MIN_ODDS = 1.30                  # Ignore heavy favourites
MAX_ODDS = 10.0                  # Ignore extreme longshots
SPORT_ID = 10                    # Soccer
MARKET_ID = 101                  # Full Time Result (1X2)

def api_get(endpoint, params=None):
    """Make an authenticated API call."""
    if params is None:
        params = {}
    params["apiKey"] = API_KEY
    response = requests.get(f"{BASE_URL}/{endpoint}", params=params)
    response.raise_for_status()
    return response.json()

Step 2: Fetch Upcoming Fixtures

def get_fixtures(sport_id, tournament_id=None):
    """Fetch upcoming fixtures with odds available."""
    params = {"sportId": sport_id, "hasOdds": True, "per_page": 50}
    if tournament_id:
        params["tournamentId"] = tournament_id
    else:
        # When using sportId alone, date range is required (max 10 days)
        from datetime import timedelta
        today = datetime.utcnow().strftime("%Y-%m-%d")
        end = (datetime.utcnow() + timedelta(days=7)).strftime("%Y-%m-%d")
        params["from"] = today
        params["to"] = end

    fixtures = api_get("fixtures", params)

    # Filter to upcoming only
    now = datetime.utcnow()
    upcoming = [
        f for f in fixtures
        if datetime.fromisoformat(f["startTime"].replace("Z", "")) > now
    ]

    return upcoming

# Example: Premier League fixtures
fixtures = get_fixtures(sport_id=10, tournament_id=17)
print(f"Found {len(fixtures)} upcoming fixtures with odds")
for f in fixtures[:5]:
    print(f"  {f['participant1Name']} vs {f['participant2Name']} — {f['startTime'][:10]}")

Step 3: Compare Sharp vs Soft Odds

This is the core engine. For each fixture, we fetch odds from Pinnacle and compare against each soft bookmaker. The API allows up to 3 bookmakers per request, so we batch them.

OUTCOME_MAP = {"101": "Home", "102": "Draw", "103": "Away"}

def get_odds_comparison(fixture_id, sharp=SHARP_BOOK, softs=SOFT_BOOKS):
    """
    Fetch odds from sharp + soft bookmakers and calculate edge.
    Returns list of value opportunities.
    """
    # Batch soft books (max 3 bookmakers per request)
    all_odds = {}

    # First request: sharp book + first 2 soft books
    batch1 = [sharp] + softs[:2]
    data = api_get("odds", {
        "fixtureId": fixture_id,
        "bookmakers": ",".join(batch1),
        "marketId": MARKET_ID
    })
    bookmaker_odds = data.get("bookmakerOdds", {})
    all_odds.update(bookmaker_odds)

    # Additional requests for remaining soft books (3 at a time)
    for i in range(2, len(softs), 3):
        batch = softs[i:i+3]
        time.sleep(1)
        data = api_get("odds", {
            "fixtureId": fixture_id,
            "bookmakers": ",".join(batch),
            "marketId": MARKET_ID
        })
        all_odds.update(data.get("bookmakerOdds", {}))

    # Get Pinnacle prices as benchmark
    if sharp not in all_odds:
        return []

    sharp_prices = {}
    sharp_markets = all_odds[sharp].get("markets", {}).get(str(MARKET_ID), {})
    for outcome_id, label in OUTCOME_MAP.items():
        player = sharp_markets.get("outcomes", {}).get(outcome_id, {}).get("players", {}).get("0", {})
        if isinstance(player, dict) and "price" in player:
            sharp_prices[outcome_id] = player["price"]

    if not sharp_prices:
        return []

    # Compare each soft book
    value_bets = []
    for soft_slug in softs:
        if soft_slug not in all_odds:
            continue

        soft_markets = all_odds[soft_slug].get("markets", {}).get(str(MARKET_ID), {})
        for outcome_id, label in OUTCOME_MAP.items():
            if outcome_id not in sharp_prices:
                continue

            player = soft_markets.get("outcomes", {}).get(outcome_id, {}).get("players", {}).get("0", {})
            if not isinstance(player, dict) or "price" not in player:
                continue

            soft_price = player["price"]
            sharp_price = sharp_prices[outcome_id]

            # Calculate edge
            edge = ((soft_price / sharp_price) - 1) * 100

            # Filter
            if edge >= MIN_EDGE and MIN_ODDS <= soft_price <= MAX_ODDS:
                value_bets.append({
                    "outcome": label,
                    "bookmaker": soft_slug,
                    "soft_odds": soft_price,
                    "pinnacle_odds": sharp_price,
                    "edge": round(edge, 2),
                    "implied_prob": round(1 / sharp_price * 100, 1),
                })

    return value_bets

Step 4: Scan All Fixtures and Rank by Edge

def scan_for_value(fixtures, sharp=SHARP_BOOK, softs=SOFT_BOOKS):
    """Scan all fixtures and return sorted value bets."""
    all_value = []

    for i, f in enumerate(fixtures):
        fixture_id = f["fixtureId"]
        match_name = f"{f['participant1Name']} vs {f['participant2Name']}"
        kickoff = f["startTime"][:16].replace("T", " ")

        print(f"Scanning [{i+1}/{len(fixtures)}]: {match_name}...")

        value_bets = get_odds_comparison(fixture_id, sharp, softs)

        for vb in value_bets:
            vb["match"] = match_name
            vb["kickoff"] = kickoff
            all_value.append(vb)

        time.sleep(2)  # Respect rate limits

    # Sort by edge (highest first)
    all_value.sort(key=lambda x: x["edge"], reverse=True)
    return all_value

# Run the scanner
print("=" * 60)
print("  VALUE BETTING SCANNER")
print(f"  Benchmark: {SHARP_BOOK} | Min edge: {MIN_EDGE}%")
print(f"  Scanning {len(fixtures)} fixtures...")
print("=" * 60)

value_bets = scan_for_value(fixtures)

if value_bets:
    print(f"\n{'='*60}")
    print(f"  FOUND {len(value_bets)} VALUE BETS")
    print(f"{'='*60}\n")

    for vb in value_bets:
        print(f"  {vb['match']}")
        print(f"  ├── Outcome:  {vb['outcome']}")
        print(f"  ├── Book:     {vb['bookmaker']} @ {vb['soft_odds']}")
        print(f"  ├── Pinnacle: {vb['pinnacle_odds']}")
        print(f"  ├── Edge:     +{vb['edge']}%")
        print(f"  └── Kickoff:  {vb['kickoff']}")
        print()
else:
    print("\nNo value bets found. Markets may be efficient right now.")
    print("Try scanning closer to kickoff — lines diverge more pre-match.")

Step 5: Kelly Criterion — How Much to Stake

Finding value is step one. Sizing your bets correctly is step two. The Kelly Criterion tells you the optimal stake based on your edge and the odds.

def kelly_stake(odds, true_prob, bankroll, fraction=0.25):
    """
    Calculate Kelly Criterion stake.

    Args:
        odds: Decimal odds offered by soft book
        true_prob: True probability (from Pinnacle implied prob)
        bankroll: Current bankroll in units
        fraction: Kelly fraction (0.25 = quarter Kelly, recommended)

    Returns:
        Recommended stake in units
    """
    # Kelly formula: f = (bp - q) / b
    # b = odds - 1 (net odds)
    # p = true probability
    # q = 1 - p
    b = odds - 1
    p = true_prob
    q = 1 - p

    kelly = (b * p - q) / b

    if kelly <= 0:
        return 0  # No edge — don't bet

    # Apply fractional Kelly (reduces variance)
    stake = bankroll * kelly * fraction
    return round(stake, 2)

# Example: Apply Kelly to our value bets
BANKROLL = 1000  # Starting bankroll in units

print(f"\n{'='*60}")
print(f"  STAKING PLAN (Quarter Kelly, Bankroll: {BANKROLL} units)")
print(f"{'='*60}\n")

for vb in value_bets[:10]:
    true_prob = 1 / vb["pinnacle_odds"]
    stake = kelly_stake(vb["soft_odds"], true_prob, BANKROLL, fraction=0.25)

    if stake > 0:
        potential_profit = stake * (vb["soft_odds"] - 1)
        print(f"  {vb['match']} — {vb['outcome']}")
        print(f"  ├── Stake:   {stake} units @ {vb['soft_odds']} ({vb['bookmaker']})")
        print(f"  ├── Edge:    +{vb['edge']}%")
        print(f"  └── Profit:  {potential_profit:.2f} units (if won)")
        print()

Closing Line Value: The Ultimate Validation

How do you know your scanner is actually finding real value and not just noise? Closing Line Value (CLV) is the answer.

CLV measures whether the odds you bet at were better than the closing line. If you consistently get odds above the closing price, you have an edge — even if individual bets lose. Here's how to track it:

def track_clv(bet_log, fixture_id, outcome_id, bet_odds):
    """
    After a match starts, compare your bet odds to Pinnacle's closing line.
    Positive CLV = you're beating the market.
    """
    try:
        data = api_get("historical-odds", {
            "fixtureId": fixture_id,
            "bookmakers": "pinnacle",
            "marketId": MARKET_ID
        })
    except Exception:
        return None

    markets = data["bookmakers"]["pinnacle"]["markets"][str(MARKET_ID)]
    snapshots = markets["outcomes"][outcome_id]["players"]["0"]

    # Closing line = first snapshot (newest, pre-match)
    closing_price = snapshots[0]["price"]
    clv = ((bet_odds / closing_price) - 1) * 100

    return {
        "bet_odds": bet_odds,
        "closing_odds": closing_price,
        "clv": round(clv, 2),
        "beating_close": clv > 0
    }

# Example usage after matches are played:
# clv_result = track_clv(bet_log, "id123", "101", 1.95)
# print(f"CLV: {clv_result['clv']:+.2f}%")
# If CLV is consistently positive across 100+ bets, your scanner works.

Advanced: Multi-Sport Scanning

The scanner works for any sport — just change the sportId, tournamentId, and marketId. Here are the most popular configurations:

Sport Sport ID Market Market ID Best Soft Books
Soccer 10 1X2 (Full Time Result) 101 1xBet, Bet365, Betway
Basketball (NBA) 11 Moneyline 141 DraftKings, FanDuel, BetMGM
Baseball (MLB) 13 Moneyline 141 DraftKings, FanDuel, Caesars
American Football (NFL) 14 Moneyline 141 DraftKings, FanDuel, BetMGM
Tennis 15 Match Winner 171 Bet365, 1xBet, Betway
Esports (CS2) 17 Match Winner 171 1xBet, GG.BET, Betway
# Scan NBA instead of soccer:
nba_fixtures = get_fixtures(sport_id=11, tournament_id=132)
nba_scanner = scan_for_value(nba_fixtures)

# Scan MLB:
mlb_fixtures = get_fixtures(sport_id=13, tournament_id=109)
mlb_scanner = scan_for_value(mlb_fixtures)

What Makes OddsPapi the Best Tool for Value Betting

Feature Why It Matters for Value Betting
350+ bookmakers More books = more mispriced odds to find
Pinnacle + Singbet + SBOBet Three sharp benchmarks, not just one. Cross-validate your "true price."
Real-time odds Value windows close fast. Sub-second latency with WebSocket.
Free historical data Backtest your value strategy against past odds — included on the free tier
Simple auth One API key as query parameter. No OAuth, no tokens, no headers.

Common Value Betting Mistakes

Mistake What Happens Fix
Chasing huge edges 15%+ edge usually means a palp (pricing error) — your bet gets voided Cap max edge at 10%. Real value is 2-5%.
Ignoring limits Soft books limit winners fast Spread bets across multiple books. Use the scanner to find which book has the best price.
No CLV tracking You don't know if your edge is real Track CLV on every bet. 100+ bets of positive CLV = confirmed edge.
Flat staking on all bets Equal stake on 1.5 odds and 5.0 odds wastes bankroll Use Kelly Criterion (quarter Kelly recommended).
One sport only Limited volume, slower compounding Scan soccer, NBA, MLB, tennis. Value is sport-agnostic.

Frequently Asked Questions

How much edge do I need for profitable value betting?

A consistent 2-3% edge over Pinnacle's closing line is enough to be profitable long-term. Over 1,000 bets at 2% edge, your expected profit is approximately 20 units (at 1 unit per bet). The key word is "consistent" — you need volume, not occasional big edges.

Won't soft bookmakers limit my account?

Eventually, yes. Most soft books limit accounts that consistently beat the closing line. That's why scanning 350+ bookmakers matters — when Bet365 limits you, move to Betway, 888sport, or regional books. OddsPapi covers bookmakers most scanners miss.

Can I use Singbet or SBOBet instead of Pinnacle as the benchmark?

Yes. Singbet and SBOBet are equally sharp, especially for Asian Handicap markets. Change SHARP_BOOK = "singbet" in the scanner configuration. Using multiple sharp benchmarks (comparing all three) gives you higher confidence in the true price.

What's the difference between value betting and arbitrage?

Arbitrage guarantees profit on every event by betting all outcomes across different bookmakers. Value betting only bets one side — you'll lose individual bets, but win over time with sufficient volume. Value betting has higher expected returns but higher variance.

How many bets per day can I find?

Depends on the sports and bookmakers you scan. A typical scan across Premier League, La Liga, NBA, and MLB on a busy day yields 5-20 value bets above 2% edge. More bookmakers and more leagues = more opportunities.

Stop Scanning Manually. Automate Your Edge.

The code above is a complete value betting scanner. Copy it, plug in your free API key, and start scanning. Pinnacle, Singbet, and 350+ other bookmakers — all through one endpoint, no scraping, no enterprise contracts.

Related guides: