NFL Alternate Lines API: Find the 295 Markets You Can Price-Check
Pull the NFL Week 1 opener from OddsPapi with no bookmaker filter and count the markets. You get 1,679 distinct market IDs from 169 bookmakers: spreads from -24.5 to +19.5, totals from 30.5 to 68.5, team totals, first-half lines, and a separate handicap and total ladder for each of the four quarters.
Now count the ones you can actually bet. 414 have a single active price. 295 have a two-sided active quote from three or more books. That is 17.6% of the headline number. The other 82% is a menu the books have posted and suspended.
This post is about the gap between those two numbers, and about the code that tells them apart. Everything below is measured live on all 16 NFL Week 1 fixtures, 422,141 prices, on the free tier.
The active rate falls off a cliff, family by family
Across the full 16-fixture slate, 29.2% of 422,141 prices are active. That average hides the real structure. Sorted by how far a market sits from the moneyline:
| Market family | Prices | Active | Books present | Market IDs |
|---|---|---|---|---|
| Winner (incl. overtime) | 4,980 | 98.3% | 164 | 1 |
| Total (incl. overtime) | 74,653 | 67.2% | 155 | 90 |
| Handicap (incl. overtime) | 67,475 | 59.7% | 149 | 101 |
| Over Under Team 1 (incl. overtime) | 35,698 | 22.1% | 91 | 120 |
| Over Under First Half | 24,634 | 4.8% | 79 | 120 |
| Handicap First Half | 19,402 | 4.0% | 37 | 121 |
| Over Under First Quarter | 18,574 | 2.9% | 64 | 80 |
| Handicap First Quarter | 9,790 | 6.6% | 71 | 81 |
| Over Under Fourth Quarter | 18,098 | 0.9% | 21 | 80 |
| Handicap Fourth Quarter | 9,242 | 1.0% | 19 | 81 |
The moneyline is 98.3% live. The fourth-quarter handicap is 1.0% live. Same fixtures, same books, same payload, two weeks before kick-off.
This is not a coverage problem, it is a timing one. Books load the whole derivative menu early and switch almost all of it off until the game is close. If you audit NFL market coverage a fortnight out and report a market count, you will report a number that is roughly five times what a bettor can touch.
Rule 1: never hardcode a market ID
American football has one market ID per line. There is no single “spread” or “totals” ID. On the opener, spread -3.5 is 14272 and -4 is 14270; total 44.5 is 1464 and 45 is 1466. The full-game handicap family alone spans 101 distinct market IDs and the totals family 90.
So resolve by name against the catalogue and collect every ID that maps to the family you want. Note that sportId on /v4/markets is a no-op: the catalogue is global (32,815 rows), not per sport, so use it purely as a lookup and read the live IDs off the /odds payload.
import requests, time
API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"
def get(path, **params):
"""One retry on the documented 429 body, which carries retryMs."""
for _ in range(5):
params["apiKey"] = API_KEY
r = requests.get(f"{BASE_URL}/{path}", params=params)
if r.status_code == 429:
time.sleep(r.json()["error"].get("retryMs", 1500) / 1000 + 1.2)
continue
return r.status_code, r.json()
return r.status_code, {}
status, catalogue = get("markets", sportId=14)
# marketId -> (name, handicap, period). This is the only lookup you need.
META = {m["marketId"]: (m["marketName"], m.get("handicap"), m.get("period"))
for m in catalogue}
print(len(META), "market IDs in the catalogue") # 32815
Rule 2: trust only the price-level active flag
Three fields look like liveness flags and only one is. marketActive sits on the market object, bookmakerIsActive and suspended sit on the book object, and all three disagree with their own prices often enough to break a parser. The flag that matters lives on the price object at players["0"], one level below the outcome.
Two more filters belong in the same pass. Drop the internal test feeds — pinnacle+30 and pinnacle+5 quote the identical number to pinnacle, so an unfiltered count includes the sharp three times. And on game lines, prices key on "0"; player-keyed entries are prop markets and there are currently none on the NFL board.
import collections
OPENER = "id1400003171515752" # Seahawks v Patriots, 10 Sep 2026
status, odds = get("odds", fixtureId=OPENER)
rows = []
for slug, book in odds["bookmakerOdds"].items():
if slug.startswith("pinnacle+") or slug == "demo":
continue
for market_id, market in book.get("markets", {}).items():
name, handicap, period = META.get(int(market_id), ("?", None, None))
for outcome_id, outcome in market["outcomes"].items():
price = outcome["players"].get("0")
if not price or not price.get("price"):
continue
rows.append({"book": slug, "market_id": market_id, "family": name,
"handicap": handicap, "outcome": outcome_id,
"price": price["price"], "active": price.get("active"),
"main_line": price.get("mainLine"), "limit": price.get("limit")})
live = [r for r in rows if r["active"]]
print(len(rows), "prices ->", len(live), "active")
# 46008 prices -> 17764 active (38.6%) -- live counts move minute to minute
print(len({r["market_id"] for r in rows}), "market IDs ->",
len({r["market_id"] for r in live}), "with an active price")
# 1679 market IDs -> 414 with an active price
Rule 3: a rung only counts if both sides are live
A one-sided quote cannot be de-vigged and cannot be compared to anything. Group by (book, handicap) inside a family and keep only the pairs.
def ladder(rows, family):
"""{(book, handicap): {outcome_id: row}} for two-sided ACTIVE rungs."""
rungs = collections.defaultdict(dict)
for r in rows:
if r["family"] != family or not r["active"]:
continue
rungs[(r["book"], r["handicap"])][r["outcome"]] = r
return {k: v for k, v in rungs.items() if len(v) == 2}
def margin(rung):
return (sum(1 / r["price"] for r in rung.values()) - 1) * 100
spreads = ladder(rows, "Handicap (incl. overtime)")
totals = ladder(rows, "Total (incl. overtime)")
print(len(spreads), "two-sided spread rungs,", len(totals), "total rungs")
# 2467 two-sided spread rungs, 3229 total rungs (this fixture)
That is 2,467 spread rungs and 3,229 total rungs on the opener alone. Across all 16 fixtures it comes to 19,790 two-sided active spread rungs and 24,895 total rungs. That is the real board.
The board splits into ladder-walkers and single-line books
On the opener, 41 of 135 books quoting a two-sided spread quote exactly one rung and 94 walk a ladder. Across the full slate the balance flips: 92 of 146 books show a median of exactly one spread rung. Books open a wider menu on the marquee game than on the Sunday slate around it. Some of those ladders are very long.
| Book | Spread rungs (median) | Total rungs (median) | Widest span seen |
|---|---|---|---|
betnacional |
28 | 61 | totals 30.0 to 59.5 |
draftkings |
44 | 39.5 | spreads -24.5 to +19.5 |
jazzsports |
44 | 39.5 | spreads -24.5 to +19.5 |
betsson |
45 | — | spreads, 13 of 16 fixtures |
betano (+6 skins) |
40 | 38 | — |
kalshi |
25 | 16 | spreads -20.5 to +14.5 |
pinnacle |
9 | 13 | spreads -10.5 to +3.5 |
circasports |
1 | 1 | the consensus line only |
The split matters because it changes what a book is telling you. DraftKings quoting 43 rungs from -24.5 to +19.5 is a retail product: most of those rungs will never be bet. Pinnacle quoting 9 rungs around the number is a trading position. Circa quoting exactly one rung is a statement about where the line is.
Pinnacle’s ladder is U-shaped, and its limit ranks the rungs
On the opener, Pinnacle’s spread margin is tightest at 2.88% on -3.5, the consensus line, and widens toward both wings. Its totals ladder bottoms out at 3.86% on 44.5, again the consensus number.
Pinnacle also publishes limit, which almost no other sportsbook does. It is a capped max win, not a capped stake, so recover the underlying base with limit = max(base, base / (price - 1)). On the opener that returns an implied base of $2,250 on the spread ladder and $1,500 on totals — the book will take 50% more on a spread than on a total, which is its own ranking of the two markets.
def implied_base(row):
"""Pinnacle's limit is a capped max win. Recover the per-market base."""
lim = row.get("limit")
if lim is None:
return None
return lim if row["price"] >= 2 else lim * (row["price"] - 1)
The mainLine flag is wrong more often than it is right
Every price object carries mainLine, and it is tempting to use it to find the headline number. Measured against the consensus line across all 16 fixtures:
| Family | Flags set | Matching the consensus line |
|---|---|---|
| Handicap (incl. overtime) | 5,245 | 46.1% |
| Total (incl. overtime) | 6,025 | 35.7% |
The failures are not spread evenly. 1xbet and 22bet score 0 for 34 on spreads, all seven betano skins score 0 for 40, and bcgame 0 for 33 — these books set the flag on a rung that is never the line. At the other end 7bet.lt, betinia.dk, estrelabet, fezbet and winpot.mx all land 28 of 32. A flag that is reliable at one book and never right at another is not a flag you can build on.
This is the fourth sport where the same measurement fails. It runs 41% on NFL team totals, 17% on rugby, 8.6% on tennis. Treat mainLine as decoration.
“Closest to even money” does not work either
The obvious replacement is to take each book’s most balanced rung. On its own that misfires badly, because books leave stale wing rungs priced near even. On the opener, SBOBet’s tightest total is 71.5, which is 27 points above the real number, and Kalshi’s tightest spread is -10.5 against a -3.5 line. Bet365 quotes exactly one total on the whole fixture and it is 37.0, seven and a half points off consensus.
What does work: balanced rung per book, then the mode
Take each book’s most balanced rung, then take the mode across all books. The individual errors above get outvoted.
def consensus_line(rungs):
"""Each book's most balanced rung, then the mode across books."""
by_book = collections.defaultdict(list)
for (book, handicap) in rungs:
by_book[book].append(handicap)
picks = []
for book, handicaps in by_book.items():
def imbalance(h):
legs = sorted(rungs[(book, h)])
a, b = rungs[(book, h)][legs[0]], rungs[(book, h)][legs[1]]
return abs(1 / a["price"] - 1 / b["price"])
picks.append(min(handicaps, key=imbalance))
line, votes = collections.Counter(picks).most_common(1)[0]
return line, votes, len(picks)
print(consensus_line(spreads)) # (-3.5, 131, 135)
print(consensus_line(totals)) # (44.5, 121, 144)
Across the slate that resolver reaches a median 84% agreement on spreads and 81% on totals. Week 1 NFL sides and totals have converged hard: on the opener 131 of 135 books agree the spread is -3.5. Compare tennis, where the same method tops out around 71%.
The wing tax, and the whole-number discount that is not a discount
Margin rises as you walk away from the consensus line, which is what you would expect. Spreads run 4.76% at the line and settle around 6.5–6.9% once you are a few points out; totals go 4.77% to 7.24%.
But the pattern has a saw-tooth in it, and the reason is worth knowing. Split every rung by whether the handicap is a whole number or a half:
| Family | Half-point rungs | Whole-number rungs |
|---|---|---|
| Handicap (incl. overtime) | 7.14% (n=16,186) | 6.37% (n=3,604) |
| Total (incl. overtime) | 6.98% (n=19,689) | 6.43% (n=5,206) |
Whole-number rungs look about 0.6pp cheaper. They are not. A whole number can push, so the book keeps a share of the handle without paying out, and it prices that into a lower nominal margin. Sorting an alt-line scan by margin puts every whole-number rung at the top and it is an artefact.
Filter whole handicaps out before de-vigging, or compare them only against each other. On NFL specifically no rung in the sample summed below 100%, so there are no false arbitrage signals here — but the ranking is still wrong.
What you should actually pull
A working screen for alt lines on an NFL board, two weeks out:
def tradeable(rungs, min_books=3):
"""Handicaps with a two-sided active quote from at least min_books books."""
votes = collections.defaultdict(set)
for (book, handicap) in rungs:
votes[handicap].add(book)
return {h: bs for h, bs in votes.items() if len(bs) >= min_books}
line, _, _ = consensus_line(spreads)
for h, books in sorted(tradeable(spreads).items()):
quotes = [margin(spreads[(b, h)]) for b in books]
flag = " <-- consensus" if h == line else ""
print(f"{h:>7} {len(books):>3} books median margin {sorted(quotes)[len(quotes)//2]:5.2f}%{flag}")
That is the difference between “the NFL board has 1,679 markets” and “the NFL board has 295 markets I can price-check against three independent books”. Build against the second number.
Old way vs OddsPapi
| Scraping sportsbooks | OddsPapi | |
|---|---|---|
| Alt-line ladders | One scraper per book, breaks weekly | 169 books, one JSON call |
| Quarter and half markets | Usually behind a separate tab or endpoint | Same payload, resolved by marketName |
| Suspended lines | Rendered the same as live ones | Explicit active flag per price |
| Stake ceilings | Not exposed | limit on Pinnacle and every exchange |
| History | Build it yourself from day one | Free tier, retained for months |
Where to go next
- NFL odds API guide — moneylines, spreads and totals from a standing start.
- NFL team totals API — derive team totals from the spread and total when only two books quote them directly.
- NFL key numbers — what a half point around 3 and 7 is actually worth.
- NFL schedule API — pull the full season, and the two date traps that break a season loop.
- NFL prediction market liquidity — the same board, measured on depth instead of breadth.
- College football odds API — where the suspended-board problem is even worse.
- Middle bets in Python — the main reason to walk an alt-line ladder in the first place.
- Vig calculator in Python — the margin maths used throughout this post.
- Line shopping in Python — best price across the full board.
- Betting limits API — how far the
limitfield goes.
Count what you can bet, not what you can see
A market count is the easiest number to publish and the least useful one to build on. The board that matters on an NFL Sunday is the subset that is two-sided, active, and quoted by enough independent books to check. On OddsPapi that subset is one active filter and one group-by away, across 350+ bookmakers including Pinnacle, SBOBET, Circa, DraftKings and Kalshi, with free historical odds behind it.
Get your free API key and run the ladder walk on this week’s slate.