NBA Alternate Lines API: Sort 722 Rungs by the Handicap Field

NBA Alternate Lines API header
How To Guides September 24, 2026

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.