NFL Odds API: Free Python Guide to Lines, Spreads & Totals (2026)
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.