NFL Prediction Market Liquidity: Depth Ladders From 7 Venues

Prediction Market Liquidity - OddsPapi API Blog
How To Guides September 4, 2026

Pull the NFL Week 1 opener from 169 bookmakers and rank them by margin. The winner is polymarket.us at 0.49%, roughly six times tighter than Pinnacle. Then read the ladder behind that price and find $0.00. Nothing. The best number on the board cannot be filled for a dollar.

This is the part of prediction-market data that a price feed alone will never tell you. Kalshi, Polymarket, Betfair Exchange, Novig, SX Bet and Duel all quote NFL now, and their prices are excellent. Their depth ranges from five figures to literally zero, and the ranking by depth is close to the reverse of the ranking by price.

This post measures it. Seven venues across all 16 Week 1 fixtures, then the same measurement run backwards over Super Bowl LX with free historical data, so you can see what a fully-loaded NFL prediction market actually looks like. Everything below runs on the free tier.

The finding in one table

Every venue below quotes a complete, active moneyline on all 16 NFL Week 1 fixtures. Measured 26 August 2026, about 14 days before the opener. “Thinnest side” means the worse of the two outcomes, because that is the side that caps an arbitrage or a hedge.

Venue Median margin Thinnest side, top of book Thinnest side, full ladder Deepest fixture
polymarket.us 0.50% $0.00 $0.00 $0.00
kalshi 1.01% $751.29 $3,901.06 $15,341.74
polymarket 1.99% $79.03 $2,393.52 $3,458.21
duel 2.10% not published not published not published
betfair-ex 2.76% $2.33 $2.33 $199.45
novig.us 2.76% $231.06 $624.44 $1,341.00
sx.bet no active quote $0.00 $0.00 $0.00

Read the first column and the third column together. polymarket.us is first on price and last on depth. kalshi is third on price and first on depth by a factor of six. sx.bet is on all 16 fixtures and has zero two-sided active quotes: both legs ship active: false at a price of zero.

For contrast, the sharp sportsbook on the same fixture: Pinnacle ranks 8th of 150 books at 3.04% margin, and it will take $2,717 on New England and $1,500 on Seattle. It is six times wider than polymarket.us and it is the only quote in the top eight that you can put real money into.

Where liquidity lives in the payload

OddsPapi exposes two separate depth fields, and they are not interchangeable.

  • limit sits on the price object. On a sportsbook it is the maximum stake the book accepts. Only Pinnacle and the exchanges populate it; the other 140-odd books on an NFL fixture return null.
  • exchangeMeta sits on the same price object and only appears on exchange-type venues. It carries a back and a lay ladder, each a list of price levels, best first. Each level has a price, a size (the payout available) and a limit (the stake needed to take it).

The top of the back ladder equals the outcome’s headline price. So a venue can post a beautiful number with one rung holding three dollars, which is exactly what Polymarket does on the opener.

import requests

API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"

# apiKey is a QUERY PARAMETER, not a header.
r = requests.get(f"{BASE_URL}/sports", params={"apiKey": API_KEY})
print(r.status_code)  # 200

Step 1: find the NFL Week 1 board

American Football is sportId 14, and it carries NCAA, CFL and the European leagues alongside the NFL. The NFL itself is tournamentId 31. Pass it to /fixtures to cut the payload by about twenty times.

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(4):
        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, {}

# NOTE: `to` is a midnight-UTC instant. Set it to the day AFTER your last day.
status, fixtures = get("fixtures", sportId=14, tournamentId=31,
                       **{"from": "2026-09-09", "to": "2026-09-16"})

for f in sorted(fixtures, key=lambda x: x["startTime"]):
    print(f["fixtureId"], f["startTime"][:16],
          f["participant2Name"], "@", f["participant1Name"])

That returns the full 16-fixture Week 1 slate. participant1 is the home team. Two schedule traps are worth knowing before you build anything on this: an empty window returns HTTP 404, not an empty array, so a season-long loop over the off-season will kill itself on raise_for_status(); and prime-time games shift to the next calendar day in UTC, so never group an NFL slate by its UTC date. The NFL schedule API guide covers the full-season pull.

Step 2: pull the board and pick out the exchanges

Call /odds with no bookmakers filter. The opener returns 169 bookmakers, 46,120 prices and 1,679 distinct market IDs in a single response, which is what 350+ bookmaker coverage looks like on one American football game.

Two filters matter before you count anything:

  1. Filter internal test feeds. pinnacle+30 and pinnacle+5 quote the identical number to pinnacle. Drop any slug matching pinnacle+ or demo or you will count the sharp three times.
  2. Filter on the price-level active flag. Only 39.0% of the opener’s 46,120 prices are active. Books post a line early and flag it suspended, and the wider the board the worse the ratio gets. Note that active lives on the price object at players["0"], not on the outcome.
OPENER = "id1400003171515752"   # Seahawks v Patriots, 10 Sep 2026
MONEYLINE = "141"               # NFL Winner (incl. overtime)

status, odds = get("odds", fixtureId=OPENER)
books = odds["bookmakerOdds"]

def moneyline(book):
    """Return {outcomeId: price_object} for active, two-sided quotes only."""
    market = book.get("markets", {}).get(MONEYLINE)
    if not market:
        return None
    legs = {}
    for outcome_id, outcome in market["outcomes"].items():
        price = outcome["players"].get("0")          # game lines key on "0"
        if price and price.get("active") and price.get("price"):
            legs[outcome_id] = price
    return legs if len(legs) == 2 else None

clean = {slug: legs for slug, book in books.items()
         if not slug.startswith("pinnacle+") and slug != "demo"
         and (legs := moneyline(book))}

print(len(books), "books ->", len(clean), "with a complete active moneyline")
# 169 books -> 150 with a complete active moneyline

Then dedupe. Several distinct slugs ship byte-identical prices while the catalogue reports cloneOf: null, so a raw book count always overstates. On this fixture 150 quotes collapse to 57 independent prices, a 62.0% collapse. Dedupe on the price tuple, per fixture, never off a static clone list.

tuples = {(legs["141"]["price"], legs["142"]["price"]) for legs in clean.values()}
print(len(clean), "quotes ->", len(tuples), "independent")
# 150 quotes -> 57 independent

Step 3: read the ladder, not the price

Now score each venue on depth. The rule that makes this useful: take the thinnest side, because a two-sided position is capped by whichever leg runs out of money first.

def depth(price_obj):
    """Top-of-book stake and full back-ladder stake for one outcome."""
    meta = price_obj.get("exchangeMeta") or {}
    ladder = meta.get("back") or []
    if ladder:
        top = ladder[0].get("limit") or 0.0
        total = sum((level.get("limit") or 0.0) for level in ladder)
        return top, total, len(ladder)
    # Sportsbook, or an exchange with no ladder: fall back to `limit`.
    lim = price_obj.get("limit")
    return (lim, lim, 0) if lim is not None else (None, None, 0)

for slug in ("kalshi", "polymarket", "polymarket.us", "novig.us", "betfair-ex", "pinnacle"):
    legs = clean.get(slug)
    if not legs:
        continue
    margin = (sum(1 / leg["price"] for leg in legs.values()) - 1) * 100
    sides = [depth(leg) for leg in legs.values()]
    thin_top = min(s[0] for s in sides) if all(s[0] is not None for s in sides) else None
    thin_all = min(s[1] for s in sides) if all(s[1] is not None for s in sides) else None
    print(f"{slug:<15} margin {margin:5.2f}%  top ${thin_top or 0:>10,.2f}  ladder ${thin_all or 0:>10,.2f}")

On the opener that prints the inversion in full:

Venue Patriots Seahawks Margin Back ladder, thinnest side
polymarket.us 1.600 2.632 0.49% 1.6 @ $0
novig.us 1.575 2.667 0.99% 2.667 @ $315, 2.632 @ $190, 2.5 @ $687
kalshi 1.587 2.632 1.01% 2.632 @ $103, 2.564 @ $9,940, 2.5 @ $5,298
polymarket 1.587 2.632 1.01% 1.587 @ $3, 1.538 @ $1,560, 1.515 @ $1,790
betfair-ex 1.520 2.700 2.83% 2.70 @ $2.33
pinnacle 1.552 2.590 3.04% $1,500 flat (no ladder)

Two things fall out of this table that a price-only feed hides.

The best price on the underdog is Betfair Exchange at 2.70, and it holds $2.33. If you run a line-shopping scan that sorts on price alone, that is the number it hands you, and it is worth about one coffee.

Kalshi’s headline rung is its worst rung. The top of its Seahawks ladder is $103 at 2.632, and the level underneath holds $9,940 at 2.564. Sweep two rungs and you get a slightly worse average price with a hundred times the size. Depth is not a single number, it is a curve, and the ladder is where the curve lives.

The screen worth copying

A single condition removes every unfillable quote in the table above without removing anything useful:

def usable(legs, max_margin=0.04, min_stake=500.0):
    """A prediction-market quote is only a benchmark if you can trade it."""
    margin = sum(1 / leg["price"] for leg in legs.values()) - 1
    if margin > max_margin:
        return False
    thin = min((depth(leg)[1] or 0.0) for leg in legs.values())
    return thin >= min_stake

Run across all 16 Week 1 fixtures, that screen passes kalshi 16 times out of 16, polymarket 14 times and novig.us 11 times. It rejects polymarket.us, sx.bet and betfair-ex on every fixture, and it rejects duel on every fixture too, for a different reason: Duel publishes no depth field at all, so there is nothing to screen. Tune min_stake to your own size. The point is that the check exists at all: run it before you treat any exchange quote as consensus.

Step 4: the Super Bowl benchmark, for free

Fifteen days out, an NFL regular-season game is a thin market everywhere. To see what a loaded one looks like, run the same measurement over a game that has already been played. Historical odds are free on OddsPapi, and retention is deep: Super Bowl LX, played 8 February 2026, still returns its full price history six months later.

Two rules for exchange history:

  • polymarket and betfair-ex reject a multi-book call. Both demand exactly one bookmaker and exactly one outcomeId, or you get HTTP 400 INVALID_PARAMETER. Put either slug in a three-book batch and the whole batch fails.
  • exchangeMeta is null on every historical snapshot. The ladder is a live-only structure. History gives you limit, which is top-of-book stake capacity. That is one number instead of three, and it is enough to chart the ramp.
SUPER_BOWL = "id1400003167426020"   # Patriots v Seahawks, 8 Feb 2026

def exchange_history(fixture_id, slug, outcome_id):
    status, data = get("historical-odds", fixtureId=fixture_id,
                       bookmakers=slug, outcomeId=outcome_id)
    if status != 200:
        return []
    market = data["bookmakers"][slug]["markets"]["141"]
    # players["0"] is a LIST here, not a dict. Historical shape != live shape.
    return market["outcomes"][str(outcome_id)]["players"]["0"]

snapshots = exchange_history(SUPER_BOWL, "polymarket", 142)
print(len(snapshots), "snapshots")          # 67865
print(snapshots[0]["createdAt"], snapshots[0]["price"], snapshots[0]["limit"])

Note the shape change. On /odds, players["0"] is a dict holding one current price. On /historical-odds the top-level key is bookmakers rather than bookmakerOdds, and players["0"] is a list of snapshots. Code written against one endpoint will not read the other.

Snapshots also keep recording after kick-off, so filter on createdAt < startTime for a true pre-game series. The tail is not noise, it is settlement: Polymarket’s losing side ends at a price of 500 and the winner collapses toward 1.0, which is how you recover the result without a scores feed.

What a loaded NFL market looks like

Super Bowl LX, Polymarket, both sides, pre-kickoff snapshots only. Median top-of-book stake capacity per day:

Day Days to kick-off Patriots, median Seahawks, median
1 Feb 8.0 $86,251 $1,482,381
3 Feb 6.0 $30,172 $1,689,255
5 Feb 4.0 $100,523 $1,816,374
7 Feb 2.0 $808,185 $1,931,302
8 Feb 1.0 $673,741 $1,504,591

Across the whole pre-game window Polymarket’s Seahawks side held a median $1,749,227 at the top of the book and peaked at $2,648,958. The Patriots side ran a median $629,882 with a $1,413,653 peak. That is a market where a five-figure bet is a rounding error.

Now put the two events side by side on the thinnest side, which is the only fair comparison:

Venue Super Bowl LX, thinnest side Week 1 opener, thinnest side Ratio
polymarket $629,882 median $3.15 top rung ~200,000x
betfair-ex $15,983 median $2.33 ~6,900x
kalshi no recorded history $751 top, $3,901 ladder (median)

Be careful with that comparison and I will be explicit about why: the Super Bowl figures are measured from 8 days out to kick-off, and the Week 1 figures are measured 14 days out. Liquidity is a function of time-to-event, so some of that gap closes on its own. The honest way to size the effect is to measure the ramp itself.

The ramp is about 7x, not 7,000x

Betfair Exchange is the better ramp subject here because its history starts earlier, 12.8 days before the Super Bowl. Median top-of-book stake capacity on the Patriots, by day:

Day Days to kick-off Median Peak
27 Jan 12.8 $4,426 $9,070
30 Jan 10.0 $15,200 $26,250
2 Feb 7.0 $13,331 $15,765
5 Feb 4.0 $27,923 $50,011
8 Feb 1.0 $31,304 $126,435

From 12.8 days out to game day the median grew 7.1x, and the peak grew 13.9x. Apply a generous 10x ramp to Betfair’s current Week 1 number and it reaches about $23. The Super Bowl figure at the same 12.8-day horizon was $4,426, roughly 1,900 times larger. The gap is not a timing artefact. A regular-season NFL game and a Super Bowl are different products on the same venue.

The useful consequence for anyone building on this: you cannot calibrate a depth threshold once and reuse it. A min_stake of $500 is trivially permissive at a Super Bowl and rejects most of the board in Week 1. Set it per event class, and re-measure.

Seven venues now, two at the Super Bowl

One more thing changed between February and September, and it is the reason this post is worth writing now. Running the same history call for each venue against Super Bowl LX:

Venue Super Bowl LX history Week 1 2026, live
polymarket 121k snapshots 16 of 16 fixtures
betfair-ex 40k snapshots 16 of 16
kalshi HTTP 404, none 16 of 16
polymarket.us HTTP 404, none 16 of 16
novig.us HTTP 404, none 16 of 16
sx.bet HTTP 404, none 16 of 16
duel not checked 16 of 16

The peer-to-peer side of an NFL board went from two venues to seven in seven months. Five of them have no NFL history at all before this season, which means any backtest that treats “the exchange price” as one continuous series will silently change definition partway through. Pin the venue list to the era you are testing.

Gotchas worth writing down

  • Kalshi’s historical payload is enormous. One NFL fixture, filtered to kalshi alone, returned 259.59 MB. Polymarket’s Super Bowl history is 5–7 MB per outcome and Betfair’s is about 2 MB. Never loop an exchange across a slate; spot-check single fixtures.
  • Rate limits are per endpoint and return a real 429 with a retryMs field. Use time.sleep(1.0) between /odds calls and about 4.5 seconds between /historical-odds calls. Do not parallelise: at any worker count, almost every request comes back 429.
  • A 429 body is valid JSON. Code that only checks for a bookmakerOdds key will read a rate-limit response as “no coverage”. Check the status code first.
  • limit is null on nearly every sportsbook. On the opener only Pinnacle and the exchanges publish it. Null-check before any arithmetic.
  • Pinnacle’s limit is a capped max win, not a capped stake. The identity limit = max(base, base / (price - 1)) recovers a per-market base figure, and that base is the book’s own confidence signal.
  • On exchanges the ladder identity is exact: per level, limit = size × cents. If it does not hold, you are parsing a different exchange shape.
  • No NFL player props exist on this board yet. Checked 26 August across Week 1 and a preseason game one day from kick-off: zero player-keyed prices on any fixture. Prop menus fill in closer to kick-off, so probe rather than assume.

Old way vs OddsPapi

Direct venue APIs OddsPapi
Venues for one NFL game Seven separate integrations, seven auth schemes One call, 169 bookmakers
Order-book depth Different shape per venue exchangeMeta.back / .lay, one shape
Odds format Share prices, American, fractional — convert yourself Decimal, American and fractional pre-converted
Sportsbook comparison Not available Pinnacle and 160+ books in the same payload
Historical depth Paid, or nonexistent Free tier, retained months after the game

Where to go next

Stop reading prices. Start reading ladders.

A prediction-market price with nothing behind it is a quote, not a market. The distinction costs nothing to check: the ladder is in the same payload as the price, on the free tier, on all 350+ bookmakers including Pinnacle, SBOBET, Kalshi, Polymarket and Betfair Exchange. Historical depth is free too, which is the only reason the Super Bowl comparison in this post was possible at all.

Get your free API key and run the depth screen on the Week 1 board yourself. It is about forty lines.