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.bookandwatchtake quotes from the book, and themarketsfooter says its quotes come from the snapshot. -
Contract counts can be fractional. Trades came back as
x 29.57andx 7.73. Parsecount_fpas aDecimal, not anint. -
taker_sideis deprecated. Usetaker_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.
Top comments (0)