Kalshi has become one of the more interesting public datasets around. Every market is a running, money-backed estimate of how likely something is: an election, a Fed decision, tomorrow's high temperature in New York. If you do research, build dashboards, or want to compare Kalshi against polls or other prediction markets, you'll want that data in your own database.
The good news is that you don't need to scrape HTML at all. The Kalshi API serves market data publicly, with no key and no login. The less good news is that it has a handful of quirks that will quietly give you wrong numbers if you don't know about them.
In this post I'll build a small Python Kalshi scraper that:
- Pages through every open market in a series
- Reads the order book the right way (it only lists bids, more on that below)
- Exports hourly price candles to CSV
- Stores a snapshot on every run in SQLite, so you can see which markets moved
Everything here was run against the live API in October 2026.
Before you start: read Kalshi's API docs and Terms of Service, keep request rates low, and don't redistribute the data commercially without checking what you're allowed to do. This tutorial only reads public market data. It doesn't place orders and doesn't need an account.
Your options, briefly
-
Public market data endpoints (this post).
GETrequests underhttps://api.elections.kalshi.com/trade-api/v2for markets, events, series, order books, trades, and candlesticks. No auth needed. - Authenticated API. You need an account and an RSA-signed API key for anything that touches your portfolio: orders, fills, balance, and a few account endpoints. You don't need it for reading markets.
- WebSocket feed. Better if you want real-time ticks, but it needs an authenticated connection and you have to handle reconnects yourself. For a snapshot every few minutes, REST is simpler.
- Scraping kalshi.com HTML. Don't bother. The site is a JavaScript app that calls the same API anyway.
How Kalshi data is shaped
There are three levels, and the tickers tell you where you are:
Series KXHIGHNY "Highest temperature in NYC" (recurring)
Event KXHIGHNY-26OCT21 one occurrence: Oct 21, 2026
Market KXHIGHNY-26OCT21-T66 one yes/no contract: "above 66°?"
A market is always a binary yes/no contract that pays $1. Prices are in dollars between 0 and 1, so a YES price of 0.23 means the market puts it at roughly 23%.
To get a series ticker from a market page, look at the URL. On kalshi.com/markets/kxhighny/..., the first path segment uppercased (KXHIGHNY) is the series ticker.
Step 1: A polite request helper
Install the one dependency:
pip install requests
The Kalshi docs say a rate-limited request returns 429 with {"error": "too many requests"}, no Retry-After header, and no penalty. So you just back off and try again. Authenticated Basic-tier accounts get a read budget of about 20 requests per second. I couldn't find a documented number for anonymous calls, so I stay far below that.
import csv
import sqlite3
import sys
import time
from datetime import datetime, timezone
from decimal import Decimal
import requests
BASE = "https://api.elections.kalshi.com/trade-api/v2"
session = requests.Session()
session.headers.update({"User-Agent": "kalshi-scraper-tutorial/1.0 (you@example.com)"})
def get(path, params=None, max_retries=5):
"""GET with backoff on 429 and 5xx."""
for attempt in range(max_retries):
res = session.get(f"{BASE}{path}", params=params, timeout=20)
if res.status_code == 429 or res.status_code >= 500:
wait = 2 ** attempt
print(f"{res.status_code} on {path}, sleeping {wait}s", file=sys.stderr)
time.sleep(wait)
continue
res.raise_for_status()
return res.json()
raise RuntimeError(f"gave up on {path} after {max_retries} tries")
Setting a descriptive User-Agent with a contact address costs nothing and makes it easier for the other side if your script ever misbehaves.
Step 2: The core of the Kalshi scraper, paging markets with a cursor
GET /markets takes series_ticker, event_ticker, status, limit (I use 200), and cursor. Each response includes a cursor. When it's empty, you're done.
def iter_markets(series_ticker, status="open", page_size=200):
cursor = None
while True:
params = {"series_ticker": series_ticker, "status": status, "limit": page_size}
if cursor:
params["cursor"] = cursor
data = get("/markets", params)
yield from data.get("markets", [])
cursor = data.get("cursor")
if not cursor:
break
time.sleep(0.25) # be polite
Two things surprised me here.
The status you filter by isn't the status you get back. You ask for status=open, but the records come back with "status": "active". If you filter again on your side with == "open", you'll get zero rows and spend ten minutes thinking pagination is broken.
Unfiltered listings are full of combo markets. If you call /markets?status=open without a series or event, the first pages are auto-generated multi-leg "combo" markets with tickers like KXMVECROSSCATEGORY-..., mostly with zero volume. Add mve_filter=exclude to leave them out, or better, always scope by series_ticker or event_ticker.
Step 3: Turn markets into clean rows
Here's the part that bites people who copy older tutorials: Kalshi sends prices and sizes as strings, with suffixes on the field names. Older posts use integer-cent fields like yes_bid and volume. What you get now looks like this:
"yes_bid_dollars": "0.0100",
"yes_ask_dollars": "0.0200",
"last_price_dollars": "0.0200",
"volume_fp": "2428.47",
"open_interest_fp": "2252.09"
_dollars is a price, _fp is a fixed-point count (contracts can be fractional now). I parse them as Decimal so I never get 0.30000000000000004 in a report:
def d(value):
"""Kalshi sends prices and sizes as strings. Keep them exact."""
return Decimal(value) if value not in (None, "") else None
def to_row(m):
return {
"ticker": m["ticker"],
"event_ticker": m["event_ticker"],
"title": m.get("title"),
"subtitle": m.get("yes_sub_title"),
"status": m.get("status"),
"close_time": m.get("close_time"),
"yes_bid": d(m.get("yes_bid_dollars")),
"yes_ask": d(m.get("yes_ask_dollars")),
"last_price": d(m.get("last_price_dollars")),
"volume": d(m.get("volume_fp")),
"volume_24h": d(m.get("volume_24h_fp")),
"open_interest": d(m.get("open_interest_fp")),
}
If you read a field that doesn't exist anymore, .get() just returns None. So when your dashboard shows empty columns after a Kalshi update, check the field names first.
Step 4: Read the order book the right way
GET /markets/{ticker}/orderbook returns this:
{"orderbook_fp": {
"no_dollars": [["0.9600","441.00"],["0.9700","592.00"],["0.9800","398.00"]],
"yes_dollars": [["0.0100","286.82"]]
}}
Notice that there are no asks. Kalshi only lists bids for YES and bids for NO. Because a YES and a NO together always pay $1, a NO bid at 0.98 works the same as a YES ask at 0.02. So the best YES ask is 1 - best NO bid:
def best_levels(ticker):
ob = get(f"/markets/{ticker}/orderbook", {"depth": 5})["orderbook_fp"]
yes_bids = ob.get("yes_dollars") or []
no_bids = ob.get("no_dollars") or []
best_yes_bid = max((Decimal(p) for p, _ in yes_bids), default=None)
best_no_bid = max((Decimal(p) for p, _ in no_bids), default=None)
# Only bids are listed. A NO bid at 0.97 is a YES ask at 0.03.
implied_yes_ask = (Decimal("1") - best_no_bid) if best_no_bid is not None else None
return best_yes_bid, implied_yes_ask
Levels are sorted with the best price last, which is why I use max() instead of trusting the order. An empty side comes back as missing or empty, so handle None. In my run, one of the busiest markets had no YES bids at all, because it was all but settled at zero.
Step 5: Snapshots in SQLite
The market list only tells you the current state. To see what moved, store a snapshot on every run:
SCHEMA = """
CREATE TABLE IF NOT EXISTS snapshots (
taken_at TEXT NOT NULL,
ticker TEXT NOT NULL,
event_ticker TEXT,
title TEXT,
yes_bid TEXT,
yes_ask TEXT,
last_price TEXT,
volume TEXT,
open_interest TEXT,
PRIMARY KEY (taken_at, ticker)
);
"""
def snapshot(series_ticker, db_path="kalshi.db"):
taken_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
rows = [to_row(m) for m in iter_markets(series_ticker)]
con = sqlite3.connect(db_path)
con.executescript(SCHEMA)
con.executemany(
"""INSERT OR IGNORE INTO snapshots VALUES
(:taken_at, :ticker, :event_ticker, :title, :yes_bid, :yes_ask,
:last_price, :volume, :open_interest)""",
[{**r, **{k: str(r[k]) if r[k] is not None else None
for k in ("yes_bid", "yes_ask", "last_price", "volume", "open_interest")},
"taken_at": taken_at} for r in rows],
)
con.commit()
con.close()
return rows
I store numbers as text so the Decimal values round-trip exactly. Cast them when you query. Here's "which markets moved the most since the last run":
WITH ranked AS (
SELECT ticker, title, taken_at, CAST(last_price AS REAL) AS price,
ROW_NUMBER() OVER (PARTITION BY ticker ORDER BY taken_at DESC) AS rn
FROM snapshots
)
SELECT a.ticker, b.price AS before, a.price AS now,
ROUND(a.price - b.price, 4) AS move
FROM ranked a JOIN ranked b ON a.ticker = b.ticker AND b.rn = 2
WHERE a.rn = 1
ORDER BY ABS(a.price - b.price) DESC
LIMIT 10;
Run it from cron every 15 minutes and you have a basic movers report.
Step 6: Hourly price history to CSV
For history you don't need your own snapshots. The candlesticks endpoint needs the series ticker in the path as well as the market ticker. That's easy to miss:
def candles(series_ticker, ticker, hours=48, interval=60):
end = int(time.time())
start = end - hours * 3600
data = get(
f"/series/{series_ticker}/markets/{ticker}/candlesticks",
{"start_ts": start, "end_ts": end, "period_interval": interval},
)
out = []
for c in data.get("candlesticks", []):
price = c.get("price", {})
out.append({
"end": datetime.fromtimestamp(c["end_period_ts"], timezone.utc).isoformat(),
"close": price.get("close_dollars"), # None when nothing traded
"yes_bid_close": c.get("yes_bid", {}).get("close_dollars"),
"yes_ask_close": c.get("yes_ask", {}).get("close_dollars"),
"volume": c.get("volume_fp"),
"open_interest": c.get("open_interest_fp"),
})
return out
period_interval is in minutes and accepts 1, 60, or 1440. Each candle has price (trades), yes_bid and yes_ask (quotes), plus volume and open interest. In hours with no trades, the price values can come back empty. Don't fill those gaps with zeros, or your chart will show a crash that never happened.
Putting the Kalshi scraper together
if __name__ == "__main__":
series = sys.argv[1] if len(sys.argv) > 1 else "KXHIGHNY"
rows = snapshot(series)
rows.sort(key=lambda r: r["volume_24h"] or 0, reverse=True)
print(f"{len(rows)} open markets in {series}")
for r in rows[:5]:
print(f'{r["ticker"]:<28} bid {r["yes_bid"]} ask {r["yes_ask"]} 24h vol {r["volume_24h"]} {r["subtitle"]}')
if rows:
top = rows[0]["ticker"]
bid, ask = best_levels(top)
print(f"\nOrder book for {top}: best YES bid {bid}, implied YES ask {ask}")
history = candles(series, top)
with open(f"{top}.csv", "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=history[0].keys() if history else ["end"])
w.writeheader()
w.writerows(history)
print(f"wrote {len(history)} hourly candles to {top}.csv")
Real output from a run on the NYC temperature series:
12 open markets in KXHIGHNY
KXHIGHNY-26OCT20-B69.5 bid 0.0000 ask 0.0100 24h vol 63785.26 69° to 70°
KXHIGHNY-26OCT20-B71.5 bid 0.0000 ask 0.0100 24h vol 45825.52 71° to 72°
KXHIGHNY-26OCT20-B65.5 bid 0.0000 ask 0.0100 24h vol 33171.92 65° to 66°
KXHIGHNY-26OCT20-B67.5 bid 0.9900 ask 1.0000 24h vol 27363.99 67° to 68°
Order book for KXHIGHNY-26OCT20-B69.5: best YES bid None, implied YES ask 0.0100
wrote 38 hourly candles to KXHIGHNY-26OCT20-B69.5.csv
One more quirk shows up in that output. Yesterday's markets are still in the open list, priced at 0.01 or 0.99, because trading has stopped but they haven't been settled yet. If you're looking for live opinion, filter on close_time in the future, or you'll be ranking markets that are already decided.
The walls you'll hit
-
Schema drift. The move from integer cents to
_dollarsand_fpstrings broke many older scripts without any error message. Log a warning when an expected field is missing instead of quietly writingNone. - Rate limits without hints. A 429 doesn't tell you how long to wait. Use exponential backoff and a small sleep between pages. If you're polling hundreds of order books, you'll hit the limit faster than you expect.
- Volume and pagination. Some series, like sports and crypto, have thousands of markets. Scope by series or event and limit columns to what you need.
- Settled vs. closed vs. active. Status values in filters and records don't match one to one. Write down which states you care about and test them.
- What isn't in the public API. Trader profiles, the leaderboard, and comments are on the website but not in the public market data endpoints. Scraping those means dealing with the web app itself, which is much more fragile.
- Terms. Market data is public, but that doesn't make every use OK. Read Kalshi's terms before you publish or resell anything.
When DIY stops being worth it
For a few series checked every 15 minutes, the script above is all you need, and it's free. It gets tedious when you track a lot of series, want Polymarket and Kalshi in the same format, or need the profile and leaderboard data that the public market endpoints don't have.
At that point I'd use a hosted API. I use ScrapeAtlas* for this. Its /v1/kalshi/markets endpoint takes series, event, tickers, status, closesAfter/closesBefore, cursor, and limit (1 to 200). It leaves out combo markets by default (combos=include brings them back) and returns the same _dollars/_fp field names you saw above, plus a nextCursor:
import os
import requests
def api_markets(series, cursor=None):
params = {"series": series, "status": "open", "limit": 200}
if cursor:
params["cursor"] = cursor
res = requests.get(
"https://api.scrapeatlas.com/v1/kalshi/markets",
params=params,
headers={"x-api-key": os.environ["SCRAPEATLAS_API_KEY"]},
timeout=30,
)
res.raise_for_status()
data = res.json()
# data["status"]: "success" or "partial"; data["coverage"]["warnings"] explains gaps
return data.get("markets", []), data.get("nextCursor")
Because the field names match, to_row() works unchanged. There are also /v1/kalshi/price-history, /v1/kalshi/orderbook, /v1/kalshi/trades, /v1/kalshi/leaderboard, and /v1/kalshi/profile endpoints if you need history, depth, or trader data.
Wrapping up
-
Use the public API, not HTML. Kalshi market data is a
GETrequest away, with no key. -
Parse
_dollarsand_fpstrings asDecimal, and don't trust tutorials that use integer-cent fields. - The order book only has bids. YES ask = 1 − best NO bid.
- Scope by series or event, exclude combos, and filter out markets that are closed but not yet settled.
- Snapshot on a schedule if you care about movement. Use candlesticks if you only need history.
If something in the API changes and the script breaks, leave a comment and I'll update the post.
*I build it.













