Polymarket API & Kalshi API: Compare 1% Markets to Pinnacle
You want Polymarket and Kalshi prices in Python, next to a sportsbook line you trust. Polymarket gives you two read APIs (Gamma for the catalogue, CLOB for the order book) that quote share prices. Kalshi gives you a third API that quotes cents. Neither one tells you what Pinnacle or bet365 says about the same fixture.
OddsPapi carries both venues as bookmakers. One key and one /v4/odds call return polymarket, polymarket.us, kalshi, Pinnacle and 350+ sportsbooks in decimal odds, with the order book attached. This guide measures what that gets you, then shows the code.
We measured 88 upcoming fixtures on 13 October 2026 (13:11 to 13:20 UTC): 10 random fixtures each from soccer, basketball, tennis, baseball, ice hockey and CS2, 9 college football games, 5 MMA bouts and all 14 NFL Week 6 games. On the winner market, the two prediction markets ran a median margin near 1%. Pinnacle ran 4.02%.
Polymarket and Kalshi Next to the Sportsbooks: 88 Fixtures
| Venue (slug) | Two-way winner prices | Median margin | Thinner side, top limit (all / NFL) |
De-vigged gap to Pinnacle |
|---|---|---|---|---|
polymarket |
54 | 1.00% | $221 / $1,830 | 0.59 pts |
polymarket.us |
55 | 0.99% | $0 / $0 | 0.59 pts |
kalshi |
57 | 1.02% | $501 / $13,067 | 0.75 pts |
pinnacle |
60 | 4.02% | 758 / 6,000 | reference |
bet365 |
46 | 6.70% | not published | 0.83 pts |
Read the table in three passes:
- Margin. Polymarket and Kalshi quote the winner market at about a quarter of Pinnacle’s overround. That holds sport by sport on NFL (1.00% vs 3.22%), MMA (1.01% vs 3.88%) and baseball (1.00-1.01% vs 3.14%).
- Depth. On the NFL, Kalshi’s thinner side held a median $13,067 at the top of the book, and Polymarket held $1,830. Across all 88 fixtures those medians fall to $501 and $221.
polymarket.usquotes the tightest NFL prices (0.26% median margin) and shipslimit: 0, so treat it as a price signal, not a fill. - Agreement. Strip the margin out of each venue and compare the fair probabilities. Polymarket sat a median 0.59 points from Pinnacle, Kalshi 0.75 and bet365 0.83. The prediction markets price the same fixture the sharp book does, at a lower margin.
The NFL Week 6 board had all five venues on 13 of 14 games and four venues on the fourteenth. On the other sports the mix changes fixture by fixture, so the code below checks for each venue before it reads a price.
Direct APIs vs One OddsPapi Call
| Feature | Polymarket direct | Kalshi direct | OddsPapi |
|---|---|---|---|
| Sources | Polymarket | Kalshi | polymarket, polymarket.us, kalshi + 350+ sportsbooks |
| Price format | $0.00-$1.00 shares | 1-99 cent contracts | Decimal odds, with the native cents kept |
| Read auth | None for Gamma and CLOB reads | None for public market reads | One API key |
| Fixture matching | Map event slugs yourself | Map tickers yourself | One fixtureId across every venue |
| Order book | CLOB /book |
/markets/{ticker}/orderbook |
3 levels per side in exchangeMeta |
| Sportsbook benchmark | Second API | Second API | Same response |
| Price history | CLOB /prices-history |
Per-market candlesticks | /v4/historical-odds, free tier |
If you need Polymarket’s raw JSON, the Gamma vs CLOB field guide walks through both payloads. For a feature-by-feature read of the two venues’ own APIs, see Kalshi API vs Polymarket API.
Step 1: Authenticate and Discover Fixtures
The key goes in the query string as apiKey, never in a header. The helper below honours the retryMs hint that a 429 response carries, so a rate limit does not read as “no data”.
import os, time, requests
API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE_URL = "https://api.oddspapi.io/v4"
def get(path, **params):
params["apiKey"] = API_KEY
for _ in range(5):
r = requests.get(f"{BASE_URL}/{path}", params=params, timeout=60)
if r.status_code == 429: # honour the retry hint in the body
time.sleep(r.json()["error"].get("retryMs", 1500) / 1000 + 0.2)
continue
r.raise_for_status()
time.sleep(1.0)
return r.json()
raise RuntimeError(f"rate limited on {path}")
# NFL is sportId 14, tournamentId 31. "to" is the day AFTER the last day you want.
fixtures = get("fixtures", sportId=14, tournamentId=31,
**{"from": "2026-10-18", "to": "2026-10-19"})
for f in fixtures[:4]:
print(f["fixtureId"], f["participant1Name"], "v", f["participant2Name"], f["startTime"])
id1400003171488626 Jacksonville Jaguars v Houston Texans 2026-10-18T13:30:00.000Z
id1400003171515902 Atlanta Falcons v Chicago Bears 2026-10-18T17:00:00.000Z
id1400003171515904 Cleveland Browns v Baltimore Ravens 2026-10-18T17:00:00.000Z
id1400003171515906 Indianapolis Colts v Tennessee Titans 2026-10-18T17:00:00.000Z
Step 2: Fetch Polymarket, Kalshi, Pinnacle and bet365 in One Call
Pass the venues you want in bookmakers. Every price arrives as decimal odds. Market 141 is the NFL winner including overtime; outcome 141 is participant 1 (the home side, Kansas City here) and 142 is participant 2.
# get() is the helper from Step 1
FIXTURE = "id1400003171515918" # Kansas City Chiefs v Los Angeles Chargers
VENUES = ["polymarket", "kalshi", "pinnacle", "bet365"]
WINNER = "141" # NFL Winner (incl. overtime): outcome 141 = participant 1, 142 = participant 2
data = get("odds", fixtureId=FIXTURE, bookmakers=",".join(VENUES))
def winner_prices(book):
market = data["bookmakerOdds"].get(book, {}).get("markets", {}).get(WINNER)
if not market:
return None
out = {}
for oid, outcome in market["outcomes"].items():
p = outcome["players"].get("0")
if not p or not p.get("active"):
return None # one side suspended: no two-way price
out[oid] = (p["price"], p.get("limit"))
return out
for book in VENUES:
w = winner_prices(book)
if not w:
print(f"{book:11s} no active two-way price")
continue
margin = (sum(1 / price for price, _ in w.values()) - 1) * 100
legs = " ".join(f"{oid}: {price:.3f} (limit {limit})" for oid, (price, limit) in sorted(w.items()))
print(f"{book:11s} {legs} margin {margin:.2f}%")
polymarket 141: 1.266 (limit 3436.5) 142: 4.545 (limit 3592.25) margin 0.99%
kalshi 141: 1.266 (limit 90776.47) 142: 4.762 (limit 106.41) margin -0.01%
pinnacle 141: 1.239 (limit 18828.0) 142: 4.340 (limit 4500.0) margin 3.75%
bet365 141: 1.200 (limit None) 142: 4.350 (limit None) margin 6.32%
Kalshi’s margin reads -0.01%. Its two sides cost 79 and 21 cents, 100 cents together, so the margin rounds to zero before any trading fee. The thin side holds about $106. Size a near-zero margin before you call it an arbitrage.
Step 3: Read the Order Book in exchangeMeta
Each prediction-market price carries a 3-level back and lay ladder. Sportsbooks ship exchangeMeta: null, so guard with or {}.
# get() is the helper from Step 1
FIXTURE = "id1400003171515918"
data = get("odds", fixtureId=FIXTURE, bookmakers="polymarket,kalshi")
for book in ("polymarket", "kalshi"):
outcome = data["bookmakerOdds"][book]["markets"]["141"]["outcomes"]["141"]
meta = outcome["players"]["0"].get("exchangeMeta") or {}
print(book)
for side in ("back", "lay"):
for lvl in meta.get(side, []):
check = lvl["size"] * lvl["cents"] # stake = payout x share price
print(f" {side:4s} price {lvl['price']:.3f} cents {lvl['cents']:.3f} "
f"size {lvl['size']:>10,.2f} limit {lvl['limit']:>10,.2f} size*cents {check:>10,.2f}")
polymarket
back price 1.266 cents 0.790 size 4,350.00 limit 3,436.50 size*cents 3,436.50
back price 1.250 cents 0.800 size 37,076.40 limit 29,661.12 size*cents 29,661.12
back price 1.235 cents 0.810 size 9,739.27 limit 7,888.81 size*cents 7,888.81
lay price 4.545 cents 0.220 size 16,328.42 limit 3,592.25 size*cents 3,592.25
lay price 4.348 cents 0.230 size 12,492.24 limit 2,873.22 size*cents 2,873.22
lay price 4.167 cents 0.240 size 7,718.00 limit 1,852.32 size*cents 1,852.32
kalshi
back price 1.266 cents 0.790 size 114,906.92 limit 90,776.47 size*cents 90,776.47
back price 1.250 cents 0.800 size 349,245.85 limit 279,396.68 size*cents 279,396.68
back price 1.235 cents 0.810 size 515,317.73 limit 417,407.36 size*cents 417,407.36
lay price 4.545 cents 0.220 size 333,559.99 limit 73,383.20 size*cents 73,383.20
lay price 4.348 cents 0.230 size 43,800.53 limit 10,074.12 size*cents 10,074.12
lay price 4.167 cents 0.240 size 2,733.16 limit 655.96 size*cents 655.96
price: decimal odds for that level.cents: the native share price, 0.79 = 79% implied.size: the payout available at that level.limit: the stake that takes it.limit = size × centsheld on all 12 levels above.
The headline price is not always the deep one. Kalshi held $90,776 at 1.266 on Kansas City, while Polymarket held $3,437 at the same price and $29,661 one tick worse at 1.250. The NFL depth-ladder study ranks seven venues on this.
Step 4: De-Vig Both Venues Before You Compare
A raw price comparison flags every Polymarket quote as an edge, because Polymarket runs about 1% margin and Pinnacle about 4%. Take the margin out of Pinnacle first, then price each venue against that fair line. The function below also drops suspended sides and prices older than 24 hours.
from datetime import datetime, timezone
# get() is the helper from Step 1
FIXTURE = "id1400003171515918"
VENUES = ["polymarket", "polymarket.us", "kalshi", "pinnacle", "bet365"]
WINNER = "141"
data = get("odds", fixtureId=FIXTURE, bookmakers=",".join(VENUES))
now = datetime.now(timezone.utc)
def two_way(book, max_age_h=24):
"""Active, fresh, two-sided winner prices for one venue, or None."""
market = data["bookmakerOdds"].get(book, {}).get("markets", {}).get(WINNER)
if not market:
return None
legs = {}
for oid, outcome in market["outcomes"].items():
p = outcome["players"].get("0")
if not p or not p.get("active") or p["price"] <= 1:
return None
age = now - datetime.fromisoformat(p["changedAt"].replace("Z", "+00:00"))
if age.total_seconds() > max_age_h * 3600:
return None # a withdrawn market keeps its last price
legs[oid] = p
return legs if len(legs) == 2 else None
def no_vig(legs):
z = sum(1 / p["price"] for p in legs.values())
return {oid: (1 / p["price"]) / z for oid, p in legs.items()}
pin = two_way("pinnacle")
fair = no_vig(pin)
print("Pinnacle no-vig: " + " ".join(f"{o} {q:.4f}" for o, q in sorted(fair.items())))
for book in VENUES:
legs = two_way(book)
if not legs:
print(f"{book:13s} skipped (no active two-way price)")
continue
gap = max(abs(no_vig(legs)[o] - fair[o]) for o in fair) * 100
for oid, p in sorted(legs.items()):
ev = (p["price"] * fair[oid] - 1) * 100 # expected value against Pinnacle's fair line
stake = p.get("limit")
flag = " <-- beats Pinnacle fair" if ev > 0 and (stake is None or stake > 0) else ""
print(f"{book:13s} {oid} {p['price']:.3f} EV {ev:+.2f}% limit {stake}{flag}")
print(f"{'':13s} de-vigged gap to Pinnacle {gap:.2f} pts")
Pinnacle no-vig: 141 0.7779 142 0.2221
polymarket 141 1.266 EV -1.52% limit 3436.5
polymarket 142 4.545 EV +0.94% limit 3592.25 <-- beats Pinnacle fair
de-vigged gap to Pinnacle 0.42 pts
polymarket.us 141 1.262 EV -1.83% limit 0.0
polymarket.us 142 4.706 EV +4.51% limit 0.0
de-vigged gap to Pinnacle 1.06 pts
kalshi 141 1.266 EV -1.52% limit 90776.47
kalshi 142 4.762 EV +5.76% limit 106.41 <-- beats Pinnacle fair
de-vigged gap to Pinnacle 1.21 pts
pinnacle 141 1.239 EV -3.62% limit 18828.0
pinnacle 142 4.340 EV -3.62% limit 4500.0
de-vigged gap to Pinnacle 0.00 pts
bet365 141 1.200 EV -6.65% limit None
bet365 142 4.350 EV -3.39% limit None
de-vigged gap to Pinnacle 0.59 pts
Three quotes beat Pinnacle’s fair price on the Chargers. Only two of them count:
kalshi+5.76% on $106 of stake. Real, but small.polymarket+0.94% on $3,592. Smaller edge, about 34 times the room.polymarket.us+4.51% onlimit 0.0. The script skips it, because nothing stands behind the price.
For a cross-book version that dedupes sportsbook feeds and screens out stale quotes across full boards, see the Polymarket arbitrage census.
Step 5: Watch Polymarket Move
Polymarket re-stamps changedAt on prices that did not move. Compare the price, not the timestamp, or you count noise as movement.
import time
# get() is the helper from Step 1
FIXTURE = "id1400003171515918"
def snapshot(book):
data = get("odds", fixtureId=FIXTURE, bookmakers=book)
snap = {}
for mid, market in data["bookmakerOdds"].get(book, {}).get("markets", {}).items():
for oid, outcome in market["outcomes"].items():
for pid, p in outcome["players"].items(): # props key players by ID, not "0"
if p.get("active"):
snap[(mid, oid, pid)] = (p["price"], p["changedAt"])
return snap
def poll(book="polymarket", polls=4, interval=30):
"""A bounded poll. Each call costs one request: use the WebSocket for a live loop."""
prev = snapshot(book)
print(f"{book}: {len(prev)} active prices")
for i in range(1, polls):
time.sleep(interval)
cur = snapshot(book)
moved = [k for k in cur if k in prev and cur[k][0] != prev[k][0]]
stamped = [k for k in cur if k in prev and cur[k][0] == prev[k][0] and cur[k][1] != prev[k][1]]
print(f"poll {i}: {len(moved)} prices moved, {len(stamped)} re-stamped at the same price")
for k in moved[:3]:
print(f" market {k[0]} outcome {k[1]}: {prev[k][0]:.3f} -> {cur[k][0]:.3f}")
prev = cur
poll()
polymarket: 504 active prices
poll 1: 1 prices moved, 17 re-stamped at the same price
market 14344 outcome 14345: 23.256 -> 20.833
poll 2: 1 prices moved, 14 re-stamped at the same price
market 14344 outcome 14345: 20.833 -> 19.231
poll 3: 3 prices moved, 9 re-stamped at the same price
market 14344 outcome 14345: 19.231 -> 17.857
market 144100 outcome 144100: 1.010 -> 1.020
market 14284 outcome 14285: 1.695 -> 1.724
Each poll is one request. The free plan gives you 250 requests a month, so a 30-second loop spends it in about two hours. For a live monitor, subscribe to the stream: the WebSocket guide counts what a poll misses between calls.
Kalshi’s Own API for Elections, Economics and Weather
This guide covers sports. Kalshi also lists contracts on the economy, politics and the weather, and its public read endpoint returns them without a key:
import requests
# Kalshi's public read endpoint: no key, no signature
r = requests.get("https://api.elections.kalshi.com/trade-api/v2/events",
params={"status": "open", "limit": 200}, timeout=30)
r.raise_for_status()
events = r.json()["events"]
for e in events:
if e["category"] in ("Economics", "Climate and Weather"):
print(e["category"], "|", e["event_ticker"], "|", e["title"])
break
print(len(events), "open events on this page; categories:",
sorted({e["category"] for e in events}))
Climate and Weather | KXWARMING-50 | Will the world pass 2 degrees Celsius over pre-industrial levels before 2050?
200 open events on this page; categories: ['Climate and Weather', 'Companies', 'Economics', 'Elections', 'Entertainment', 'Financials', 'Health', 'Politics', 'Science and Technology', 'Social', 'Sports', 'World']
Conversion Reference
OddsPapi hands you decimal odds and keeps the native cents field on prediction-market ladders. If you read Polymarket or Kalshi directly, these three lines convert their formats:
def share_to_decimal(share_price):
"""Polymarket $0.56 share -> 1.786 decimal odds"""
return 1 / share_price
def cents_to_decimal(cents):
"""Kalshi 56 cents -> 1.786 decimal odds"""
return 1 / (cents / 100)
def american_to_decimal(american):
"""-127 -> 1.787 decimal odds"""
return american / 100 + 1 if american > 0 else 100 / abs(american) + 1
print(f"{share_to_decimal(0.56):.3f} {cents_to_decimal(56):.3f} {american_to_decimal(-127):.3f}")
1.786 1.786 1.787
| Source format | Example | Implied probability | Decimal odds |
|---|---|---|---|
| Polymarket share | $0.56 | 56% | 1.786 |
| Kalshi contract | 56 cents | 56% | 1.786 |
| American odds | -127 | 56% | 1.787 |
The odds converter post adds fractional odds and the de-vig maths.
What You Get From One Key
- 350+ bookmakers in one response, including
polymarket,polymarket.us,kalshi, Pinnacle and bet365, on onefixtureId. - The order book: three levels a side in
exchangeMeta, withlimitas the stake you can place. - Free historical odds:
/v4/historical-oddsreplays the line. Forpolymarket, pass one bookmaker and oneoutcomeIdper call; the history carrieslimit, and the ladder is live-only. The backtesting guide shows the full loop. - WebSockets for the moves a poll misses.
Frequently Asked Questions
Do I need a Polymarket wallet to get Polymarket data?
No. Polymarket’s Gamma and CLOB read endpoints need no wallet, and OddsPapi returns the polymarket and polymarket.us slugs with one API key. You need a wallet only to place orders on Polymarket.
How do Polymarket and Kalshi margins compare to sportsbooks?
On 88 fixtures measured on 13 October 2026, the median winner-market margin was 1.00% on polymarket, 1.02% on kalshi, 4.02% on Pinnacle and 6.70% on bet365. De-vigged, Polymarket sat a median 0.59 points from Pinnacle’s fair probability and Kalshi 0.75.
What do size and limit mean in exchangeMeta?
size is the payout available at a ladder level and limit is the stake that takes it. limit equals size × cents, where cents is the native share price. Check limit before you treat a price as fillable.
Which venue holds more NFL depth, Kalshi or Polymarket?
On the 14 NFL Week 6 games measured on 13 October 2026, Kalshi’s thinner side held a median $13,067 at the top of the book and Polymarket’s held $1,830. Pinnacle’s thinner side carried a median limit of 6,000.
Can I get Polymarket price history through OddsPapi?
Yes. Call /v4/historical-odds with bookmakers=polymarket and one outcomeId. Each snapshot carries the price and the top-of-book limit. The three-level ladder is in live responses.
Start With Your Own Fixture
Swap the fixture ID in Step 2 for any game on your slate. If polymarket or kalshi is missing from the response, that venue has not listed the fixture yet; the code skips it and keeps the rest.