NBA Prediction Market API: Pinnacle Holds 3x the Spread Depth

NBA Prediction Market API: Pinnacle Holds 3x the Spread Depth
How To Guides October 20, 2026

The NBA tips off on 20 October 2026, and six exchange venues already price the opener: Kalshi, Polymarket US, Novig, Betfair Exchange, SX Bet and Duel. On 21 September Novig, Betfair Exchange, Polymarket and ProphetX had no NBA price at all; Kalshi and SX Bet did. If you plan to trade NBA on a prediction market this season, the question is where the depth sits, because the price alone will not tell you.

We pulled every priced board for opening week, 18 fixtures from 20 to 24 October, through one OddsPapi key on 12 October at 13:13 UTC, eight days before the first tip. On the moneyline, the exchanges rival the sharpest sportsbook: Kalshi’s ladder holds 1,474 on the thin side against Pinnacle’s 1,350 limit. On the spread, the market NBA bettors live on, the deepest exchange rung holds 702 and Pinnacle takes 2,341. Every number below comes from those calls.

The Finding in One Table

For each venue and each of the three main markets, this is the median stake you could place on the thinner side of the main rung, across the opening-week boards where the venue had a two-sided active quote. “Thin side” means the worse of the two outcomes, because that side caps a hedge or an arbitrage. The count in brackets is the number of boards. Pinnacle is the sportsbook benchmark; its number is the limit field, the largest stake it accepts in one bet.

Venue Moneyline Spread Total Ladder levels
kalshi 1,474 (3) no market no market 3
pinnacle 1,350 (12) 2,341 (12) 894 (12) limit only
novig.us 587 (14) 702 (14) 648 (13) 1 to 2
sx.bet 400 (11) 78 (12) 290 (11) 1
betfair-ex 376 (16) 91 (16) 211 (15) 3
polymarket.us 0 (13) 0 (8) 0 (4) 1
duel not published (18) not published (18) not published (18) none

Read the moneyline column first. Kalshi beats Pinnacle on the three boards it prices, and Novig came within reach on the three opening-night boards (832, 1,400 and 1,710). Then read the spread column. Novig’s 702 is the best any exchange offers, and Pinnacle takes 3.3 times that. Betfair lists around 50 spread rungs a board, and its main rung holds a median 91 on the thin side. SX Bet holds 78. Kalshi lists no NBA spread at all eight days out.

So the NBA exchange board is a moneyline board. If your model prices the spread, the size is still at the sportsbook, and the opening-night board of 217 books is where you shop it.

Why a Price Feed Hides This

Most odds feeds return one price per outcome per venue. A prediction market can post a tight number with ten dollars behind it, and the feed reports the tight number. Polymarket US quotes the opener’s moneyline at a 4.00% overround and every rung of its ladder carries a stake of zero. Duel posts 321 spread rungs at a 3.10% margin and publishes no depth field at all. A price-only screener ranks both of them above Pinnacle.

OddsPapi returns the venue’s own ladder next to the price, under exchangeMeta, and the sportsbook’s maximum stake under limit. Three ladder shapes ship on the v4 feed, and each venue sticks to one:

  • Betfair network (betfair-ex): availableToBack and availableToLay, each level {price, size}. size is the stake available.
  • Prediction markets (kalshi, novig.us, polymarket.us): back and lay, each level {cents, limit, price, size}. limit is the stake, size is the payout, cents is the share price.
  • Limit only (sx.bet): back and lay with {cents, limit, price} and no size.

On the opener Kalshi’s Celtics side reads 1.923 for 606, then 1.887 for 1,149, then 1.852 for 333. Novig’s Celtics side reads 1.852 for 1,829 and 1.786 for 756. Betfair’s Pistons side reads 2.02 for 284, 2.00 for 358 and 1.90 for 21. One parser handles all three shapes, and it is the first thing in the tutorial below.

Job Venue APIs one by one OddsPapi
Opening-week NBA slate One schedule per venue, matched by hand One /fixtures call on tournamentId 132
Prices on one fixture Six venue calls, six auth schemes 258 bookmakers in one /odds response
Depth behind the price Kalshi order book, Betfair listMarketBook, Polymarket CLOB, Novig signed REST exchangeMeta ladder on the same price object
Sportsbook benchmark Pinnacle’s API needs a commercial account pinnacle with its limit, same call
Price moves during the game Poll each venue WebSocket push
Cost Exchange API keys, Betfair app key, Pinnacle account Free tier

Step 1: Find the Opening-Week Board

Basketball is sportId 11 and the NBA is tournamentId 132. Pass both to /fixtures. The to date is a midnight-UTC instant, so ask for the day after the last day you want. The first call also pulls the opener’s full board without a bookmaker filter, which is how you learn which exchange venues have opened a fixture: pass a slug the venue has not priced and the response comes back 200 with the slug missing, so a presence check on the unfiltered board is the cheaper test.

import os, requests

API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE = "https://api.oddspapi.io/v4"
EXCHANGES = ["kalshi", "polymarket", "polymarket.us", "novig.us",
             "prophetx", "betfair-ex", "sx.bet", "duel", "myriad"]

# NBA is tournamentId 132 inside basketball (sportId 11).
fixtures = requests.get(f"{BASE}/fixtures", params={
    "apiKey": API_KEY, "sportId": 11, "tournamentId": 132,
    "from": "2026-10-20", "to": "2026-10-25",   # "to" is a midnight-UTC instant
}).json()
board = sorted((f for f in fixtures if f["hasOdds"]), key=lambda f: f["startTime"])
print(len(board), "priced NBA fixtures, 20 to 24 October")

opener = board[0]
odds = requests.get(f"{BASE}/odds", params={
    "apiKey": API_KEY, "fixtureId": opener["fixtureId"], "oddsFormat": "decimal",
}).json()
books = {s: b for s, b in odds["bookmakerOdds"].items() if "+" not in s and s != "demo"}
print(opener["participant1Name"], "v", opener["participant2Name"], opener["startTime"])
print(len(books), "bookmakers on the opener")
print("exchange venues present:", [s for s in EXCHANGES if s in books])
18 priced NBA fixtures, 20 to 24 October
Detroit Pistons v Boston Celtics 2026-10-20T19:00:00.000Z
258 bookmakers on the opener
exchange venues present: ['kalshi', 'polymarket.us', 'novig.us', 'betfair-ex', 'sx.bet', 'duel']

Three venues the catalogue lists did not appear on any of the 18 boards: polymarket, prophetx and myriad. Each returns 200 with the slug missing, which is the venue’s own opening schedule rather than a gated feed. A gated slug returns 403 and a misspelled one 400, so read the status code before you conclude anything about coverage.

Step 2: Parse the Three Ladder Shapes

Save this file as s2_ladder.py; the later steps import from it. read_ladder takes the price object at players["0"] and returns the back ladder as a list of (price, stake) pairs, best price first, plus a label for the shape it found. The get helper honours the retryMs the API sends on a 429.

import os, time, requests

API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE = "https://api.oddspapi.io/v4"
FIXTURE = "id1100013273682354"   # Detroit v Boston, 20 Oct 19:00 UTC
VENUES = ["kalshi", "polymarket.us", "novig.us", "betfair-ex", "sx.bet", "duel"]


def get(path, **params):
    """GET with the 429 retry the API asks for (retryMs in the error body)."""
    while True:
        r = requests.get(f"{BASE}{path}", params={"apiKey": API_KEY, **params})
        if r.status_code != 429:
            return r.json()
        time.sleep(r.json()["error"].get("retryMs", 2000) / 1000 + 0.5)


def read_ladder(price_obj):
    """Return (levels, shape). levels = [(price, stake), ...], best price first.

    Three ladder shapes ship on v4, and each venue sticks to one:
      Betfair network : availableToBack / availableToLay, each level {price, size}
      prediction market: back / lay, each level {cents, limit, price, size}
      limit-only       : back / lay, each level {cents, limit, price}, no size
    "stake" is the money you can put down at that level: Betfair publishes it as
    size, the prediction markets as limit.
    """
    meta = price_obj.get("exchangeMeta") or {}
    if "availableToBack" in meta:
        return [(l["price"], l.get("size") or 0.0) for l in meta["availableToBack"]], "betfair"
    if "back" in meta:
        levels = meta["back"]
        shape = "prediction" if levels and "size" in levels[0] else "limit-only"
        return [(l["price"], l.get("limit") or 0.0) for l in levels], shape
    return [], "none"


if __name__ == "__main__":
    odds = get("/odds", fixtureId=FIXTURE, oddsFormat="decimal", bookmakers=",".join(VENUES))
    print(f"{'venue':14s} {'shape':11s} {'home':>6s} {'away':>6s} {'margin':>7s} {'thin top':>9s} {'thin ladder':>12s}")
    for venue in VENUES:
        book = odds["bookmakerOdds"].get(venue)
        if not book or "111" not in book["markets"]:
            continue
        legs = []
        for outcome in book["markets"]["111"]["outcomes"].values():
            p = outcome["players"]["0"]          # game markets key the price object "0"
            levels, shape = read_ladder(p)
            legs.append((p["price"], levels[0][1] if levels else None,
                         sum(s for _, s in levels) if levels else None, shape))
        home, away = sorted(legs, key=lambda l: l[0])  # just for a stable print order
        margin = (1 / home[0] + 1 / away[0] - 1) * 100
        thin_top = min(l[1] for l in legs) if None not in [l[1] for l in legs] else None
        thin_full = min(l[2] for l in legs) if None not in [l[2] for l in legs] else None
        fmt = lambda v: f"{v:,.2f}" if v is not None else "n/a"
        print(f"{venue:14s} {home[3]:11s} {home[0]:6.3f} {away[0]:6.3f} {margin:6.2f}% {fmt(thin_top):>9s} {fmt(thin_full):>12s}")
venue          shape         home   away  margin  thin top  thin ladder
kalshi         prediction   1.923  2.041   1.00%    120.54       688.06
polymarket.us  prediction   1.818  2.041   4.00%      0.00         0.00
novig.us       prediction   1.852  1.961   4.99%    831.50       831.50
betfair-ex     betfair      1.870  2.020   2.98%      3.50       291.02
sx.bet         limit-only   1.706  1.923  10.62%    430.13       430.13
duel           none         1.840  2.100   1.97%       n/a          n/a

Kalshi’s 1.00% is the tightest moneyline on the whole 258-book opener, and the thin side holds 688 over three levels. Betfair’s 2.98% is the next tightest exchange, with 3.50 at the top of the Pistons ladder and 291 once you take all three levels. SX Bet’s 10.62% is the price of a single resting order per side. Novig’s 4.99% at 832 a side was two levels deep on one outcome and one on the other.

The guard that drops Kalshi

On the opener Kalshi’s moneyline market carries marketActive: false while both prices are active: true, the ladder holds stake on every level, and changedAt is seconds old. The same pattern shows on 8 of the 240 two-sided exchange and Pinnacle quotes across the slate, 3 on Kalshi and 5 on Novig. A guard built for a sportsbook board, where marketActive is the flag that separates a parked line from a traded one, drops the tightest quote on the board. On an exchange, read the ladder and the age of the quote instead: a populated ladder with a fresh changedAt is live.

Step 3: Score Depth by Market Family

The moneyline is one market ID, 111. The spread and the total are ladders of rungs, one market ID each, and the line lives in the handicap field of /markets. Basketball ships the second half and the fourth quarter on two clocks, so key every definition on (period, handicap) and select the full-game families on period == "result". The main rung is each venue’s most balanced two-sided quote inside a 1.5 to 2.6 price band; never trust the mainLine flag, because a venue sets it on its own schedule. The NBA alternate lines post covers the 722-rung ladders in full.

# s3_family.py
import os, requests
from s2_ladder import read_ladder, get

API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE = "https://api.oddspapi.io/v4"
FIXTURE = "id1100013273682354"
VENUES = ["kalshi", "polymarket.us", "novig.us", "betfair-ex", "sx.bet", "pinnacle"]

# Key every basketball definition on (period, handicap). Basketball ships the second
# half and the fourth quarter on two clocks, so the market name alone is not enough.
defs = {d["marketId"]: d for d in get("/markets") if d["sportId"] == 11}
FAMILY = {
    "moneyline": lambda d: d["marketId"] == 111,
    "spread":    lambda d: d["marketName"] == "Handicap (incl. overtime)" and d["period"] == "result",
    "total":     lambda d: d["marketName"] == "Over Under (incl. overtime)" and d["period"] == "result",
}


def two_sided(market):
    legs = [o["players"]["0"] for o in market["outcomes"].values()]
    return legs if len(legs) == 2 and all(l["active"] and l["price"] > 1 for l in legs) else None


def main_rung(book, family):
    """The venue's most balanced rung inside a [1.5, 2.6] price band. Never mainLine."""
    rungs = []
    for mid, market in book["markets"].items():
        d = defs.get(int(mid))
        legs = two_sided(market) if d and FAMILY[family](d) else None
        if legs and (family == "moneyline" or all(1.5 <= l["price"] <= 2.6 for l in legs)):
            rungs.append((abs(legs[0]["price"] - legs[1]["price"]), d["handicap"], legs))
    return min(rungs)[1:] if rungs else (None, None)


def thin_side(legs):
    """Stake on the thinner side: the exchange ladder summed, or the sportsbook limit."""
    depth = []
    for l in legs:
        levels, _ = read_ladder(l)
        depth.append(sum(s for _, s in levels) if levels else l.get("limit"))
    return min(depth) if None not in depth else None


if __name__ == "__main__":
    odds = get("/odds", fixtureId=FIXTURE, oddsFormat="decimal", bookmakers=",".join(VENUES))
    print(f"{'venue':14s} {'family':10s} {'line':>6s} {'margin':>7s} {'thin-side stake':>16s}")
    for venue in VENUES:
        book = odds["bookmakerOdds"].get(venue)
        if not book:
            continue
        for family in FAMILY:
            line, legs = main_rung(book, family)
            if legs is None:
                print(f"{venue:14s} {family:10s} {'-':>6s} {'-':>7s} {'no two-sided rung':>16s}")
                continue
            margin = (1 / legs[0]["price"] + 1 / legs[1]["price"] - 1) * 100
            stake = thin_side(legs)
            print(f"{venue:14s} {family:10s} {line:6.1f} {margin:6.2f}% {stake if stake is None else f'{stake:,.0f}':>16}")
venue          family       line  margin  thin-side stake
kalshi         moneyline     0.0   1.00%              688
kalshi         spread          -       - no two-sided rung
kalshi         total           -       - no two-sided rung
polymarket.us  moneyline     0.0   4.00%                0
polymarket.us  spread          -       - no two-sided rung
polymarket.us  total           -       - no two-sided rung
novig.us       moneyline     0.0   4.99%              832
novig.us       spread       -1.5   7.49%              679
novig.us       total       221.5   7.99%              567
betfair-ex     moneyline     0.0   2.98%              291
betfair-ex     spread       -2.0  10.82%               43
betfair-ex     total       221.5  10.80%              345
sx.bet         moneyline     0.0  10.62%              430
sx.bet         spread       -1.5  10.61%               48
sx.bet         total       221.5  10.62%              297
pinnacle       moneyline     0.0   2.86%            1,350
pinnacle       spread       -2.0   2.93%            2,319
pinnacle       total       221.5   3.41%              894

This is the opener on its own, and the pattern of the slate is already visible. Novig lists five spread rungs and holds 679 on the thin side at -1.5. Betfair lists 24 spread rungs on this board and its main rung at -2.0 holds 43 on one side and 45 on the other, a single level each. Pinnacle sits at -2.0 for 2,319. On the total, every venue agrees on 221.5, and the stake runs 567 at Novig, 345 at Betfair, 297 at SX Bet and 894 at Pinnacle.

Step 4: Run the Slate

Eighteen boards, one call each with a bookmaker filter, one second apart. The API rate-limits parallel calls to /odds, so loop; the filtered payloads are small and the whole run takes under a minute.

# s4_slate.py
import os, time, statistics, requests
from s2_ladder import read_ladder, get
from s3_family import main_rung, thin_side, FAMILY, BASE, API_KEY

VENUES = ["kalshi", "polymarket.us", "novig.us", "betfair-ex", "sx.bet", "pinnacle"]
fixtures = get("/fixtures", sportId=11, tournamentId=132, **{"from": "2026-10-20", "to": "2026-10-25"})
board = [f for f in fixtures if f["hasOdds"]]

stakes = {}   # (venue, family) -> [thin-side stake per board]
for f in board:
    odds = get("/odds", fixtureId=f["fixtureId"], oddsFormat="decimal", bookmakers=",".join(VENUES))
    for venue, book in odds.get("bookmakerOdds", {}).items():
        for family in FAMILY:
            _, legs = main_rung(book, family)
            if legs:
                stake = thin_side(legs)
                if stake is not None:
                    stakes.setdefault((venue, family), []).append(stake)
    time.sleep(1.0)   # never parallelise /odds

print(f"{len(board)} boards. Median thin-side stake on the main rung (boards with a two-sided quote)")
print(f"{'venue':14s} {'moneyline':>14s} {'spread':>14s} {'total':>14s}")
for venue in VENUES:
    cells = []
    for family in FAMILY:
        v = stakes.get((venue, family), [])
        cells.append(f"{statistics.median(v):,.0f} ({len(v)})" if v else "-")
    print(f"{venue:14s} {cells[0]:>14s} {cells[1]:>14s} {cells[2]:>14s}")
18 boards. Median thin-side stake on the main rung (boards with a two-sided quote)
venue               moneyline         spread          total
kalshi              1,474 (3)              -              -
polymarket.us          0 (13)          0 (8)          0 (4)
novig.us             587 (14)       702 (14)       648 (13)
betfair-ex           376 (16)        91 (16)       211 (15)
sx.bet               400 (11)        78 (12)       290 (11)
pinnacle           1,350 (12)     2,341 (12)       894 (12)

That output is the table at the top of the post. Three details from the slate are worth writing down before you build on it.

Kalshi opened the three opening-night games and nothing else. Detroit v Boston, New York v Philadelphia and San Antonio v Oklahoma City carry a Kalshi moneyline; the eleven games on 21 October and the four after them do not, eight days out. Novig was on 14 boards, Betfair on 16, SX Bet on 12 and Duel on all 18. Four boards drew 146 to 151 bookmakers against 240 to 258 on the rest, and Pinnacle, Novig and SX Bet were absent from all four. The books open the slate fixture by fixture, so re-run the presence check the day before each tip.

Pinnacle’s limits are flat pre-season defaults. The moneyline limit is 1,350 on all 12 boards. The spread limit runs 2,295 to 2,363 and the total 886 to 903. Those numbers will move once the board gets traded, so treat them as the floor of what the sharp book takes, and read them again on game day.

Two venues ship a price with no usable depth. Polymarket US quotes 13 boards and every rung carries limit: 0 and size: 0, with the venue flagged bookmakerIsActive: false. Duel quotes 663 market IDs on the opener, the full ladder the crypto and offshore sportsbooks post, with no limit and no exchangeMeta. Score both on price if you like, but do not size a position off either.

Where the Exchanges Sit Among 258 Books

Run the full opener board through the sportsbook guard (suspended, bookmakerIsActive, marketActive and the price-level active flag) and 122 books survive on the moneyline. Dedupe on the price pair and those 122 collapse to 28 distinct quotes. Pinnacle is sixth at 2.86%, level with seven feeds that mirror it. Betfair Exchange is ninth at 2.98%, with four Asian feeds that clone its book. Novig is 48th, SX Bet 120th. Kalshi, Polymarket US and Duel fall out of the guard, Kalshi on marketActive and the other two on bookmakerIsActive. The NFL version of this study found depth rankings close to the reverse of price rankings. On NBA, the exchange that leads on price is also the one that leads on moneyline depth, and the inversion sits between the moneyline and the spread instead.

If you size stakes off a sharp price, the betting limits guide explains what limit means book by book, and the Novig API post walks the novig.us ladder on a 191-market NFL board, where the same venue quoted a 0.50% moneyline. Its NBA pre-season board sits at 7.0%, which is what a resting-order book looks like before the flow arrives.

What This Looks Like on Game Day

The ladder is live only. Once a fixture tips and settles, /historical-odds keeps every price snapshot and the limit on each, but exchangeMeta comes back null on history. We checked on Game 5 of the June 2026 Finals: Novig’s history holds 83 markets, a limit on every snapshot (1,909 to 3,847 on the first six moneyline prices) and a null ladder throughout. So the ramp from eight days out to tip-off is one number wide per venue. Store the ladder yourself if you want it: poll /odds with the venue filter, or take the WebSocket and write each change as it lands. On opening night, that is the dataset no venue will sell you afterwards.

Read the Ladder Before the First Tip

One free OddsPapi key returns 258 bookmakers on the NBA opener, six exchange venues with their order-book ladders, Pinnacle with its limit, and the history to grade all of it after the game. Get a free API key, run the slate script on 19 October, and know where the size is before the Celtics and Pistons walk out.

FAQ

Which prediction markets have NBA odds through the API?

On 12 October 2026, eight days before the opener, six exchange venues priced NBA fixtures on OddsPapi: Kalshi, Polymarket US, Novig, Betfair Exchange, SX Bet and Duel. Kalshi quoted the three opening-night games, Betfair 16 of the 18 priced boards, Novig 14, Polymarket US 13, SX Bet 12 and Duel all 18.

Does Kalshi offer NBA spreads and totals?

Eight days before opening night Kalshi listed one NBA market per fixture, the moneyline, on the three opening-night games. Its ladder ran three levels deep and held a median 1,474 on the thin side, more than Pinnacle’s 1,350 limit. Spreads and totals on the exchanges came from Novig, Betfair Exchange and SX Bet.

How does Novig’s NBA liquidity compare with Kalshi’s?

On the three opening-night boards both venues priced, Novig held 832, 1,400 and 1,710 on the thin side of the moneyline against Kalshi’s median 1,474, at a wider price: Novig 5.0% to 7.0% overround on a one- or two-level ladder, Kalshi 1.0% on three levels. Novig also lists five spread rungs and seven total rungs, which Kalshi does not.

Which API streams consolidated order-book depth across sports in real time?

OddsPapi returns the back and lay ladder of each exchange venue under exchangeMeta on the same price object as the sportsbook quotes, across every sport the venue lists, and pushes each change over a WebSocket. The ladder is live only; historical snapshots keep the price and the limit field.

How do I measure the depth of a betting market from an odds API?

Take the main rung of each market, read the back ladder on both outcomes, sum the stake across the levels, and keep the smaller side. That thin-side stake is what caps a hedge or an arbitrage. Compare it with the sportsbook’s limit field on the same rung; on the NBA opener Pinnacle’s spread limit was 2,341 and the deepest exchange spread rung held 679.