Originally published on BotCookery, free build recipes for AI-built trading and prediction-market bots. Written with AI assistance. Every output below is from a real run on Oct 25, 2026. Nothing here is financial advice, and nothing in this code can place an order.

Our Kalshi API starter is a prompt: paste it into Claude Code, Cursor, Codex or Grok, and you get a small read-only Python CLI for the Kalshi Trade API v2. It covers markets, order books, trades, a watch loop, and signed balance/position calls.

On Sunday we tested it the only way that counts. We pasted the published prompt into an empty folder, let it build, and pointed the result at live Kalshi data. The signing code was right first time. Almost everything around it had a problem. Here's what broke and how the prompt changed.

1. Production died on the first command

From our server, the main production host answered every request with an HTML 403:

requests.exceptions.HTTPError: 403 Client Error: Forbidden for url: https://external-api.kalshi.com/trade-api/v2/markets?status=open&limit=20&mve_filter=exclude 

Kalshi's docs list api.elections.kalshi.com as an "also supported" production host. Despite the name, it serves all markets, and it answered fine from the same machine (we rechecked at 1:13 p.m. ET: still 403 on one host, 200 on the other). The fix is a one-time fallback with a visible note. Every other HTTP error now prints one line instead of a traceback:

note: external-api.kalshi.com returned 403 from this network; using api.elections.kalshi.com [PROD] real exchange data 

Signatures cover only timestamp + METHOD + /trade-api/v2/..., so the same signing code works on both hosts.

2. Empty markets looked free

The first demo screen:

KXRAIN-26OCT26-WPB yes bid 0.0000 (0%) ask 0.0000 Will it rain in West Palm Beach on Oct 26, 2026? 

An ask of $0.00 doesn't mean YES is free. It means nobody is selling. Of 10,000 open demo markets we scanned, 9,705 had no ask, and the API showed that as 0.0000 on some markets and 1.0000 on others. The price field can't tell you a side is empty. The size field can: yes_ask_size_fp == "0.00". The rebuild checks size first and prints — for an empty side.

3. The default market list was dead

markets printed the first 20 open markets the API returned, which were mostly brand-new and unquoted. Production has more than 60,000 open markets (we paged through 60,000 in 51 seconds and still had a cursor), and /markets isn't sorted by volume. The rebuilt command scans a few pages, keeps two-sided markets, sorts by 24-hour volume and says plainly that it's a sample. --series jumps straight to a busy category. This is from 1:17 p.m. ET, just after the early NFL kickoffs:

$ KALSHI_ENV=prod python cli.py markets --series KXNFLGAME --top 6 [PROD] real exchange data TICKER BID ASK SPRD 24H VOL CLOSES TITLE KXNFLGAME-26OCT25SFATL-SF 62¢ 63¢ 1¢ 3,011,623 1d San Francisco KXNFLGAME-26OCT25CINBAL-CIN 45¢ 46¢ 1¢ 2,704,971 1d Cincinnati KXNFLGAME-26OCT25NYGHOU-NYG 21¢ 22¢ 1¢ 2,671,110 1d New York G KXNFLGAME-26OCT25SFATL-ATL 37¢ 38¢ 1¢ 1,202,528 1d Atlanta KXNFLGAME-26OCT25CLETEN-CLE 29¢ 30¢ 1¢ 1,118,062 1d Cleveland KXNFLGAME-26OCT25INDMIN-MIN 59¢ 60¢ 1¢ 1,095,654 1d Minnesota scanned 52 open markets (all pages scanned), 52 had both sides quoted quotes here are the market snapshot; `book <ticker>` reads the live order book 

4. A typo looked like an empty market

GET /markets/{ticker}/orderbook and GET /markets/trades return 200 with an empty result for a ticker that doesn't exist. Only GET /markets/{ticker} returns 404. The old build printed blank ladders for KXNOPE-NOTREAL. Now book, trades and watch look up the market first:

$ KALSHI_ENV=prod python cli.py book KXNOPE-NOTREAL [PROD] real exchange data not found on prod: demo and prod list different markets; pick a ticker from `markets` in the same env 

5. watch said nothing traded during an NFL game

We fixed bugs 1 to 4 that morning. In the afternoon we rebuilt from scratch again and found a fifth. Kalshi's order book only lists bids, YES and NO, best last. The YES ask is 1.00 minus the best NO bid, and the CLI works that out for you:

$ KALSHI_ENV=prod python cli.py book KXNFLGAME-26OCT25SFATL-SF [PROD] real exchange data YES bids (best last): 58¢ x 25,727, 59¢ x 445, 60¢ x 9,396, 61¢ x 30,602, 62¢ x 78,614 NO bids (best last): 33¢ x 1,145, 34¢ x 10,184, 35¢ x 82,737, 36¢ x 178,735, 37¢ x 166,245 best YES bid $0.62 (62%) | implied YES ask $0.63 (63%) | spread 1¢ | mid $0.625 (62.5%) implied NO ask $0.38 (38%) contracts within 5¢ of best: YES 169,214 NO 443,544 

Then we ran watch on the same 49ers–Falcons market. San Francisco jumped 5¢ in ten seconds, and the volume column read +0 the whole time:

$ KALSHI_ENV=prod python cli.py watch KXNFLGAME-26OCT25SFATL-SF --interval 10 --cycles 6 13:17:22 #1 bid 62¢ ask 63¢ mid 62.5¢ last 63¢ 24h vol +0 13:17:33 #2 bid 62¢ ask 63¢ mid 62.5¢ last 63¢ 24h vol +0 13:17:43 #3 bid 62¢ ask 63¢ mid 62.5¢ last 62¢* 24h vol +0 13:17:54 #4 bid 67¢* ask 68¢* mid 67.5¢* last 63¢* 24h vol +0 13:18:04 #5 bid 67¢ ask 68¢ mid 67.5¢ last 67¢* 24h vol +0 13:18:15 #6 bid 67¢ ask 68¢ mid 67.5¢ last 68¢* 24h vol +0 summary: 6 cycles, 4 with a change; mid 62.5¢ -> 67.5¢, 24h volume +0 

trades showed six fills in a single second just before, so the market wasn't quiet. Our first-pass explanation, a rolling window where old trades fall out as new ones come in, was wrong. We polled the market every 20 seconds for two minutes. volume_fp, the lifetime total, went from 4,146,334 to 4,220,425. volume_24h_fp stayed at exactly 3,011,623.46 the whole time. The 24-hour number is a slow snapshot. That's fine for ranking markets, but it can't tell you what's trading now. The rebuilt watch counts the change in volume_fp:

$ KALSHI_ENV=prod python cli.py watch KXNFLGAME-26OCT25SFATL-SF --interval 10 --cycles 6 13:21:03 #1 bid 67¢ ask 68¢ mid 67.5¢ last 68¢ traded +0 13:21:14 #2 bid 67¢ ask 68¢ mid 67.5¢ last 68¢ traded +1,064 13:21:24 #3 bid 67¢ ask 68¢ mid 67.5¢ last 68¢ traded +4,019 13:21:35 #4 bid 67¢ ask 68¢ mid 67.5¢ last 68¢ traded +7,665 13:21:45 #5 bid 67¢ ask 68¢ mid 67.5¢ last 68¢ traded +9,928 13:21:56 #6 bid 67¢ ask 68¢ mid 67.5¢ last 68¢ traded +13,243 summary: 6 cycles, 0 with a change; mid 67.5¢ -> 67.5¢, 13,243 contracts traded 

The quote didn't move for a minute while 13,243 contracts changed hands.

Smaller things that will bite you

  • The market snapshot and the order book disagree more than you'd think. We read GET /markets/{ticker} and its order book back to back 118 times across the 40 busiest production markets. 20 pairs disagreed on bid or ask, 7 of them by 5¢ or more, mostly in-play soccer and same-day weather markets. They're two separate reads of a fast book. book and watch take quotes from the book, and the markets footer says its quotes come from the snapshot.
  • Contract counts can be fractional. Trades came back as x 29.57 and x 7.73. Parse count_fp as a Decimal, not an int.
  • taker_side is deprecated. Use taker_outcome_side.
  • Demo isn't a copy of production. Two demo scans of 3,000 markets found 43 and 63 two-sided ones. Some production tickers 404 on demo, and others exist there with an empty book, like the Harry Kane goal market we checked.

What we didn't test

We don't have Kalshi demo keys, so balance and positions haven't been run against a real account. What we did check: RSA-PSS and Ed25519 signatures from throwaway keys verify against their public keys under Kalshi's documented scheme, in offline tests (16 in the morning build, 12 in the afternoon one, all passing). A key file inside the project folder is rejected. With no keys, the CLI prints "no Kalshi keys set; balance unavailable" and never shows a fake balance. An unknown key ID gets a clean 401 ... (NOT_FOUND) and a checklist, not a crash. If you get a 401 with real keys, it's almost always one of three things: a demo key on prod (or the reverse), a timestamp in seconds instead of milliseconds, or a query string left in the signed path.

The prompt

Paste this into your AI coding tool in an empty folder, then run python -m pytest -q before touching the network. The tests run offline and generate their own throwaway keys.

Build a small, read-only Python client and CLI for the Kalshi Trade API v2. Use only endpoints and fields documented at https://docs.kalshi.com. If unsure, ask me. NO order placement anywhere in this project. Python 3.10+, requests, cryptography, python-dotenv, pytest. Files: - config.py: KALSHI_ENV = "demo" (default) or "prod". demo: https://external-api.demo.kalshi.co/trade-api/v2 prod: https://external-api.kalshi.com/trade-api/v2 prod fallback: https://api.elections.kalshi.com/trade-api/v2 (documented as "also supported"; same markets, same signing). KALSHI_PROD_BASE_URL overrides prod. Every CLI run prints one banner line: "[demo] mock money" or "[PROD] real exchange data". - kalshi_http.py: one requests.Session, 15 s timeout. Retry 429 and 5xx with exponential backoff (0.5 s doubling, max 8 s, max 5 retries; sleep is injectable for tests). If the default prod host answers 403, retry the request ONCE on the fallback host, print "note: <host> returned 403 from this network; using api.elections.kalshi.com" once, and keep using it. Any other non-2xx raises KalshiHTTPError(status, path, short body text); the CLI prints it on one line, never a traceback. - kalshi_public.py (no auth): list_markets(status="open", limit=1000, max_pages=2, series_ticker=None) -> GET /markets with mve_filter=exclude, following `cursor`. get_market(ticker) -> GET /markets/{ticker} get_orderbook(ticker, depth=None) -> GET /markets/{ticker}/orderbook. Root key "orderbook_fp" with "yes_dollars" and "no_dollars": lists of [price, count] strings sorted by price ASCENDING, so the best bid is the LAST item. Either list can be missing or empty. get_trades(ticker, limit=10) -> GET /markets/trades. Use taker_outcome_side (taker_side is deprecated), yes_price_dollars, count_fp (can be fractional, e.g. "29.57": parse as Decimal), created_time. list_events(limit=20) -> GET /events - quotes.py (pure functions, no network; most of the tests live here): Use Decimal for prices. quote(market) -> yes_bid, yes_ask (None when the matching *_size_fp is 0: an empty side can read "0.0000" OR "1.0000", so never trust the price alone), spread, mid (only when both sides exist), last, volume_24h. book_summary(orderbook): best YES bid, best NO bid, implied YES ask = 1 - best NO bid, implied NO ask = 1 - best YES bid, spread, mid, contracts within 5 cents of each best bid. active(markets, top): keep markets with both sides quoted, sort by volume_24h_fp descending. - kalshi_auth.py: Key id from KALSHI_API_KEY_ID; PEM path from KALSHI_PRIVATE_KEY_PATH. The key file must live OUTSIDE this repo: resolve the path and raise if it is inside the project folder. message = timestamp_ms + METHOD + path, where path starts /trade-api/v2/... with the query string removed. RSA keys: RSA-PSS, MGF1(SHA256), salt_length=DIGEST_LENGTH, SHA256. Ed25519 keys: sign the message directly. Detect key type after loading. Headers: KALSHI-ACCESS-KEY, KALSHI-ACCESS-TIMESTAMP, KALSHI-ACCESS-SIGNATURE. get_balance() -> GET /portfolio/balance (cents; its own rate bucket, at most once a second on Basic, so never call it faster). get_positions() -> GET /portfolio/positions (unsettled by default). Missing keys: raise NoKeys; the CLI prints "no Kalshi keys set; balance unavailable" (or "positions unavailable") and exits 1. NEVER print a simulated, mock or placeholder balance; a test must check this. On 401, print the error body's "details" plus: "check the key belongs to this KALSHI_ENV (demo keys only work on demo), the timestamp is in milliseconds, and the signed path has no query string". - cli.py commands (prices as dollars with implied % next to them, "—" for an empty side; never print an empty side as $0.00 or $1.00): markets [--pages 3] [--top 15] [--series S]: scan open markets, show the most active two-sided ones: ticker, title (yes_sub_title when set, e.g. the team), YES bid/ask, spread, 24h volume, time to close. Kalshi has tens of thousands of open markets and /markets is NOT sorted by volume, so this is a sample of the first N pages, not an exchange-wide ranking. Footer: "scanned N open markets (more not scanned: a sample, not a full ranking; use --series or --pages), M had both sides quoted", then "quotes here are the market snapshot; `book <ticker>` reads the live order book" (the two can differ by several cents on in-play markets). On demo, if M is small, say demo has few live quotes. market <ticker> | book <ticker> [--depth 5] | trades <ticker> [--limit 10] book prints both bid ladders (best last), the implied asks, spread and mid, and one line explaining that Kalshi lists YES bids and NO bids only, so the YES ask is 1.00 minus the best NO bid. watch <ticker> [--interval 10] [--cycles 6]: poll market + book each cycle (interval >= 2 s) and print one line per cycle: time, bid, ask and mid FROM THE BOOK, last from the market, contracts traded since the first cycle (change in volume_fp; volume_24h_fp is a slow snapshot that can sit still for minutes during heavy trading), and "*" where a price changed. Ctrl-C exits cleanly with a summary. events | balance | positions The orderbook and trades endpoints return 200 with an EMPTY result for a ticker that does not exist, so book/trades/watch call get_market first (it 404s) before reading the book. On a 404 for market/book/trades/watch: "not found on <env>: demo and prod list different markets; pick a ticker from `markets` in the same env". Show a half-cent mid as 52.5¢, not rounded to 52¢. - .env.example with blanks, .env in .gitignore, never print secrets. - tests (offline, no network, never a real key; generate throwaway keys in a temp dir): signing message without query string; Ed25519 AND RSA signatures verify with the public key; key inside the repo rejected; no keys -> no balance printed; quote() with empty sides as "0.0000" and as "1.0000"; book_summary on the docs' example book (best YES bid 0.42, implied YES ask 0.44, spread 0.02); 429 backoff bounded; 403 fallback switches host once; watch marks changed values and counts traded contracts from volume_fp, not volume_24h_fp. - README: demo vs prod, the commands, and that demo and prod list different markets (a prod ticker may 404 on demo, or exist there with no quotes). Show the plan, build it, run the tests, then tell me the commands to run. 

Then:

python cli.py markets # demo: mostly empty, and it says so KALSHI_ENV=prod python cli.py markets --series KXNFLGAME KALSHI_ENV=prod python cli.py book <ticker> KALSHI_ENV=prod python cli.py trades <ticker> KALSHI_ENV=prod python cli.py watch <ticker> --interval 10 --cycles 6 

No account or key is needed for any of those. Production market data is public.

Already built an earlier version?

Paste this into the same session. If you built it from the version published on the morning of Oct 25, you only need fix 5.

Five fixes, with tests: 1. HTTP: retry 429/5xx with bounded backoff. If the default prod host external-api.kalshi.com returns 403, retry once on the documented also-supported host api.elections.kalshi.com, print a one-line note, and keep using it. Any other HTTP error prints one line, no traceback. 2. Treat a quote side as empty when its *_size_fp is 0 (the price can read 0.0000 or 1.0000). Print "—" for empty sides, never $0.00/$1.00. 3. `markets`: page with limit=1000 and cursor (default 3 pages), keep only two-sided markets, sort by volume_24h_fp, show bid/ask/spread/24h volume/time to close, and add --series. Footer: how many were scanned, how many were two-sided, and that it's a sample, not a full ranking. 4. book/trades: call GET /markets/{ticker} first, because the orderbook and trades endpoints return 200 + empty for unknown tickers. 5. watch: take bid/ask/mid from the order book, and show contracts traded since the first cycle as the change in volume_fp. volume_24h_fp is a slow snapshot and can stay flat for minutes while thousands of contracts trade. Parse count_fp as Decimal (it can be fractional). If you don't have them yet, add `trades <ticker>` (use taker_outcome_side) and `watch <ticker> --interval 10 --cycles 6` printing bid/ask/mid/last per cycle with * on changes. Show me the diff first. 

Rules worth knowing

Kalshi's Developer Agreement allows API use for your own trading. Don't cache, share or republish Kalshi data (no public odds dashboards) without written permission. Keep demo and production keys in separate files, outside your repo.

The full recipe, with step-by-step setup for demo keys, the FAQ and links to every Kalshi doc we used, is at botcookery.com/recipes/kalshi-api-starter. The next recipe builds a bot on top of this data layer, running on demo.