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:

  1. Pages through every open market in a series
  2. Reads the order book the right way (it only lists bids, more on that below)
  3. Exports hourly price candles to CSV
  4. 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). GET requests under https://api.elections.kalshi.com/trade-api/v2 for 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 _dollars and _fp strings broke many older scripts without any error message. Log a warning when an expected field is missing instead of quietly writing None.
  • 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() Source:https://dev.to/viktorhendelmann/how-to-scrape-kalshi-with-python-markets-order-books-price-history-and-a-snapshot-tracker-2026-3a98

Related posts