NBA Alternate Lines API: Sort 722 Rungs by the Handicap Field
You want every NBA spread and total, not just the one the book shows on its front page. OddsPapi ships them as separate market IDs: 401 rungs on the full-game total and 321 on the full-game handicap, 722 in all. The line itself lives in the handicap field of /v4/markets, never on the price.
Sort those rungs by marketId and you get a ladder that is out of order. Measured on 15 September 2026 at 13:14 UTC, the market ID order and the line order agree on 81 of the 401 total rungs and 80 of the 321 spread rungs. Everything below comes from live calls made that afternoon on the free tier.
Why the Market ID Is Not the Line
OddsPapi assigns market IDs in blocks, and it added those blocks at different times. On the NBA total ladder the half-point rungs from 100.5 to 259.5 sit in one block of five-digit IDs. Every whole-point rung, plus the half points above 259.5, sits in a six-digit block that starts at 112878. Walk the ladder by ID and it climbs 100.5, 101.5, 102.5 all the way to 259.5, then drops back to 100.0 and climbs again.
The spread ladder splits three ways. IDs 11356 to 11556 hold the rungs from -25.0 to +25.0. The outer rungs sit above 113360. An ID walk jumps backwards twice: from +25.0 to -40.0, then from +40.0 to -80.0.
| Line | Market ID | Outcome array as shipped |
|---|---|---|
| 220.0 | 113118 | Under, Over |
| 220.5 | 11246 | Over, Under |
| 221.0 | 113120 | Over, Under |
| 221.5 | 11248 | Over, Under |
| 222.0 | 113122 | Under, Over |
| 222.5 | 11250 | Over, Under |
| 223.0 | 113124 | Over, Under |
| 223.5 | 11252 | Under, Over |
Two traps in one table. The IDs alternate between blocks as the line steps by half a point, and the outcome array order changes with it. Across all 401 total rungs, 198 list Over first and 203 list Under first. On the spread ladder, 163 rungs list participant 1 first and 158 list participant 2 first. Read the label, never the array position.
One more: all 4,902 basketball market definitions reuse the market ID as one of their own outcome IDs. Market 11248 has outcome 11248. Key a dictionary on the bare integer and the total ladder collides with itself.
Old Way vs OddsPapi
| Task | Scraping a sportsbook | OddsPapi |
|---|---|---|
| Alt spread and total rungs | Render the alt-lines drawer, parse it per book | 722 rungs from one /v4/markets call |
| Line value | Regex the label text | handicap, typed as a float |
| Cross-book comparison | Rebuild each book’s ladder by hand | One /v4/odds call, 220 books keyed by slug |
| Historical rungs | Gone once the fixture settles | /v4/historical-odds, free on every tier |
| Live updates | Re-poll the whole drawer | WebSocket push on wss://v5.oddspapi.io/ws |
Step 1: Authenticate and Pull the Ladder
The key travels as a query parameter. It is never a header.
import os, requests
API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE = "https://api.oddspapi.io/v4"
BASKETBALL = 11
defs = requests.get(f"{BASE}/markets", params={"apiKey": API_KEY}).json()
hoops = [m for m in defs if m.get("sportId") == BASKETBALL]
print(f"basketball market definitions: {len(hoops)}")
totals = [m for m in hoops if m["marketName"] == "Over Under (incl. overtime)"]
spreads = [m for m in hoops if m["marketName"] == "Handicap (incl. overtime)"]
print(f"total rungs: {len(totals)} spread rungs: {len(spreads)}")
by_id = sorted(totals, key=lambda m: m["marketId"])
by_line = sorted(totals, key=lambda m: m["handicap"])
print("sorted by marketId:", [m["handicap"] for m in by_id[:5]])
print("sorted by handicap:", [m["handicap"] for m in by_line[:5]])
agree = sum(1 for a, b in zip(by_id, by_line) if a["marketId"] == b["marketId"])
print(f"positions where the two orders agree: {agree} of {len(totals)}")
basketball market definitions: 4902
total rungs: 401 spread rungs: 321
sorted by marketId: [100.5, 101.5, 102.5, 103.5, 104.5]
sorted by handicap: [100.0, 100.5, 101.0, 101.5, 102.0]
positions where the two orders agree: 81 of 401
/v4/markets ignores a sportId parameter and returns all 33,115 definitions, so filter client-side. That quirk also bites on the EuroLeague board, where the match result ships under two market IDs.
Step 2: Resolve the Ladder Once, Reuse It Everywhere
Build the ladder in line order and carry the outcome labels with it. Then nothing downstream needs to know about ID blocks.
import os, time, requests
API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE = "https://api.oddspapi.io/v4"
def get(path, **params):
"""One call, with the rate-limit retry the docs ask for."""
for _ in range(4):
r = requests.get(f"{BASE}{path}", params={"apiKey": API_KEY, **params})
if r.status_code == 429:
time.sleep(r.json().get("retryMs", 2000) / 1000)
continue
r.raise_for_status()
return r.json()
raise RuntimeError(f"rate limited on {path}")
def load_ladder(sport_id, market_name):
"""Return the rungs of one ladder, in line order, with their outcome labels."""
rungs = [m for m in get("/markets")
if m.get("sportId") == sport_id and m["marketName"] == market_name]
rungs.sort(key=lambda m: m["handicap"])
return [{
"line": m["handicap"],
"market_id": m["marketId"],
# Match on the NAME. The array order is not stable across rungs.
"outcomes": {o["outcomeName"]: o["outcomeId"] for o in m["outcomes"]},
} for m in rungs]
totals = load_ladder(11, "Over Under (incl. overtime)")
spreads = load_ladder(11, "Handicap (incl. overtime)")
print(f"{len(totals)} total rungs, {len(spreads)} spread rungs")
for r in totals:
if 220.0 <= r["line"] <= 222.0:
print(f" line {r['line']:6.1f} market {r['market_id']:<7} outcomes {r['outcomes']}")
401 total rungs, 321 spread rungs
line 220.0 market 113118 outcomes {'Under': 113119, 'Over': 113118}
line 220.5 market 11246 outcomes {'Over': 11246, 'Under': 11247}
line 221.0 market 113120 outcomes {'Over': 113120, 'Under': 113121}
line 221.5 market 11248 outcomes {'Over': 11248, 'Under': 11249}
line 222.0 market 113122 outcomes {'Under': 113123, 'Over': 113122}
The dictionary keys tell you the flip is real. Line 220.0 hands you Under first, 220.5 hands you Over first. Position-based parsing writes the Under price into the Over column on roughly half the ladder. The CS2 map handicap does the same thing across its 15 market IDs.
Step 3: Read a Live Board
Opening night, Detroit at Boston, fixture id1100013273682354. One unfiltered call returned 220 bookmakers and 531 distinct market IDs across 9.2 MB of JSON.
FIXTURE = "id1100013273682354" # Detroit v Boston, NBA opening night
ladder = {m["marketId"]: m["handicap"] for m in get("/markets")
if m.get("sportId") == 11 and m["marketName"] == "Handicap (incl. overtime)"}
board = get("/odds", fixtureId=FIXTURE)
books = board["bookmakerOdds"]
print(f"{len(books)} bookmakers on the board")
posted, tradeable = {}, {}
for slug, book in books.items():
for market_id, market in book["markets"].items():
line = ladder.get(int(market_id))
if line is None:
continue
legs = [o["players"].get("0") for o in market["outcomes"].values()]
legs = [p for p in legs if p]
if len(legs) != 2:
continue
posted.setdefault(line, set()).add(slug)
live = (not book["suspended"]
and market.get("marketActive")
and all(p["active"] and (p["price"] or 0) > 1.0 for p in legs))
if live:
tradeable.setdefault(line, set()).add(slug)
print(f"spread rungs with a posted price: {len(posted)} of {len(ladder)}")
top = sorted(posted, key=lambda l: -len(posted[l]))[:5]
for line in top:
print(f" {line:+6.1f} {len(posted[line]):3d} books posted, "
f"{len(tradeable.get(line, [])):3d} tradeable")
220 bookmakers on the board
spread rungs with a posted price: 321 of 321
-1.5 190 books posted, 88 tradeable
-1.0 63 books posted, 36 tradeable
-2.5 59 books posted, 8 tradeable
-3.5 58 books posted, 8 tradeable
+1.5 44 books posted, 6 tradeable
The spread ladder was complete: all 321 rungs from -80.0 to +80.0 carried a price. The total ladder carried 189 rungs, spanning 100.5 to 259.5. Together that is 510 of the 722 catalogue rungs on a single fixture, 35 days before tip-off.
Three flags decide whether a rung is tradeable. suspended sits on the bookmaker, marketActive on the market, and active on the individual price. Drop any one of the three and the leaderboard changes, which is the same lesson the opening-night board study ran into on the moneyline.
Ladder Width Splits the Board in Two
The median bookmaker posts two spread rungs. A cohort of crypto and offshore sportsbooks posts the whole thing: 500.casino, bcgame, betfury and betify each shipped all 321 spread rungs, and gamdom shipped 163 total rungs. US retail runs narrow at this range, with draftkings, bet365 and caesars on three total rungs apiece.
pinnacle, betfair-ex, polymarket and circasports had no NBA price at all 35 days out. Test for it rather than guessing, because the API tells you which case you are in:
| Call | Response | Meaning |
|---|---|---|
bookmakers=notarealbook |
400 INVALID_PARAMETER |
The slug does not exist |
bookmakers=<gated slug> |
403 | Your key cannot read it |
bookmakers=pinnacle |
200, slug absent from the payload | The book has not opened this fixture |
Sharp books open NBA sides late and deep. On a played fixture they carry the full ladder, which Step 5 shows.
Step 4: Resolve the Main Line Without Trusting mainLine
Every price carries a mainLine boolean. On the spread it behaves: 195 books set it and only 2 set it on more than one rung. On the total it does not. 199 books set mainLine on a total rung and 97 of them set it on more than one. bet365 flagged 221.5, 222.5 and 223.5 on the same fixture.
Resolve the main line yourself instead. Take each book’s most balanced rung inside a sane price band, then take the mode across books.
import collections, statistics
ladder = {m["marketId"]: m["handicap"] for m in get("/markets")
if m.get("sportId") == 11 and m["marketName"] == "Handicap (incl. overtime)"}
books = get("/odds", fixtureId=FIXTURE)["bookmakerOdds"]
mains, quotes = {}, {}
for slug, book in books.items():
if "+" in slug or slug in {"demo", "singbet-b"}:
continue
best = None
for market_id, market in book["markets"].items():
line = ladder.get(int(market_id))
if line is None or book["suspended"] or not market.get("marketActive"):
continue
legs = [o["players"].get("0") for o in market["outcomes"].values()]
legs = [p for p in legs if p and p["active"] and (p["price"] or 0) > 1.0]
if len(legs) != 2:
continue
prices = sorted(p["price"] for p in legs)
if not (1.5 <= prices[0] and prices[1] <= 2.6):
continue
balance = prices[1] - prices[0]
if best is None or balance < best[0]:
best = (balance, line, tuple(prices))
if best:
mains[slug], quotes[slug] = best[1], best[2]
mode, count = collections.Counter(mains.values()).most_common(1)[0]
print(f"{len(mains)} books resolved, main line {mode:+.1f} on {count} of them")
on_mode = {s: q for s, q in quotes.items() if mains[s] == mode}
margin = {s: (sum(1 / p for p in q) - 1) * 100 for s, q in on_mode.items()}
print(f"median margin on {mode:+.1f}: {statistics.median(margin.values()):.2f}%")
tuples = collections.Counter(on_mode.values())
print(f"{len(on_mode)} quotes collapse to {len(tuples)} distinct prices "
f"({100 * (1 - len(tuples) / len(on_mode)):.1f}%)")
for slug, m in sorted(margin.items(), key=lambda kv: kv[1])[:5]:
print(f" {slug:<16} {m:5.2f}% {on_mode[slug]}")
91 books resolved, main line -1.5 on 88 of them
median margin on -1.5: 5.83%
88 quotes collapse to 18 distinct prices (79.5%)
duel 3.09% (1.94, 1.94)
atg.se 4.71% (1.91, 1.91)
ballybet 4.71% (1.91, 1.91)
betcity.nl 4.71% (1.91, 1.91)
betmgm.co.uk 4.71% (1.91, 1.91)
The dedupe step matters more than the ranking. Those 88 quotes on -1.5 carry 18 distinct price pairs, a 79.5% collapse, and the largest cluster is 26 slugs quoting 1.91 both ways. The main total behaves the same: 142 tradeable books on 221.5, 27 distinct pairs, 81.0% collapse, 27 slugs sharing 1.90 / 1.92. cloneOf does not predict which slugs land in a cluster, so dedupe on the price tuple before you build a consensus.
Margins on the main rungs ran 6.40% median on the 221.5 total and 5.83% on the -1.5 spread. 1xbet was tightest on the total at 2.07%, duel tightest on the spread at 3.09%. Among the US names, draftkings, caesars and hardrockbet all sat at 4.77% on the total and fanduel at 4.76%, with bet365 at 7.53%. That spread across the field is what makes line shopping across the full catalogue worth the extra call.
Step 5: Pull the Ladder From a Played Fixture
Historical odds are free on every tier, and /v4/historical-odds keeps every rung a book ever posted. Lakers at Thunder on 8 April 2026, fixture id1100013262924355:
PLAYED = "id1100013262924355" # Lakers v Thunder, 8 April 2026
ladder = {m["marketId"]: m["handicap"] for m in get("/markets")
if m.get("sportId") == 11 and m["marketName"] == "Over Under (incl. overtime)"}
for slug in ["pinnacle", "draftkings"]:
book = get("/historical-odds", fixtureId=PLAYED, bookmakers=slug)["bookmakers"][slug]
rungs, snapshots, changes = {}, 0, 0
for market_id, market in book["markets"].items():
line = ladder.get(int(market_id))
if line is None:
continue
rungs[line] = market_id
for outcome in market["outcomes"].values():
for series in outcome["players"].values():
snapshots += len(series)
last = None
for snap in series:
if snap["price"] != last:
changes += 1
last = snap["price"]
lines = sorted(rungs)
print(f"{slug}: {len(lines)} total rungs, {lines[0]} to {lines[-1]}, "
f"{snapshots} snapshots, {changes} price changes")
time.sleep(4.6)
pinnacle: 69 total rungs, 200.5 to 234.5, 8195 snapshots, 6329 price changes
draftkings: 55 total rungs, 192.5 to 246.5, 15519 snapshots, 14586 price changes
Pinnacle carried 447 market IDs on that one fixture. Its busiest total rung, 223.0, moved from 1.833 / 2.009 at the open to 2.04 / 1.806 on the last active quote, and the closing limits read 3,000 on the Over and 3,722 on the Under. DraftKings ran wider on the ladder and narrower on the limit. A backtest built off either book alone learns that book’s habits, which is the argument the 176-book study makes at length.
Sleep 4.6 seconds between historical calls. The endpoint rate-limits at a lower threshold than /odds, and a 429 body is valid JSON, so a bookmakerOdds check on its own reads a rate limit as an empty board. Store the pull locally before you model on it.
The Same Trap in Four Other Ladders
The feed holds 179 ladders of 40 rungs or more. Market ID order matches line order on 173 of them. Six behave differently, and NBA owns two:
sports = {s["sportId"]: s["sportName"] for s in get("/sports")}
ladders = collections.defaultdict(list)
for m in get("/markets"):
if m.get("handicap") is not None:
ladders[(m.get("sportId"), m["marketName"])].append(m)
for (sport_id, name), rungs in ladders.items():
if len(rungs) < 40:
continue
by_id = sorted(rungs, key=lambda m: m["marketId"])
by_line = sorted(rungs, key=lambda m: m["handicap"])
agree = sum(1 for a, b in zip(by_id, by_line) if a["marketId"] == b["marketId"])
if agree < len(rungs):
print(f"{sports.get(sport_id, sport_id):<18}{name:<36}"
f"{len(rungs):4d} rungs, {agree:4d} in line order")
Tennis Participant 2 Total Games 121 rungs, 3 in line order
Tennis Participant 1 Total Games 105 rungs, 3 in line order
Basketball Over Under (incl. overtime) 401 rungs, 81 in line order
Basketball Handicap (incl. overtime) 321 rungs, 80 in line order
American Football Total (incl. overtime) 201 rungs, 51 in line order
American Football Handicap (incl. overtime) 281 rungs, 90 in line order
Run that audit against any sport before you write a parser for it. Basketball’s quarter and half ladders come out clean at 141 of 141 and 201 of 201, so the split is specific to the full-game markets. The NFL versions behave the same way, which is worth knowing before you walk the NFL alternate-line board.
Once the ladder is in line order, the rest follows. Adjacent rungs give you middles, the price gap between neighbours gives you the cost of a half point, and the same handicap-first rule resolves the NHL twin markets and NBA player props.
Stop Scraping Alt-Line Drawers
The catalogue held 360 bookmaker slugs on 15 September 2026, 285 of them flagged for live odds. The free tier reads every one of them, plus the full historical archive, plus the WebSocket stream on wss://v5.oddspapi.io/ws when polling stops being fast enough. Push beats polling on a board this wide.
Get a free API key and pull the 722 rungs yourself.
FAQ
How many alternate lines does the NBA board carry?
The basketball catalogue defines 401 full-game total rungs from 100.0 to 300.0 and 321 full-game handicap rungs from -80.0 to +80.0, in half-point steps. On the Detroit at Boston opener measured 35 days out, 510 of those 722 rungs carried a price from at least one of 220 bookmakers.
Where does OddsPapi put the line value for an alternate spread?
In the handicap field of the market definition returned by /v4/markets. Each rung is its own market ID, and the price payload carries no line value, so you join the board to the catalogue on marketId.
Can I sort alternate lines by market ID?
Not on the NBA full-game ladders. Market ID order matches line order on 81 of the 401 total rungs and 80 of the 321 spread rungs, because the rungs sit in separate ID blocks added at different times. Sort on handicap instead. Basketball’s quarter and half ladders do come out in ID order.
Is the mainLine flag reliable for NBA totals?
Treat it as a hint. On the opener, 199 bookmakers set mainLine on a total rung and 97 of them set it on more than one; bet365 flagged three rungs at once. Resolve the main line as each book’s most balanced rung inside a 1.5 to 2.6 price band, then take the mode across books.
Which bookmakers post the deepest NBA alternate-line ladder?
Crypto and offshore sportsbooks. On the opener, 500.casino, bcgame, betfury and betify each posted all 321 spread rungs, and gamdom posted 163 total rungs. The median bookmaker posted two rungs a ladder that far out, and the board widens as tip-off approaches.
Do historical odds keep the alternate lines?
Yes, and it is free on every tier. On Lakers at Thunder from 8 April 2026, /v4/historical-odds returned 69 total rungs from Pinnacle across 8,195 snapshots and 55 rungs from DraftKings across 15,519 snapshots, with the full price series on each.