Kalshi API integration breaks in predictable ways. After reviewing current documentation, developer forums, and production bot failures, these are the 10 problems that cause the most wasted hours — with working code fixes for each.
1. Bad Request Signatures
The single most common hard failure is signing the wrong string. Kalshi requires timestamp + HTTP_METHOD + path, signed with RSA-PSS and SHA-256. The critical detail: the path must exclude query parameters. This rule applies to both REST endpoints and the WebSocket handshake path (/trade-api/ws/v2).
import base64, time
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
def sign_request(private_key, method: str, path: str):
ts = str(int(time.time() * 1000))
path = path.split("?", 1)[0] # strip query params — critical for Kalshi
msg = f"{ts}{method.upper()}{path}".encode()
sig = private_key.sign(
msg,
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.DIGEST_LENGTH,
),
hashes.SHA256(),
)
return {
"KALSHI-ACCESS-TIMESTAMP": ts,
"KALSHI-ACCESS-SIGNATURE": base64.b64encode(sig).decode(),
}
Debugging tip: log the exact method, path_without_query, and millisecond timestamp you signed. That makes 401 debugging deterministic instead of guesswork. If you are coming from Polymarket’s HMAC-based signing (covered in our Polymarket API guide), note that Kalshi uses RSA-PSS — a completely different cryptographic primitive.
2. Mixing Demo and Production Credentials
Demo and production are separate environments with separate credentials. Kalshi does not share keys between them. The base URLs differ and mixing them produces silent auth failures, not helpful error messages.
| Environment | REST Base URL | WebSocket URL |
|---|---|---|
| Demo | https://demo-api.kalshi.co/trade-api/v2 | wss://demo-api.kalshi.co/trade-api/ws/v2 |
| Production | https://api.elections.kalshi.com/trade-api/v2 | wss://api.elections.kalshi.com/trade-api/ws/v2 |
from dataclasses import dataclass
@dataclass(frozen=True)
class KalshiEnv:
rest: str
ws: str
key_id: str
key_path: str
DEMO = KalshiEnv(
rest="https://demo-api.kalshi.co/trade-api/v2",
ws="wss://demo-api.kalshi.co/trade-api/ws/v2",
key_id="demo_key",
key_path="demo.key",
)
PROD = KalshiEnv(
rest="https://api.elections.kalshi.com/trade-api/v2",
ws="wss://api.elections.kalshi.com/trade-api/ws/v2",
key_id="prod_key",
key_path="prod.key",
)
Fix: never keep a single API_KEY_ID environment variable. Use KALSHI_DEMO_* and KALSHI_PROD_* prefixes so you cannot accidentally sign production requests with demo keys. This same environment-isolation pattern applies across prediction market APIs — see the prediction market API reference for a cross-platform comparison.
3. Assuming WebSocket Channels Don’t Need Auth
Developers familiar with other exchange APIs often assume public market-data channels like ticker or trade can be consumed without authentication. On Kalshi, this is wrong. The connection itself is authenticated — even when you only subscribe to public channels.
Additional WebSocket traps that catch developers:
- Kalshi sends ping frames every 10 seconds and expects pong responses. Many DIY WebSocket wrappers do not handle this automatically.
- The
orderbook_deltachannel requires market tickers, notmarket_idormarket_ids. - Reconnect logic with exponential backoff is not optional — it is a documented requirement.
import asyncio, json, websockets
async def run_ws(ws_url, headers, tickers):
backoff = 1
while True:
try:
async with websockets.connect(ws_url, additional_headers=headers) as ws:
await ws.send(json.dumps({
"id": 1,
"cmd": "subscribe",
"params": {
"channels": ["orderbook_delta"],
"market_tickers": tickers, # not market_ids
}
}))
backoff = 1
async for raw in ws:
msg = json.loads(raw)
yield msg
except Exception:
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 30)
4. Letting the Local Order Book Drift
Kalshi’s orderbook stream sends an initial orderbook_snapshot, then incremental orderbook_delta messages. Every message includes sid (stream ID) and seq (sequence number). If your client misses a message — network hiccup, slow processing, GC pause — the local book silently diverges from reality.
The fix is simple in concept and routinely skipped in practice: track seq per sid and re-subscribe when you detect a gap.
from decimal import Decimal
from collections import defaultdict
books = {}
last_seq = {}
def apply_snapshot(msg):
books[msg["market_ticker"]] = {
"yes": {Decimal(p): Decimal(q) for p, q in msg["yes_dollars_fp"]},
"no": {Decimal(p): Decimal(q) for p, q in msg["no_dollars_fp"]},
}
def apply_delta(msg):
side = msg["side"]
px = Decimal(msg["price_dollars"])
delta = Decimal(msg["delta_fp"])
book = books[msg["market_ticker"]][side]
book[px] = book.get(px, Decimal("0")) + delta
if book[px] <= 0:
book.pop(px, None)
def process_ws(data):
sid, seq = data["sid"], data["seq"]
prev = last_seq.get(sid)
if prev is not None and seq != prev + 1:
raise RuntimeError(f"Sequence gap on sid={sid}: {prev} -> {seq}")
last_seq[sid] = seq
if data["type"] == "orderbook_snapshot":
apply_snapshot(data["msg"])
elif data["type"] == "orderbook_delta":
apply_delta(data["msg"])
Validation strategy: persist a checksum of your top-of-book state after every N deltas. That makes silent drift visible before it corrupts trading logic. For agents that trade across both Kalshi and Polymarket, maintaining synchronized books is the foundation of cross-market arbitrage.
5. Misreading the Bids-Only Order Book
This one trips up every developer coming from traditional exchange APIs. Kalshi’s REST orderbook endpoint returns bids only: yes_dollars and no_dollars. There are no explicit asks.
Binary markets are reciprocal: a YES bid at price X implies a NO ask at 1.00 - X, and vice versa. The arrays are sorted ascending, so the best bid is the last element — another inversion from the typical descending-bid convention.
from decimal import Decimal
def best_yes_bid_ask(orderbook_fp):
yes = orderbook_fp.get("yes_dollars", [])
no = orderbook_fp.get("no_dollars", [])
best_yes_bid = Decimal(yes[-1][0]) if yes else None
best_yes_ask = Decimal("1.00") - Decimal(no[-1][0]) if no else None
return best_yes_bid, best_yes_ask
Normalize early: convert everything internally to one view (YES bid / YES ask) before sending data to models, dashboards, or the Kelly criterion sizing layer. That eliminates an entire class of side-flip bugs.
6. Missing Data from Pagination and the Live/Historical Split
Two data-access patterns break simultaneously if you aren’t prepared for them.
Cursor-based pagination. List endpoints return a cursor field. If you only process the first page, you silently miss data — positions, fills, historical trades, market listings.
Live vs. historical endpoint split. Kalshi now separates data into live and historical tiers. Older records must be queried through /historical/... endpoints, with cutoff dates available from GET /historical/cutoff. As of the March 2026 migration, historical data has been removed from live endpoints. Build as if the split is mandatory.
import requests
def fetch_all(url, params=None):
params = dict(params or {})
out = []
while True:
r = requests.get(url, params=params, timeout=20)
r.raise_for_status()
data = r.json()
items = next(v for v in data.values() if isinstance(v, list))
out.extend(items)
cursor = data.get("cursor")
if not cursor:
return out
params["cursor"] = cursor
For backtests or account-history rebuilds: query both live and historical endpoints, merge by stable IDs, then dedupe. The prediction market trading layer guide covers how agents should architect data pipelines that account for these kinds of upstream API splits.
7. Using Legacy Integer Price Fields
This was the biggest schema-risk item in the Kalshi ecosystem — and the migration is now complete. As of March 2026, legacy integer fields (prices in cents, counts as integers) have been removed from all REST and WebSocket response payloads. Prices use *_dollars fixed-point strings and counts use *_fp strings exclusively.
All code must use _dollars and _fp as the only interface. If you’re still referencing yes_price, no_price, count, or other integer fields, your integration is broken.
from decimal import Decimal
def parse_price(x: str) -> Decimal:
return Decimal(x) # never float()
def parse_count_fp(x: str) -> Decimal:
return Decimal(x)
# correct usage
yes_px = parse_price(trade["yes_price_dollars"])
qty = parse_count_fp(trade["count_fp"])
In Python, set Decimal everywhere at the API boundary. In TypeScript, use a decimal library like decimal.js or store fixed-point strings until the latest possible moment. Using native float or JavaScript Number for financial calculations is a guaranteed source of reconciliation bugs.
8. Not Handling Fractional Contracts and Fee Rounding
Markets on Kalshi can now be fractional on a per-market basis via the fractional_trading_enabled flag. The _fp values can contain 0–2 decimal places on input. This interacts with fee rounding in ways that break naive P&L tracking.
Sub-penny prices and fractional sizes create sub-cent balance changes. Kalshi resolves these with a rounding fee and a fee accumulator/rebate mechanism. If you reconcile fills by multiplying price × quantity and comparing to your balance delta, the numbers will not match — and the exchange is behaving correctly.
from decimal import Decimal
def fp_to_hundredths(count_fp: str) -> int:
"""Convert '1.55' -> 155 for internal integer arithmetic."""
return int((Decimal(count_fp) * 100).to_integral_value())
def supports_fractional(market: dict) -> bool:
return bool(market.get("fractional_trading_enabled"))
Internal representation: store "1.55" as integer 155 (contract hundredths) and "0.1200" as a 4-decimal-place Decimal. That preserves precision without floats and makes reconciliation against Kalshi’s accumulator logic possible.
9. Order Entry Bugs from Side/Action Confusion
Kalshi’s order API accepts side (yes/no) and action (buy/sell), plus multiple price and count representations. The combinatorial surface for bugs is large:
buy_max_costautomatically makes the order Fill-or-Killsell_position_flooris deprecated in favor ofreduce_only- Self-crossing FoK orders can still produce partial fills depending on
self_trade_prevention_type client_order_idis essential for idempotent deduplication and appears inorderbook_deltamessages when your own order caused the change
import uuid
def build_order(ticker: str):
return {
"ticker": ticker,
"side": "yes",
"action": "buy",
"count_fp": "10.00",
"yes_price_dollars": "0.5600",
"client_order_id": str(uuid.uuid4()),
"reduce_only": False,
"self_trade_prevention_type": "taker_at_cross",
}
The reconciliation ledger: maintain a durable local map of client_order_id → intended payload → server order_id → fills/WS events. That one data structure saves enormous debugging time during retries and reconnects. For agents managing positions across multiple markets, the agent betting stack architecture places this reconciliation logic in Layer 3 (Trading).
10. Poor Rate Limit and Throughput Management
Kalshi publishes tiered rate limits that vary by account level:
| Tier | Read/sec | Write/sec | Qualification |
|---|---|---|---|
| Basic | 20 | 10 | Completing signup |
| Advanced | 30 | 30 | Typeform application |
| Premier | 100 | 100 | 3.75% of monthly exchange volume + technical review |
| Prime | 400 | 400 | 7.5% of monthly exchange volume + technical review |
Additional constraints that developers miss: batch order creates are capped at 20 per request and each item counts against write limits. Batch cancels are special — each cancel counts at 0.2 against the write limit. In demo, batch endpoints are opened to Basic users, but production enforces tier-appropriate limits.
import time
import threading
class TokenBucket:
def __init__(self, rate_per_sec: int):
self.rate = rate_per_sec
self.tokens = rate_per_sec
self.last = time.time()
self.lock = threading.Lock()
def take(self, n=1):
while True:
with self.lock:
now = time.time()
self.tokens = min(
self.rate,
self.tokens + (now - self.last) * self.rate,
)
self.last = now
if self.tokens >= n:
self.tokens -= n
return
time.sleep(0.01)
Don’t hardcode tier assumptions. Load GET /account/limits at startup and configure separate read/write token buckets dynamically. Kalshi also exposes queue-position endpoints so you can measure why resting orders are not filling instead of guessing. The Kalshi API guide covers the full endpoint reference.
The Kalshi Integration Golden Path
If you’re starting a Kalshi integration from scratch or auditing an existing one, this is the priority checklist:
Build around
Decimal+_dollars+_fponly. Legacy integer fields have been removed. Every line of code must use the fixed-point interface.Treat the live/historical split as already enforced. Query both tiers and merge by stable IDs. Don’t assume live endpoints return historical records.
Make WebSockets authenticated, stateful, and sequence-aware. Auth the connection, track
seqpersid, and re-bootstrap the book on any gap.Use
client_order_ideverywhere and reconcile across REST + WS. The durable ledger pattern —client_order_id → payload → server_id → fills— is the single most important debugging tool for production bots.Load rate limits dynamically. Call
GET /account/limitsat startup. Don’t hardcode tier values that change when your account is upgraded.
For a complete walkthrough of Kalshi’s REST, WebSocket, and FIX APIs, start with the Kalshi API guide. To compare Kalshi’s architecture against Polymarket and other prediction market platforms, see the prediction market API reference. For the Polymarket equivalent of this guide, see the top 10 Polymarket API problems. For agents that trade across multiple platforms, the cross-market arbitrage guide covers the execution layer in detail.
