NFL Alternate Lines API: Find the 295 Markets You Can Price-Check

NFL Alternate Lines - OddsPapi API Blog
How To Guides September 7, 2026

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

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.