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.

EnvironmentREST Base URLWebSocket URL
Demo (recommended)https://external-api.demo.kalshi.co/trade-api/v2wss://external-api-ws.demo.kalshi.co/trade-api/ws/v2
Production (recommended)https://api.elections.kalshi.com/trade-api/v2 (docs also list external-api.kalshi.com)wss://external-api-ws.kalshi.com/trade-api/ws/v2
Demo (legacy, still works)https://demo-api.kalshi.co/trade-api/v2wss://demo-api.kalshi.co/trade-api/ws/v2
Production (legacy WS, still works)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://external-api.demo.kalshi.co/trade-api/v2",
    ws="wss://external-api-ws.demo.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://external-api-ws.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:

  • Keepalive matters. The Python websockets library handles ping/pong automatically; many DIY WebSocket wrappers do not, and dropped keepalives get your connection closed.
  • The orderbook_delta channel requires market tickers, not market_id or market_ids.
  • Reconnect logic with exponential backoff is not optional — it is a documented requirement.
  • Orderbook subscriptions have sanity limits (added June 2026): up to 500,000 markets per session and 10,000 subscription commands per second. Bulk subscribers should batch tickers per command rather than issuing one command per market.
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"])

Related failure mode: if your client processes messages too slowly, Kalshi now disconnects the subscription with error code 25 — subscription buffer overflow (added May 2026). Treat it like a sequence gap: reconnect and re-bootstrap the book. Slow consumers should move parsing off the receive loop.

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

Fractional contract sizes are now simply always on. The per-market fractional_trading_enabled flag was deprecated in April 2026 (it always returned true) and removed from the market schema on July 9, 2026 — along with response_price_units and resting_orders_count. If your code gates fractional handling on that field, a market.get("fractional_trading_enabled") check now returns None, so a naive supports_fractional() helper silently reports False for every market. Delete the check; handle fractional sizes everywhere. 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())

# BROKEN as of July 9, 2026 — the field no longer exists, so this
# always returns False. Fractional trading is always enabled; remove
# the gate entirely.
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 Targeting the Removed Legacy Endpoint

The single biggest order-entry failure in 2026: code still posting to the legacy POST /portfolio/orders mutation endpoint with side (yes/no), action (buy/sell), count_fp, and yes_price_dollars. That endpoint was deprecated and removed between June 18–25, 2026 (changelog). The canonical order endpoint is now CreateOrder V2 at POST /portfolio/events/orders, with a different model:

  • side is bid or ask — the yes/no + buy/sell combination is gone
  • price and count are fixed-point strings (price supports up to 6 decimal places)
  • time_in_force is required: fill_or_kill, good_till_canceled, or immediate_or_cancel
  • self_trade_prevention_type is required: taker_at_cross or maker
  • Optional fields include expiration_time (seconds), subaccount, order_group_id, exchange_index, and cancel_order_on_pause
  • Cancel, amend, and decrease have V2 equivalents, and batch creation lives at POST /portfolio/events/orders/batched
  • client_order_id is still essential for idempotent submission — but note it was removed from Fill responses in March 2026, so you cannot match fills back to it directly
import uuid

def build_order(ticker: str):
    return {
        "ticker": ticker,
        "side": "bid",
        "count": "10.00",
        "price": "0.56",
        "time_in_force": "good_till_canceled",
        "self_trade_prevention_type": "taker_at_cross",
        "client_order_id": str(uuid.uuid4()),
    }

The reconciliation ledger: maintain a durable local map of client_order_id → intended payload → server order_id → fills/WS events. Since fills no longer carry client_order_id, the server order_id returned at creation time is the join key — capture it immediately and reconcile fills against it. 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 moved to a token-cost rate limit system in April 2026 (rate limits docs). Each tier has separate read and write token budgets per second, and each endpoint consumes a published token cost per call:

TierRead tokens/secWrite tokens/secQualification
Basic200100Completing signup
Advanced300300Self-serve via POST /account/api_usage_level/upgrade
Expert600600Trailing 30-day volume (automatic)
Premier1,0001,000Trailing 30-day volume (automatic)
Paragon2,0002,000Trailing 30-day volume (automatic)
Prime4,0004,000Trailing 30-day volume (automatic)
Prestige10,0008,000Trailing 30-day volume (automatic)

Volume tiers are earned automatically from trailing 30-day trading volume (thresholds from 0.075% to 1.00% of exchange volume; requirements were halved June 25, 2026) and granted for 30 days.

Constraints that developers miss:

  • A single order costs 10 write tokens; a batch of N orders costs N × 10, so the effective maximum batch size scales with your tier’s write budget — there is no fixed per-request cap
  • Legacy (pre-V2) order endpoints cost 5x their V2 equivalents as of June 1, 2026 — old code silently burns 5x the budget for every call
  • Per-endpoint costs are queryable via GET /account/endpoint_costs; don’t hardcode them
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 (and GET /account/endpoint_costs) 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:

  1. Build around Decimal + _dollars + _fp only. Legacy integer fields have been removed. Every line of code must use the fixed-point interface.

  2. Treat the live/historical split as already enforced. Query both tiers and merge by stable IDs. Don’t assume live endpoints return historical records.

  3. Make WebSockets authenticated, stateful, and sequence-aware. Auth the connection, track seq per sid, and re-bootstrap the book on any gap.

  4. Use client_order_id on submission and reconcile fills via order_id. The durable ledger pattern — client_order_id → payload → server order_id → fills — is the single most important debugging tool for production bots. Remember fills stopped carrying client_order_id in March 2026.

  5. Load rate limits dynamically and stay off legacy endpoints. Call GET /account/limits and GET /account/endpoint_costs at startup. Don’t hardcode tier values that change when your account is upgraded — and migrate any remaining legacy order calls to V2, since they cost 5x the tokens and the mutation endpoints have been removed.

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.