NFL Odds API: Free Python Guide to Lines, Spreads & Totals (2026)

How To Guides November 25, 2025

Fetch live NFL moneylines, spreads and totals in Python from 16 sportsbooks, including Pinnacle and SBOBet, on a free API key. Every code block and every number on this page ran against the live feed before it was published.

If you are building a betting model, a line-movement tracker, an arbitrage calculator or a props screener, you have hit the same wall everyone hits. NFL odds data is either priced for enterprise buyers (Sportradar, Genius) or capped at a dozen retail books that all copy the same opening number. Neither gives you what you need, which is the sharp price next to the soft price.

This guide fixes that. It also fixes the mistake that breaks most NFL scripts on day one: hardcoded market IDs.

What the NFL feed looks like right now

Numbers pulled live on 28 July 2026, six weeks before kickoff:

Window Fixtures with odds Depth
NFL Preseason, from 6 Aug 10 Opener carries 14 books including Pinnacle, SBOBet, Kalshi and Polymarket
NFL Week 1, 10 to 13 Sep 14 (all of them) 16 books, 28 to 31 distinct markets per game
NCAA, 5 to 14 Sep 51 14 books, SBOBet the only sharp so far

Lines are already live and moving. You do not have to wait for September to build against real data.

NFL odds API providers compared

Provider Bookmakers Sharp books Historical odds Free tier
OddsPapi 350+ (381 live) Pinnacle, SBOBet, Circa, exchanges Yes, free Yes
The Odds API ~15 US books Pinnacle Paid add-on Yes, capped
SportsDataIO ~10 books No Limited Trial only
Sportradar Enterprise Yes Yes None

The difference that matters for NFL: retail books shade their lines toward public money, so a feed made only of DraftKings, FanDuel and BetMGM shows you the same shaded number three times. Pinnacle priced our Week 1 test game at 3.18% vig against Bet365’s 4.40%. You need both sides of that to know what anything is worth.

Terminology: US sports on a global feed

US concept API term What to watch for
League tournament NFL is tournamentId 31 inside sport 14
Game fixture Everything keys off fixtureId
Team participant participant1Name and participant2Name
Moneyline Market 141 Stable. Outcomes 141 and 142
Spread Handicap markets One market ID per line. -3.5 is a different ID from -4
Total Total markets One market ID per line. 44.5 is a different ID from 45

The last two rows are where scripts break. There is no single “spreads” endpoint or single totals market ID. Read on.

Step 1: Authenticate and find the NFL

The API key goes in the query string, not a header. That means you can paste any of these URLs straight into a browser to check them.

import time
import requests

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


def api(path, **params):
    """GET with the retry the free tier expects."""
    for _ in range(4):
        r = requests.get(f"{BASE_URL}/{path}",
                         params={"apiKey": API_KEY, **params})
        body = r.json()
        if r.status_code == 200 and not (isinstance(body, dict) and body.get("error")):
            return body
        wait = (body.get("error") or {}).get("retryMs", 2000) / 1000
        time.sleep(wait + 0.3)
    raise RuntimeError(f"{path} kept rate-limiting")


def find_tournament(name):
    for t in api("tournaments", sportId=NFL_SPORT_ID):
        if t["tournamentName"] == name:
            return t


nfl = find_tournament("NFL")
print(nfl["tournamentId"], nfl["futureFixtures"])   # 31 105

Rate limits are per endpoint and return a real HTTP 429 with a retryMs value in the body. Sleep about a second between calls to the same endpoint and honour retryMs when you see it. Do not thread these calls. Firing twelve concurrent requests at /odds gets eleven of them rejected.

Step 2: Pull the schedule

The from and to parameters accept a maximum ten-day span. Filter on hasOdds so you never waste an odds call on a game nobody has priced.

def nfl_games(start, end):
    games = api("fixtures", sportId=NFL_SPORT_ID, **{"from": start, "to": end})
    return [g for g in games
            if g.get("hasOdds") and g.get("tournamentName") == "NFL"]


games = nfl_games("2026-09-05", "2026-09-14")
g = games[0]
print(g["participant1Name"], "v", g["participant2Name"], g["fixtureId"])
# Seattle Seahawks v New England Patriots id1400003171515752

Filtering on tournamentName matters in September, when the same sport ID also carries 551 NCAA fixtures and the CFL.

Step 3: Parse the moneyline

The odds payload nests four levels deep: bookmaker, market, outcome, then a players dict holding the price. On game lines the players key is always "0".

def prices(odds, market_id):
    """{bookmaker: {outcome_id: price_object}} for one market."""
    out = {}
    for slug, book in odds.get("bookmakerOdds", {}).items():
        market = book.get("markets", {}).get(str(market_id))
        if not market:
            continue
        row = {}
        for outcome_id, outcome in market["outcomes"].items():
            p = outcome["players"].get("0")
            if p and p.get("active") is not False:
                row[outcome_id] = p
        if row:
            out[slug] = row
    return out


def american(dec):
    return f"+{round((dec - 1) * 100)}" if dec >= 2 else f"{round(-100 / (dec - 1))}"


odds = api("odds", fixtureId=g["fixtureId"])
for slug, row in sorted(prices(odds, 141).items(), key=lambda kv: kv[1]["141"]["price"]):
    p1, p2 = row["141"]["price"], row["142"]["price"]
    print(f"{slug:18} {american(p1):>5} / {american(p2)}")

Real output from Seahawks at Patriots, fifteen books deep:

betparx              -233 / +185
ballybet             -233 / +185
fourwinds            -233 / +185
betrivers            -233 / +180
hardrockbet          -210 / +165
pinnacle             -206 / +179
bet365               -200 / +165
betmgm               -200 / +165
borgata              -200 / +165
caesars              -200 / +166
williamhill          -200 / +166
pointsbet.com.au     -200 / +165
draftkings           -192 / +160
circasports          -190 / +165
kalshi               -186 / +178

Two things to take from that list. Seattle ranges from -233 to -186 across fifteen books, so shopping the same side is worth roughly 7.6% on price. And those fifteen slugs are only nine independent opinions: betparx, ballybet and fourwinds quote identical numbers, as do betmgm and borgata, and caesars and williamhill. Dedupe on the price tuple before you average anything or you will triple-weight one trading desk.

The feed also ships priceAmerican and priceFractional on every outcome, so the converter above is optional. Write it once if you want the control, skip it if you do not.

Step 4: Spreads, and why hardcoding market IDs fails

NFL spreads live in Handicap markets, and every line is a separate market ID. There is no fixed ID for “the spread”. A guide that tells you to fetch market 1076 for -3.5 is quoting soccer IDs and will hand you an empty dict on every NFL game.

Build the index from /markets instead, then ask the fixture which lines are actually quoted:

def market_index(sport_id):
    catalog = api("markets", sportId=sport_id)
    by_id = {m["marketId"]: (m["marketName"], m.get("handicap")) for m in catalog}
    outcomes = {(m["marketId"], o["outcomeId"]): o["outcomeName"]
                for m in catalog for o in m.get("outcomes", [])}
    return by_id, outcomes


def find_market(odds, by_id, name):
    """Which lines are on the board, and how many books quote each."""
    hits = {}
    for slug, book in odds.get("bookmakerOdds", {}).items():
        for mid in book.get("markets", {}):
            label, handicap = by_id.get(int(mid), ("", None))
            if label == name:
                hits.setdefault((int(mid), handicap), []).append(slug)
    return dict(sorted(hits.items(), key=lambda kv: -len(kv[1])))


by_id, outcome_names = market_index(NFL_SPORT_ID)
for (mid, line), books in find_market(odds, by_id, "Handicap (incl. overtime)").items():
    print(f"market {mid:<7} line {line:>6}  {len(books)} books")
market 14272   line   -3.5  7 books
market 14270   line     -4  3 books
market 14302   line      4  3 books
market 14300   line    3.5  2 books
market 14268   line   -4.5  2 books
market 14244   line  -10.5  1 books
market 14266   line     -5  1 books
market 14262   line     -6  1 books
market 14274   line     -3  1 books
market 14264   line   -5.5  1 books

The consensus line is -3.5 on seven books. Everything below it is Pinnacle alone, walking its alternate ladder out to -10.5. The book with the most lines on the board is the one that will take a bet on any of them, which tells you something before you have priced anything yourself.

Sort by book count and take the top entry and you have the main line, without hardcoding a thing. That pattern survives a season of line moves.

Step 5: Totals work the same way

for (mid, line), books in find_market(odds, by_id, "Total (incl. overtime)").items():
    if len(books) >= 2:
        print(f"market {mid:<7} total {line:>6}  {len(books)} books")

# market 1464    total   44.5  12 books   <- main line
# market 1466    total     45   5 books
# market 1432    total   36.5   3 books
# market 1460    total   43.5   3 books

Market 1464 carries Over as outcome 1464 and Under as 1465. Line shop it and the spread across books is real money:

totals = prices(odds, 1464)
for side, label in (("1464", "Over 44.5"), ("1465", "Under 44.5")):
    quotes = sorted(((s, r[side]["price"]) for s, r in totals.items() if side in r),
                    key=lambda x: -x[1])
    print(f"{label}: best {quotes[0][1]} @ {quotes[0][0]}, worst {quotes[-1][1]} @ {quotes[-1][0]}")

# Over 44.5:  best 2.02 @ pinnacle,  worst 1.9 @ draftkings
# Under 44.5: best 1.91 @ betmgm,    worst 1.819 @ pinnacle

Pinnacle is the best price on the Over and the worst on the Under, which is what a low-margin book looks like when its number sits slightly off the retail consensus. Taking the best of each side across books beats any single book on both. For the full version of this across every market, see our guide to line shopping in Python.

Step 6: Measure the vig

Sum the inverse decimal prices on both sides. Anything above 1.0 is the book’s margin.

ml = prices(odds, 141)
for slug in ("pinnacle", "draftkings", "bet365", "caesars"):
    if slug in ml:
        p1, p2 = ml[slug]["141"]["price"], ml[slug]["142"]["price"]
        print(f"{slug:12} vig {(1/p1 + 1/p2 - 1) * 100:.2f}%")

# pinnacle     vig 3.18%
# draftkings   vig 4.25%
# caesars      vig 4.26%
# bet365       vig 4.40%

Strip that margin out and you get the market’s honest probability, which is the benchmark any model has to beat. Three ways to do it are in our no-vig odds guide.

Step 7: Check the limit before you trust the price

Pinnacle and the exchanges publish a limit on every outcome, which is the maximum they will accept. It doubles as a confidence signal, and in July the NFL numbers are small:

Market Price Pinnacle limit
Moneyline, Seattle 1.485 $1,546
Moneyline, New England 2.79 $750
Spread -3.5, Seattle 1.877 $1,710
Spread -3.5, New England 1.99 $1,515

Pinnacle will take more than twice as much on the spread as on the moneyline, and it caps a live MLB moneyline at eight thousand dollars while capping this one at fifteen hundred. Six weeks out, the sharpest book in the market is telling you it does not trust its own September number yet. Those limits climb as kickoff approaches. US retail books return null here, because their maximum is set per account rather than per market, so null-check before you do arithmetic.

Step 8: Historical odds and closing line value

Every price the feed has ever seen is retrievable through /historical-odds, on the free tier. Competitors charge for this or do not offer it.

def price_history(fixture_id, book, market_id, outcome_id):
    data = api("historical-odds", fixtureId=fixture_id, bookmakers=book)
    market = data["bookmakers"][book]["markets"][str(market_id)]
    snaps = market["outcomes"][str(outcome_id)]["players"]["0"]
    return [(s["createdAt"][:16], s["price"], s.get("limit")) for s in snaps]

Note the shape change: the live endpoint keys on bookmakerOdds and gives you one price, the historical endpoint keys on bookmakers and gives you a list of snapshots. Mixing them up is the second most common NFL parsing bug after the market IDs. The endpoint takes a maximum of three bookmakers per call, so loop and merge for wider coverage. From there you have opening lines, closing lines, and everything between, which is all you need to grade your bets against the close or export a season to CSV for backtesting.

Player props: where things stand

The NFL prop markets exist in the catalog, including Player To Score TD (market 14388) and Player To Score First TD (14390). Pricing is a different question. Across six Week 1 games sampled in late July, DraftKings had an anytime touchdown market on two of them and no other book had posted a single prop.

That is normal. Prop menus fill in as kickoff approaches, so a scan six weeks out reads empty and the same scan on game day returns a full board. When they do arrive, the parse changes: on prop markets the players dict is keyed by player ID rather than "0", with a playerName on each entry in “Last, First” format. Hardcode players["0"] and every prop market will look empty to you. Our player props API guide has the full pattern.

College football

Same sport ID, same code, different tournament. NCAA carried 51 priced fixtures for the 5 to 14 September window when we checked, across 14 books, with up to 90 markets on the bigger matchups. Pinnacle had not posted yet and SBOBet was the only sharp on the board, so treat early college numbers as retail consensus rather than a sharp reference. Swap the filter and everything else in this guide runs unchanged:

ncaa = [g for g in api("fixtures", sportId=14, **{"from": "2026-09-05", "to": "2026-09-14"})
        if g.get("hasOdds") and g["tournamentName"].startswith("NCAA")]

Frequently Asked Questions

What is the market ID for NFL spreads?

There is no single one. Each spread line is its own market ID, so -3.5 and -4 are different markets. On our Week 1 test game, -3.5 was market 14272 and -4 was 14270. Query /markets?sportId=14, match on the market name “Handicap (incl. overtime)”, and pick the line the most books are quoting rather than hardcoding an ID.

Which bookmakers does the NFL odds API cover?

A Week 1 fixture returned 16 books: Pinnacle, SBOBet, DraftKings, BetMGM, Caesars, Bet365, BetRivers, William Hill, Circa Sports, HardRock Bet, PointsBet, Kalshi, plus several regional US skins. The wider catalogue runs to 381 bookmakers, though any single fixture carries a subset.

Is NFL player prop data available?

The markets exist year round, but books post prop menus close to kickoff. In late July only DraftKings had posted an anytime touchdown market on Week 1 games. Expect a full board in the days before each game rather than weeks out.

How do I get historical NFL odds for backtesting?

Call /historical-odds with a fixture ID and up to three bookmakers. It returns timestamped snapshots, including the limit at each timestamp, so you can reconstruct opening lines, closing lines and how the market moved. It is included in the free tier.

Does the API include NFL scores or player stats?

No. The feed carries schedules, fixture status and odds. For results and box scores you will need a separate stats provider, which you can join on the IDs in the externalProviders object on every fixture.

Start building

You now have working code that finds the NFL, pulls the schedule, parses moneylines across fifteen books, discovers whichever spread and total lines are actually on the board, measures the vig, reads the sharp limits and reconstructs price history. None of it needs a sales call.

Get your free API key and have the preseason board on your screen before August.