NBA Odds API: Rank 217 Books Pricing Opening Night

NBA Odds API header image
How To Guides September 22, 2026

The 2026-27 NBA season tips off on 20 October. On 11 September, 39 days out, one NBA odds API call on the Detroit Pistons v Boston Celtics opener returns 217 bookmakers and 531 distinct market IDs. The board is already there. Rank it the naive way and you put a book on top whose price last moved on 2 September.

This post measures that board. It covers what the NBA slate holds 39 days before the first jump ball, the three flags that separate a live quote from a parked one, and how far 217 quotes shrink once you dedupe them. Every number comes from a live call made on 11 September 2026. Re-run the scripts and your numbers will differ, because the board moves.

What the NBA Board Holds 39 Days Out

Sixteen NBA fixtures sit on the board between 20 and 24 October. All sixteen return hasOdds: true, and all sixteen carry a real board when you probe /odds. Book count tracks the calendar:

Tip-off (UTC) Fixture Bookmakers Market IDs On market 111
20 Oct 19:00 Detroit Pistons v Boston Celtics 217 531 211
20 Oct 23:00 New York Knicks v Philadelphia 76ers 212 518 206
21 Oct 01:30 San Antonio Spurs v Oklahoma City Thunder 214 516 203
21 Oct 23:30 Miami Heat v Minnesota Timberwolves 175 599 169
22 Oct 02:00 Los Angeles Lakers v Golden State Warriors 175 537 170
22 Oct 00:30 Houston Rockets v Dallas Mavericks 168 522 162
23 Oct 01:30 Oklahoma City Thunder v Denver Nuggets 166 585 164
23 Oct 23:00 Boston Celtics v New York Knicks 146 523 140
24 Oct 01:30 San Antonio Spurs v Houston Rockets 145 534 145
Measured 11 September 2026. Internal test feeds excluded.

Opening night carries 217 books. Four days into the season that drops to 145. The books open the marquee fixtures first and fill the rest of the calendar as it approaches, which is the same ramp the NFL board shows in our 272-fixture study of when NFL odds open.

Two market IDs carry the result, and they settle differently. Market 111 is “Winner (incl. overtime)”, the two-way moneyline, and it sits on 140 to 211 books per fixture. Market 113 is “Regular Time Result”, a three-way with a live draw leg graded on the score at the end of the fourth quarter. It runs on 40 to 65 books. Merging the two is a grading error. Our EuroLeague odds API post works through the settlement difference in full.

Who Is On the Board, and With How Much

Menu width splits the board into two groups. The crypto and casino brands ship the full ladder: gamdom carries 486 market IDs on the opener, and 21 more slugs carry 483 each. The US retail books carry the core four and stop there.

Bookmaker Market IDs on the 20 Oct opener
gamdom 486
bcgame, betfury, duel, roobet and 18 more 483
draftkings 5
bet365 5
betmgm 4
kalshi 1
tipico, fanatics, mrgreen, 888sport.es 1
Measured 11 September 2026 on fixture id1100013273682354.

Pinnacle, Betfair Exchange and Polymarket post their NBA regular-season board closer to tip-off. Pass bookmakers=pinnacle alone and the call returns HTTP 200 with the slug absent from bookmakerOdds, which is the API telling you the book has no price on this fixture yet. A 403 would mean your key cannot read the slug. Kalshi opened three of the sixteen boards, all on 20 and 21 October.

That changes how you build a fair price in September. You cannot anchor on Pinnacle, so de-vig the deduped consensus of the books that are up. Our consensus odds calculator covers the maths.

Why a Plain Best-Price Scan Ranks the Wrong Book

Most odds clients read one field. They walk the outcomes, check active on the price row, and rank what survives. On a board opened six weeks early that filter is too loose.

On the Detroit v Boston opener, 199 of the 211 moneyline quotes carry active: true at the price level. Rank those by margin and 1xbet tops the table at 2.05%. Open the record and the price last changed at 2026-09-02T03:41:26Z, nine days before the call, and the market it sits in reports marketActive: false. The price row says active. The market around it says closed.

The OddsPapi payload carries three separate liveness flags, and each one means something different:

Field Level What it tells you
suspended / bookmakerIsActive Bookmaker The book has the whole fixture up or down
marketActive Market This market ID is open for bets at this book
active Price This outcome is priced and takeable
changedAt Price When the book last moved this number

Apply them in that order on the opener and the count falls in steps: 211 books quote market 111, 199 pass the price-level active check, 195 pass the book-level flags, 99 sit in a market flagged open, and 39 moved their moneyline inside the last 48 hours. The board median margin shifts from 5.68% to 6.43% across that funnel, and the top of the leaderboard changes hands: duel at 2.13%, repriced 24 minutes before the call, with kalshi behind it at 2.98%.

In-play the same fields behave differently again. Bet365 flips bookmakerIsActive to false half a minute after kick-off and keeps repricing to full time, which we measured second by second in the bet365 live odds post. Pre-match, trust the flags. In-play, read changedAt.

Old Way vs OddsPapi

Task Scraping or a single-book API OddsPapi
Books on one NBA fixture 1 to 20, one integration each 217 in one call, from a catalogue of 357 slugs
Knowing a price is live Infer from a page that renders the same either way Three flags plus changedAt on every price row
Alternate totals and handicaps Scrape each rung 531 market IDs with the line in the handicap field
Opening and closing lines Run your own collector for a season Free historical odds on every played fixture
Sub-second updates Poll and get blocked WebSocket push on the odds channel
Cost to start Proxies, maintenance, legal risk Free tier, no card

Build It: Rank the NBA Opening-Night Board in Python

Four steps. Every snippet below ran against the live API on 11 September 2026.

Step 1: Find the NBA Tournament and Its Fixtures

Authentication is a query parameter, never a header. Basketball is sportId=11, and the NBA is tournamentId=132. Filter /fixtures on the tournament ID: it cuts the payload by about 20x and lifts the 10-day range cap.

import os, time, requests

API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE = "https://api.oddspapi.io/v4"
BASKETBALL = 11

def get(path, **params):
    params["apiKey"] = API_KEY
    r = requests.get(f"{BASE}/{path}", params=params)
    r.raise_for_status()
    time.sleep(1.2)
    return r.json()

nba = next(t for t in get("tournaments", sportId=BASKETBALL)
           if t["tournamentSlug"] == "nba")
print(nba["tournamentId"], nba["tournamentName"], nba["futureFixtures"], "future fixtures")

fixtures = get("fixtures", sportId=BASKETBALL, tournamentId=nba["tournamentId"],
               **{"from": "2026-09-11", "to": "2026-11-15"})
fixtures.sort(key=lambda f: f["startTime"])
print(f"{len(fixtures)} fixtures on the board")
for f in fixtures[:5]:
    print(f["startTime"][:16], f["participant1Name"], "v", f["participant2Name"],
          "hasOdds", f["hasOdds"])
132 NBA 21 future fixtures
16 fixtures on the board
2026-10-20T19:00 Detroit Pistons v Boston Celtics hasOdds True
2026-10-20T23:00 New York Knicks v Philadelphia 76ers hasOdds True
2026-10-21T01:30 San Antonio Spurs v Oklahoma City Thunder hasOdds True
2026-10-21T23:00 Orlando Magic v Atlanta Hawks hasOdds True
2026-10-21T23:00 Washington Wizards v Milwaukee Bucks hasOdds True

Set to to the day after the last day you want. It is a midnight-UTC instant, so a same-day query returns the 00:00Z fixtures and nothing else. A 404 on /fixtures means an empty window, not a failure, so do not let raise_for_status() kill a season loop on the first off-season month.

Step 2: Measure the Board on One Fixture

Drop the internal test feeds before you count anything. Slugs matching pinnacle+ and the names demo, singbet-b and vertexv1 ship inside live payloads and will count Pinnacle three times.

import collections

INTERNAL = ("demo", "singbet-b", "vertexv1")

payload = get("odds", fixtureId="id1100013273682354")
books = {s: b for s, b in payload["bookmakerOdds"].items()
         if not s.startswith("pinnacle+") and s not in INTERNAL}

menu = collections.Counter({s: len(b.get("markets") or {}) for s, b in books.items()})
market_ids = {int(m) for b in books.values() for m in (b.get("markets") or {})}
print(f"{len(books)} bookmakers, {len(market_ids)} distinct market IDs")
print("widest menus :", menu.most_common(3))
print("narrowest    :", menu.most_common()[-3:])
for slug in ("draftkings", "betmgm", "bet365", "kalshi", "pinnacle", "betfair-ex"):
    print(f"  {slug:<12} {menu.get(slug, 'not on this board')}")
217 bookmakers, 531 distinct market IDs
widest menus : [('gamdom', 486), ('500.casino', 483), ('bcgame', 483)]
narrowest    : [('mrgreen.dk', 1), ('pointsbet.com.au', 1), ('tipico', 1)]
  draftkings   5
  betmgm       4
  bet365       5
  kalshi       1
  pinnacle     not on this board
  betfair-ex   not on this board

The markets= and marketId= parameters on /odds shrink the response but do not restrict which market IDs come back. Filter client-side.

Step 3: Apply the Three Flags and Rank on Margin

This is the part that changes your leaderboard. Check the book, then the market, then the price, then the clock.

import statistics as st
from datetime import datetime, timezone

MONEYLINE = 111                      # "Winner (incl. overtime)"
FRESH_HOURS = 48

def parse(ts):
    return datetime.fromisoformat(ts.replace("Z", "+00:00"))

def moneyline_rows(payload):
    now = datetime.now(timezone.utc)
    rows = []
    for slug, book in payload["bookmakerOdds"].items():
        if slug.startswith("pinnacle+") or slug in INTERNAL:
            continue
        market = (book.get("markets") or {}).get(str(MONEYLINE))
        if not market:
            continue
        legs = {}
        for outcome_id, outcome in market["outcomes"].items():
            price = (outcome.get("players") or {}).get("0")
            if price:
                legs[outcome_id] = price
        if len(legs) != 2:                                   # guard the leg count
            continue
        if book.get("suspended") or book.get("bookmakerIsActive") is False:
            continue                                         # flag 1: the book is up
        if market.get("marketActive") is not True:
            continue                                         # flag 2: the market is up
        if not all(leg.get("active") for leg in legs.values()):
            continue                                         # flag 3: both prices are live
        stamps = [leg.get("changedAt") for leg in legs.values()]
        if not all(stamps):
            continue
        age = min((now - parse(s)).total_seconds() / 3600 for s in stamps)
        if age > FRESH_HOURS:                                # and it moved recently
            continue
        margin = (sum(1 / leg["price"] for leg in legs.values()) - 1) * 100
        rows.append((slug, margin, age, {o: l["price"] for o, l in legs.items()}))
    return sorted(rows, key=lambda r: r[1])

rows = moneyline_rows(get("odds", fixtureId="id1100013273682354"))
print(f"{len(rows)} books clear all three flags")
print(f"board median margin {st.median(m for _, m, _, _ in rows):.2f}%")
for slug, margin, age, prices in rows[:8]:
    print(f"{slug:<20} {margin:5.2f}%  last moved {age:5.1f}h ago  {prices}")
39 books clear all three flags
board median margin 6.43%
duel                  2.13%  last moved   0.4h ago  {'111': 1.85, '112': 2.08}
kalshi                2.98%  last moved   0.0h ago  {'112': 2.128, '111': 1.786}
borgata               4.34%  last moved  45.6h ago  {'112': 2.05, '111': 1.8}
bet365.it             4.56%  last moved   1.2h ago  {'112': 2.0, '111': 1.833}
betify                5.06%  last moved   1.8h ago  {'111': 1.8, '112': 2.02}
betplay.io            5.06%  last moved   1.8h ago  {'111': 1.8, '112': 2.02}
roobet                5.06%  last moved   1.8h ago  {'111': 1.8, '112': 2.02}
bet365.de             6.32%  last moved   1.2h ago  {'111': 1.8, '112': 1.97}

The len(legs) != 2 guard earns its place. On the EuroLeague slate we found three separate books shipping a two-way market with one leg missing, one of them at an overround of minus 52%, which puts it first on an unguarded leaderboard. Margin ranking is also orientation-independent, so it survives a feed that lists the outcomes in a different order. Best-price scanning is not, and the fix is in our line shopping guide.

Step 4: Dedupe Before You Quote a Book Count

Distinct slugs quote byte-identical prices. The cloneOf field does not catch it: 117 of the 357 catalogue slugs carry a parent, and the clusters below cut across that.

clusters = collections.defaultdict(list)
for slug, book in payload["bookmakerOdds"].items():
    if slug.startswith("pinnacle+") or slug in INTERNAL:
        continue
    market = (book.get("markets") or {}).get("111")
    if not market:
        continue
    legs = {o: (od.get("players") or {}).get("0") for o, od in market["outcomes"].items()}
    legs = {o: p for o, p in legs.items() if p and p.get("active")}
    if len(legs) != 2:
        continue
    key = tuple(sorted((o, round(p["price"], 3)) for o, p in legs.items()))
    clusters[key].append(slug)

quotes = sum(len(v) for v in clusters.values())
print(f"{quotes} quotes collapse to {len(clusters)} distinct prices "
      f"({100 * (1 - len(clusters) / quotes):.1f}% duplicated)")
for key, slugs in sorted(clusters.items(), key=lambda kv: -len(kv[1]))[:3]:
    print(f"  {len(slugs):>3} books at {dict(key)}  e.g. {', '.join(slugs[:5])}")
199 quotes collapse to 38 distinct prices (80.9% duplicated)
   26 books at {'111': 1.8, '112': 2.05}  e.g. betboo.bet.br, betmgm, betway, betway.de, betway.es
   23 books at {'111': 1.741, '112': 2.0}  e.g. 7bet.co.uk, 7bet.lt, allbritishcasino, alphawin, betinia.dk
   21 books at {'111': 1.82, '112': 2.02}  e.g. atg.se, ballybet, betcity.nl, betparx, betplay

Four in five quotes on the opener repeat a price another slug already posted. Feed a raw 199-book sample into a consensus and the 26-slug cluster at 1.80 / 2.05 counts 26 times. Dedupe on the price tuple first, then average the 38 that remain.

Bonus: How Far Back the NBA History Goes

Historical odds are free on every plan, including the free tier. On a played fixture the endpoint returns the full snapshot series per book. The 2026 Finals game between the New York Knicks and the San Antonio Spurs shows where each book’s board starts:

FIXTURE = "id1100013270505016"           # New York Knicks v San Antonio Spurs, 9 Jun 2026
TIP = parse("2026-06-09T00:30:00Z")

data = get("historical-odds", fixtureId=FIXTURE, bookmakers="pinnacle,draftkings,bet365")
for slug, book in data["bookmakers"].items():
    stamps, snapshots = [], 0
    for market in book["markets"].values():
        for outcome in market["outcomes"].values():
            for series in outcome["players"].values():
                snapshots += len(series)
                stamps += [s["createdAt"] for s in series]
    lead = (TIP - parse(min(stamps))).total_seconds() / 3600
    print(f"{slug:<12} {len(book['markets']):>3} markets  {snapshots:>6} snapshots  "
          f"opens T-{lead:.0f}h")
bet365        21 markets     373 snapshots  opens T-66h
draftkings   633 markets  174950 snapshots  opens T-68h
pinnacle     443 markets   77163 snapshots  opens T-69h

Three points worth keeping. The endpoint caps at three bookmaker slugs per call, and one unreadable slug fails the whole batch. Sleep 4.6 seconds after each successful call. And the closing price is the last snapshot with active: true before startTime, not the last snapshot in the series, because snapshots keep arriving after the jump ball. Our backtesting guide builds a full season loop on top of this.

Four Traps on the NBA Board

  1. The price row lies on its own. Read marketActive and changedAt alongside active. On the 20 October opener that is the difference between 199 books and 39.
  2. Market 111 and market 113 are different bets. The three-way settles on the score at the end of regulation. The two-way settles after overtime. Never merge them into one price.
  3. Fixture count is not coverage. Every NBA fixture in the window returns hasOdds: true, and the boards behind them range from 145 books to 217. Probe /odds before you commit.
  4. Alternate totals live in the handicap field. Market 11248 and market 11252 share the name “Over Under (incl. overtime)” and carry 221.5 and 223.5. Split on the pair, never on the name alone. The same pattern shows up across the NFL alternate lines ladder.

Start Pulling the NBA Board

The free tier reads every endpoint used above: /tournaments, /fixtures, /odds and /historical-odds, across a catalogue of 357 bookmaker slugs. No card, no sales call. When polling stops keeping up, the same data pushes over a WebSocket odds channel.

Get a free API key and run the four scripts on tonight’s board. If you want the player-prop side of the NBA feed, which opens closer to tip-off, start with our NBA player props and spreads guide.

Frequently Asked Questions

How many bookmakers price an NBA fixture?

On 11 September 2026, 39 days before tip-off, the Detroit Pistons v Boston Celtics opener returned 217 bookmakers and 531 distinct market IDs in a single /odds call. Fixtures later in the opening week returned 145 to 175 books. The catalogue holds 357 slugs, and the count on any one fixture depends on how close it is and how big the matchup is.

What is the difference between market 111 and market 113 in basketball?

Market 111 is “Winner (incl. overtime)”, a two-way moneyline settled on the final score. Market 113 is “Regular Time Result”, a three-way with a draw leg settled on the score at the end of the fourth quarter. They are separate bets with separate settlement rules, so keep them apart in your parser.

Why does a bookmaker show an active price in a closed market?

The payload carries liveness at three levels. suspended and bookmakerIsActive describe the book, marketActive describes the market, and active describes the individual price row. A book can leave a price row flagged active inside a market it has flagged closed. Read changedAt as well, so you know when the number last moved.

Can I get historical NBA odds for free?

Yes. The /historical-odds endpoint is on the free tier and returns the full snapshot series per bookmaker on a played fixture. On the 2026 Finals game tested here, Pinnacle returned 443 market IDs and 77,163 snapshots. The endpoint takes up to three bookmaker slugs per call, and 4.6 seconds of spacing between calls keeps you under the rate limit.

How do I find the NBA tournament ID?

Call /v4/tournaments?sportId=11 and match on tournamentSlug == "nba". The ID is 132. Pass it to /fixtures as tournamentId, which shrinks the payload and lifts the 10-day date-range cap that applies when sportId is your only filter.