DEV Community

BotCookery
BotCookery

Posted on Originally published at botcookery.com

We rebuilt our Kalshi API starter from its own prompt. Live data found five bugs.

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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)