Polymarket API & Kalshi API: Compare 1% Markets to Pinnacle

How To Guides January 2, 2026

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.us quotes the tightest NFL prices (0.26% median margin) and ships limit: 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 × cents held 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% on limit 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 one fixtureId.
  • The order book: three levels a side in exchangeMeta, with limit as the stake you can place.
  • Free historical odds: /v4/historical-odds replays the line. For polymarket, pass one bookmaker and one outcomeId per call; the history carries limit, 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.