Kalshi & Polymarket NFL Odds: Prediction Market Data in Python
Kalshi and Polymarket both price NFL games, and on the 2026 season opener they beat every sportsbook on the board. Polymarket quoted the moneyline at a 0.98% margin. Kalshi came in at 1.02%. Pinnacle, the book everyone treats as the sharp benchmark, sat at 3.22%.
That comparison is real, and it is also the most misleading number in this post. We pulled the full order book behind both quotes. Polymarket’s 0.98% price had $7.22 of stake capacity at the top of the ladder. You can take that price. You cannot take it for anything that matters.
This guide shows you how to pull Kalshi and Polymarket NFL odds from one endpoint in Python, how to score them against 16 sportsbooks on the same fixture, and how to read the depth ladder so you know which prediction-market quote is a fair price and which one is an empty book. Every number below came off the live API on August 13, 2026.
Why You Cannot Just Call Kalshi and Polymarket Directly
You can. Both run public read APIs. Doing it means writing and maintaining two clients that agree on nothing.
Kalshi exposes contracts in cents with its own series and ticker taxonomy. NFL games live under a series key like kxnflgame, with per-game tickers such as kxnflgame-26sep09nesea. Polymarket splits its read surface across two services: Gamma for the catalogue and CLOB for the order book. Gamma returns stringified JSON arrays inside JSON, so outcomes and clobTokenIds need a second json.loads or you end up iterating over characters. Neither venue gives you decimal odds. Both hand you share prices between 0 and 1.
Then comes the part that kills the project. A prediction-market price only tells you something once you compare it to the sportsbook board. Kalshi at 1.538 on Seattle means nothing on its own. Kalshi at 1.538 while Pinnacle sits at 1.49 and DraftKings at 1.521 tells you where the disagreement is. So now you need a third integration, and a fourth.
OddsPapi carries Kalshi and Polymarket as bookmaker slugs alongside 350+ sportsbooks. One fixture ID, one call, decimal odds already converted, and the exchange depth ladder attached to every outcome.
| Task | Direct integration | OddsPapi |
|---|---|---|
| Venues to integrate | Kalshi API + Polymarket Gamma + Polymarket CLOB | One REST endpoint |
| NFL game lookup | Series tickers vs event slugs, different per venue | fixtureId, shared across all books |
| Odds format | Cents and share prices, convert yourself | price, priceAmerican, priceFractional supplied |
| Sportsbook comparison | Build it yourself, book by book | 18 books on the same payload |
| Order book depth | Separate CLOB call per token | exchangeMeta ladder on every outcome |
| Price history | Paid or unavailable at most vendors | Free tier, full snapshot history |
Step 1: Authenticate and Find the NFL
The API key travels as a query parameter. It is not a header. Every call below uses the same helper, which also honours the documented cooldown when the free tier returns HTTP 429.
import requests, time
API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"
def get(path, **params):
params["apiKey"] = API_KEY
r = requests.get(f"{BASE_URL}{path}", params=params, timeout=60)
if r.status_code == 429:
time.sleep(r.json()["error"]["retryMs"] / 1000 + 0.5)
return get(path, **{k: v for k, v in params.items() if k != "apiKey"})
r.raise_for_status()
return r.json()
tours = get("/tournaments", sportId=14)
nfl = [t for t in tours if t["tournamentName"] == "NFL"][0]
print(nfl["tournamentId"], nfl["futureFixtures"])
# 31 132
American football is sportId=14, and that sport also carries NCAA, CFL, AFLE and the preseason. Filter on the tournament name or hardcode 31 for the NFL. On August 13 it held 132 future fixtures.
Pull the Week 1 slate
fixtures = get("/fixtures", sportId=14, tournamentId=31,
**{"from": "2026-09-10", "to": "2026-09-16"})
fixtures.sort(key=lambda f: f["startTime"])
for f in fixtures[:3]:
print(f["fixtureId"], f["startTime"],
f["participant1Name"], "v", f["participant2Name"])
# id1400003171515752 2026-09-10T00:20:00.000Z Seattle Seahawks v New England Patriots
# id1400003171387370 2026-09-11T00:35:00.000Z Los Angeles Rams v San Francisco 49ers
# id1400003171515762 2026-09-13T17:00:00.000Z Indianapolis Colts v Baltimore Ravens
Two things will bite you here. An empty window returns HTTP 404 with a FIXTURE_NOT_FOUND body rather than an empty list, so a loop with raise_for_status() dies on the first quiet week. And to is a midnight-UTC instant, not a whole day, so set it to the day after the last date you want. The NFL schedule API guide walks the full-season pull and both gotchas.
Step 2: Get Every Book on One Fixture
PREDICTION_MARKETS = {"kalshi", "polymarket"}
odds = get("/odds", fixtureId="id1400003171515752")
books = odds["bookmakerOdds"]
print(len(books), sorted(set(books) & PREDICTION_MARKETS))
# 18 ['kalshi', 'polymarket']
Eighteen books on the season opener, and both prediction markets are among them. Each entry carries a fixturePath that deep-links the venue’s own page: https://kalshi.com/markets/kxnflgame/e#kxnflgame-26sep09nesea for Kalshi, https://polymarket.com/event/nfl-ne-sea-2026-09-10 for Polymarket. Use it for outbound clicks on a comparison page.
Step 3: Rank the Board by Margin
The odds payload nests four levels deep. A price lives at bookmakerOdds[slug]["markets"][market_id]["outcomes"][outcome_id]["players"]["0"]. The outcome object itself holds a single players key, so outcome["active"] is always undefined. Check active on the price object one level down.
MONEYLINE = "141" # Winner (incl. overtime), outcomes 141 / 142
def two_sided_prices(market):
prices = {}
for outcome_id, outcome in market["outcomes"].items():
quote = outcome["players"].get("0")
if quote and quote["active"] and quote["price"]:
prices[outcome_id] = quote["price"]
return prices if len(prices) == 2 else None
rows = []
for slug, book in books.items():
if book.get("suspended"): # book pulled the market, still returns prices
continue
market = book["markets"].get(MONEYLINE)
if not market:
continue
prices = two_sided_prices(market)
if not prices:
continue
margin = sum(1 / p for p in prices.values()) - 1
rows.append((margin, slug, prices))
for margin, slug, prices in sorted(rows):
print(f"{slug:18} {margin*100:5.2f}% {prices}")
Output on Seahawks v Patriots, 28 days before kickoff:
| Book | Moneyline margin | Seattle | New England |
|---|---|---|---|
| polymarket | 0.98% | 1.493 | 2.941 |
| kalshi | 1.02% | 1.538 | 2.778 |
| pinnacle | 3.22% | 1.49 | 2.77 |
| circasports | 3.27% | 1.526 | 2.65 |
| fanduel | 3.82% | 1.53 | 2.60 |
| caesars / williamhill | 4.19% | 1.508 | 2.64 |
| draftkings | 4.21% | 1.521 | 2.60 |
| bet365 / betmgm / borgata | 4.40% | 1.50 | 2.65 |
| hardrockbet | 4.75% | 1.526 | 2.55 |
| sbobet | 5.14% | 1.49 | 2.63 |
The pattern repeats on the other headline markets. Kalshi took the spread at 0.99% against Pinnacle’s 3.36% and DraftKings’ 5.26%. On the total, Kalshi ran 0.99% while Pinnacle charged 4.19%.
Dedupe before you read too much into the book count. On this fixture caesars and williamhill quote identical prices, as do betmgm and borgata, and ballybet with betparx and fourwinds. The cloneOf field in /v4/bookmakers does not flag these, so collapse on the price tuple yourself. Eighteen slugs come out as twelve independent quotes.
Step 4: Read the Ladder, Not the Headline Price
The margin table above is worth exactly as much as the money standing behind it. Sportsbooks ship exchangeMeta: null. Kalshi and Polymarket ship a three-level depth ladder on both sides of every outcome.
def back_ladder(quote):
meta = quote.get("exchangeMeta") or {} # null on sportsbooks, {} on some venues
return meta.get("back") or []
def stake_capacity(market):
"""Stake you can get down on the thinner side, across the whole visible ladder."""
caps = []
for outcome in market["outcomes"].values():
quote = outcome["players"].get("0")
if not quote:
continue
caps.append(sum(level.get("limit") or 0 for level in back_ladder(quote)))
return min(caps) if caps else 0.0
for slug in ("kalshi", "polymarket"):
market = books[slug]["markets"][MONEYLINE]
top = min((o["players"]["0"].get("limit") or 0)
for o in market["outcomes"].values())
print(f"{slug:12} top-of-book ${top:,.2f} full ladder ${stake_capacity(market):,.2f}")
# kalshi top-of-book $107.59 full ladder $8,990.74
# polymarket top-of-book $7.22 full ladder $454.62
Polymarket’s 0.98% moneyline holds $7.22 at the best price and $454.62 across three price levels. Kalshi’s 1.02% holds $107.59 at the top and $8,990.74 in total. Same fixture, same market, and close to twenty times the difference in how much money either venue will take.
Each ladder level carries price (decimal), size (payout available), cents (the native share price) and limit (stake needed to take that payout). The relationship is exact: limit = size × cents. Kalshi’s Patriots side stacked $107.59 at 2.778, then $8,336.11 at 2.703, then $547.04 at 2.632. Sweep two levels and your average price is nowhere near the screen quote.
If you are sizing real bets off this, the betting limits and stake sizing guide covers the same field on Pinnacle, where limit encodes a capped max win rather than a capped stake.
Kalshi Prices More NFL Markets Than Pinnacle
Market count is the one place Kalshi wins outright. On the opener it shipped 42 markets. Pinnacle shipped 25, Bet365 six, DraftKings four, FanDuel three.
| Book | Markets on the opener | Menu |
|---|---|---|
| kalshi | 42 | Spread ladder -20.5 to +14.5, totals 32.5 to 65.5, moneyline |
| pinnacle | 25 | Spreads, totals, team totals, moneyline |
| polymarket | 10 | Moneyline, four spreads, five totals |
| bet365 | 6 | Moneyline, two spreads, three totals |
| draftkings | 4 | Moneyline, spread, total, Anytime TD |
Now score those 42 markets instead of counting them. Only 12 of the 42 sit inside a 4% margin. The median is 5.02%. The wings of the ladder are brutal: the +9.5 spread ran a 28.99% margin, the 32.5 total 23.03%, and six more markets cleared 19%. Polymarket’s ten markets tell the same story from the other end, with four inside 4% and its -6.5 spread at 53.02%.
ladder = []
for market_id, market in books["kalshi"]["markets"].items():
prices = two_sided_prices(market)
if not prices:
continue
margin = sum(1 / p for p in prices.values()) - 1
ladder.append((margin, market_id, stake_capacity(market)))
tradeable = [m for m in ladder if m[0] < 0.04 and m[2] >= 500]
print(len(ladder), "markets;", len(tradeable), "inside 4% with $500+ behind them")
Two filters, and Kalshi’s 42-market menu shrinks to a handful. Run the same screen before you build anything that treats a prediction-market quote as consensus. The La Liga odds API guide found the identical trap on Polymarket’s soccer board, where 60 listed markets came down to nine that cleared both tests.
Far From Kickoff, Prediction Markets Are the Only Board
Coverage flips completely depending on how far out you look. We ran the moneyline across six fixtures at different distances from kickoff and recorded both the margin and the thin-side ladder.
| Fixture | Time to kickoff | Books | Polymarket margin | Thin-side ladder |
|---|---|---|---|---|
| Preseason PIT v GB | 10 hours | 18 | 1.00% | $20,817 |
| Preseason BUF v CAR | 2 days | 17 | 1.00% | $3,373 |
| Preseason HOU v LV | 8 days | 1 | 65.98% | $455 |
| Week 1 Thursday, SEA v NE | 28 days | 18 | 0.98% | $455 |
| Week 2 Thursday, BUF v DET | 36 days | 12 | 12.01% | $1,034 |
| Week 2 Sunday, ATL v CAR | 38 days | 4 | 92.96% | $164 |
Read the third row twice. That preseason game eight days out had exactly one bookmaker on it, and the bookmaker was Polymarket. No Pinnacle, no DraftKings, no Bet365. The fixture reports hasOdds: true and your parser sees a full payload, which is why hasOdds is a flag rather than a depth signal.
Kalshi behaves differently again. It covers Week 1 and stops. We sampled one fixture on each of the 40 NFL match days the feed carries, and Kalshi appeared on the September 10 to 15 slate and on nothing after it. Polymarket runs one week deeper, through the Week 2 games, then disappears from Week 3 onward. From late September the board thins to caesars, williamhill and draftkings, and two of those three share a feed.
So the 92.96% margin in the last row is not a prediction market being wrong. It is an empty order book with one resting bid on each side, and $164 behind the thinner one. Remember that the same venue prices the opener at 0.98%, and both numbers arrive through the identical parser.
Free Price History Proves the Point
/historical-odds ships the whole snapshot record on the free tier, which competitors either sell or withhold. The shape differs from the live endpoint in two ways: the top key is bookmakers rather than bookmakerOdds, and players["0"] is a list of snapshots rather than a single dict.
hist = get("/historical-odds", fixtureId="id1400003171515752",
bookmakers="polymarket") # max 3 bookmakers per call
snapshots = hist["bookmakers"]["polymarket"]["markets"]["141"]["outcomes"]["141"]["players"]["0"]
changes = sum(1 for a, b in zip(snapshots, snapshots[1:]) if a["price"] != b["price"])
print(len(snapshots), "snapshots,", changes, "price changes")
print(snapshots[0]["createdAt"], snapshots[0]["price"], "->",
snapshots[-1]["createdAt"], snapshots[-1]["price"])
# 1821 snapshots, 386 price changes
# 2026-07-30T13:10:45.262Z 1.031 -> 2026-08-13T13:07:35.465Z 1.493
Polymarket’s first recorded price on this game was 1.031 on Seattle, and the first price on New England was 1.053. Both sides quoted as near-certainties at the same moment, for a combined margin above 90%. Two weeks and 386 price changes later the number settled at 1.493, three ticks off Pinnacle’s 1.49.
Pinnacle’s history on the same fixture runs 36 snapshots and 12 price changes since June 2, drifting 1.448 to 1.49. The sharp book took ten weeks to move four ticks. The prediction market took two weeks to travel from noise to fair value.
Watch the payload size. That single Polymarket call returned 23.68 MB. The same fixture filtered to Pinnacle returned 0.18 MB. Exchange history records every book update, so pull prediction-market history one fixture at a time and never inside a season-wide loop. Sleep about 4.5 seconds between successful /historical-odds calls, against roughly 1 second on /odds.
What to Actually Build With This
| Use case | Which venue | Rule |
|---|---|---|
| Fair-price reference on Week 1 game lines | Kalshi | Deepest ladder and lowest margin together |
| Line shopping the moneyline | Both, plus 16 sportsbooks | Filter suspended, then dedupe clone feeds |
| Alt spreads and alt totals | Kalshi, screened | Drop anything above 4% margin or under $500 depth |
| Any fixture past Week 2 | Neither | The quote exists, the market does not |
| Player props | DraftKings and the US retail books | Prediction markets price game lines only |
The last row catches people out. Neither venue quoted a single player prop on the opener. Anytime TD (market 14388) and First TD (14390) come from the US retail books, keyed by player ID inside the players dict rather than the "0" key the game lines use.
For the wider NFL market map, spread and total market IDs, and the season-long pull, start with the NFL odds API guide. For how the two venues differ at the API level, the Kalshi vs Polymarket API comparison covers auth, taxonomy and rate limits. If you need to move between decimal odds and native share prices, the odds converter handles all four formats, and the prediction market API stack covers the other venues worth wiring up.
Get Your Free API Key
One endpoint, 350+ bookmakers, Kalshi and Polymarket sitting in the same payload as Pinnacle and DraftKings, with exchange depth ladders and full price history on the free tier. No sales call, no order-book integration, no share-price conversion.
Get your free OddsPapi key and pull the Week 1 board yourself.
FAQ
Does Kalshi cover the whole NFL season?
No. As of August 13, 2026, Kalshi appeared on the Week 1 slate only, from September 10 to 15. It was absent from every NFL fixture after that. Re-run the census as the season progresses, because coverage moves with the calendar.
Are Kalshi and Polymarket odds better than sportsbook odds?
On margin, yes. Both quoted the season opener under 1.1% against Pinnacle’s 3.22% and DraftKings’ 4.21%. On size, no. Polymarket held $7.22 at its best moneyline price and $454.62 across the visible ladder. Score both numbers before you treat a prediction-market quote as the fair price.
How do I get Polymarket share prices as decimal odds?
OddsPapi converts them for you. The price field is decimal, priceAmerican and priceFractional come as strings, and exchangeMeta.back[].cents keeps the native 0 to 1 share price if you want it. Calling Polymarket’s CLOB directly means converting with 1 / share_price yourself.
Why does a fixture show hasOdds true with only one bookmaker?
Because hasOdds tells you a price exists, not that a market exists. One preseason game eight days out carried Polymarket and nothing else. Probe /odds and count books before you rank coverage on a fixture list.
Can I backtest prediction-market NFL prices?
Yes, on the free tier. /historical-odds returned 1,821 Polymarket snapshots on one fixture going back to July 30. Budget for the size: that call was 23.68 MB against 0.18 MB for Pinnacle, and there is no market filter, so you download every market the venue priced.
Which market IDs do I need for NFL?
Moneyline is 141, with outcomes 141 and 142. Spreads and totals carry one market ID per line, so -3.5 is 14272 and the 44.5 total is 1464. Resolve them by name and handicap from /v4/markets?sportId=14 rather than hardcoding, because the line the books quote moves through the season.