Kalshi API: The Complete Developer Guide (2026)

2026-05-16

The Kalshi API is the REST + WebSocket interface for the CFTC-regulated event-contract exchange of the same name, exposing thousands of binary markets and authenticated portfolio operations to programmatic clients. Production traffic targets https://external-api.kalshi.com/trade-api/v2; authentication is RSA-PSS signing over timestamp + METHOD + path, applied as three request headers. The day-to-day surface covers four families: public discovery (/series, /events, /markets, orderbooks), authenticated portfolio (/portfolio/balance, /portfolio/orders, V2 order endpoints under /portfolio/events/orders), historical data (a separate tier since 2026-02-19), and a real-time WebSocket at wss://external-api-ws.kalshi.com/trade-api/ws/v2. Rate limits are token-bucket with independent Read and Write budgets across five tiers (Basic through Prime). The rest of this guide walks each piece end-to-end with working code in Python, TypeScript, and cURL, verified against docs.kalshi.com (2026-05).

If you've used an older Kalshi tutorial, skim §0 first; several specifics changed in late 2025 and through 2026 in ways that will silently break old code.

0. What changed since most older Kalshi tutorials

If you're working from a 2024 or early-2025 guide, here's a tight diff so you don't waste a debugging cycle:

TopicOld (≤ early 2025)Current (2026-05)
Signing algorithmRSA-SHA256 / PKCS#1 v1.5RSA-PSS with MGF1(SHA256), salt = digest length
Rate-limit modelRPS quota, three tiersToken bucket, 10 tokens/request default, five tiers (Basic / Advanced / Premier / Paragon / Prime), Read and Write are independent buckets
429 behaviourRetry-After headerNo Retry-After, no X-RateLimit-* — pure exponential backoff
WebSocket authQuery-string signed URLHTTP headers during the WebSocket handshake (same payload as REST)
WebSocket heartbeatClient-drivenServer-driven; server sends Ping every 10 s with body heartbeat, client responds Pong
Order typesmarket and limitCurrent create-order examples and SDK surfaces use limit orders; don't start new integrations around old market-order examples
Orderbook shapebids + asksBids only on each side; YES BID @ $0.60 ≡ NO ASK @ $0.40
Price / size fieldsInteger cents (yes_bid: 42), integer count (count: 10)String fixed-point (yes_bid_dollars: "0.4200", count_fp: "10.00"); old fields removed during 2026 Q1
Public hostnametrading-api.readme.io documentation, older shared API hostsDocs at docs.kalshi.com; recommended production REST host is external-api.kalshi.com, with api.elections.kalshi.com still supported for compatibility

If any of those rows surprise you, this guide is for you.

The Kalshi API surface at a glance

A single mental model for what you're working with before any code runs:

Kalshi API v2
├── REST  →  https://external-api.kalshi.com/trade-api/v2
│   ├── Public discovery (no auth)
│   │   ├── /series, /series/{ticker}
│   │   ├── /events, /events/{ticker}, /events/multivariate
│   │   ├── /markets, /markets/{ticker}
│   │   ├── /markets/{ticker}/orderbook  (bids only)
│   │   ├── /markets/orderbooks?tickers=...  (batched, 100 max)
│   │   └── /markets/trades
│   ├── Portfolio (RSA-PSS auth)
│   │   ├── /portfolio/balance
│   │   ├── /portfolio/positions
│   │   ├── /portfolio/settlements
│   │   ├── /portfolio/orders, /portfolio/fills
│   │   └── /portfolio/events/orders/...  (V2 create / amend / decrease / cancel / batched)
│   ├── Historical tier (since 2026-02-19, separate cutoff)
│   │   └── /historical/{cutoff, markets, trades, fills, orders}
│   └── Account
│       ├── /account/limits           (live token-bucket budget)
│       └── /account/endpoint_costs   (token cost per endpoint)
└── WebSocket  →  wss://external-api-ws.kalshi.com/trade-api/ws/v2
    ├── Public channels: ticker, trade, market_lifecycle_v2, multivariate*
    └── Private channels (require authenticated handshake):
        orderbook_delta, fill, market_positions, user_orders, ...

The legacy hosts (api.elections.kalshi.com, demo-api.kalshi.co) still resolve for compatibility, but every new integration should target the external-api.* hosts above.

1. 30 seconds: your first Kalshi API response

The fastest possible Kalshi call is unauthenticated. GET /series and GET /markets are public — you don't need a key, a signature, or a clock-synced machine. Pick a series ticker, pull its metadata, done. From there you can decide whether you actually need to authenticate at all (many analytics use cases don't).

curl -sS https://external-api.kalshi.com/trade-api/v2/series/KXHIGHNY \
  | jq '.series | {ticker, title, frequency, category}'
Connect Parlay MCP:
https://mcp.parlay.run/mcp

Ask your AI client:
Find Kalshi markets about NYC high temperature and include source links.

Expect:
Structured market results from Parlay's read-only MCP tools, not raw Kalshi JSON.

Direct API read vs hosted Parlay MCP research prompt.

Two notes worth knowing before you go further:

  1. The recommended production REST hostname is external-api.kalshi.com. The older shared host api.elections.kalshi.com is still supported for compatibility and still covers all Kalshi market categories, not just elections. The recommended demo REST host is external-api.demo.kalshi.co; demo-api.kalshi.co remains supported.
  2. Old trading-api.readme.io docs and trading-api.kalshi.com examples are stale. The documentation moved to docs.kalshi.com on 2025-07-31, and current examples use the external API hosts. Treat older tutorials and SDK READMEs as suspect unless they have been updated.

The rest of this guide assumes you're hitting the recommended production REST host and want to do something more interesting than read a single series.

2. Authentication: RSA-PSS signing, step by step

Kalshi API authentication, in one paragraph. Generate an RSA key pair, upload the public key in the Kalshi dashboard, keep the private key in a secrets manager. For every authenticated request, build the message timestamp_ms + METHOD + path (path includes the /trade-api/v2 prefix, excludes the query string), sign it with RSA-PSS using SHA-256 for both hash and MGF1 and a salt length equal to the digest length (32 bytes), and base64-encode the signature. Attach three headers: KALSHI-ACCESS-KEY (your key ID), KALSHI-ACCESS-TIMESTAMP (the Unix milliseconds you signed with), and KALSHI-ACCESS-SIGNATURE (the base64 signature). Public endpoints (/series, /events, /markets, orderbooks) accept these headers but don't require them; everything under /portfolio does.

Authentication is where most Kalshi integrations get stuck for an hour and then for a few minutes again later. The mechanics are not complicated, but every detail has to be right — wrong padding scheme, wrong timestamp unit, wrong path prefix, and you get the same opaque 401 with no useful body. Read all five steps before writing code.

Auth · Step 1 — How the signature is constructed

Kalshi uses RSA-PSS signing, not HMAC and not RSA PKCS#1 v1.5. PSS is the probabilistic signature scheme; it adds randomized padding (the "salt") so two signatures of the same payload don't match — that's intentional and you don't have to do anything special with it. Use SHA-256 as the hash and as the MGF1 hash, and a salt length equal to the digest length (32 bytes).

The payload you sign is plain ASCII concatenation: timestamp + METHOD + path. Three rules about that payload — this is the part that surprises people:

  • timestamp is the current Unix time in milliseconds, not seconds. Use int(time.time() * 1000) in Python; Date.now() in JavaScript.
  • METHOD is the HTTP verb in upper case (GET, POST, DELETE).
  • path must include the /trade-api/v2 prefix and must not include the query string. Always strip everything from ? onward before signing, or you'll match on local tests (no query) and fail in production (with a query).

Then attach three headers to every authenticated request:

KALSHI-ACCESS-KEY: <api_key_id>
KALSHI-ACCESS-TIMESTAMP: <unix_ms>
KALSHI-ACCESS-SIGNATURE: <base64-encoded RSA-PSS signature>

Public endpoints (/series, /events, /markets, the orderbook endpoints) accept these headers but don't require them. Anything under /portfolio requires them.

Auth · Step 2 — A complete working signing implementation

Below is the canonical implementation in three languages. The Python version is the one Kalshi's official docs publish; the TypeScript and cURL/OpenSSL versions are functional ports. You can paste any of them into a fresh project and immediately authenticate against GET /portfolio/balance.

import base64
import time
from urllib.parse import urlparse

import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding

KEY_ID = "your-api-key-id"
PRIVATE_KEY_PEM = open("kalshi_private_key.pem", "rb").read()
BASE_URL = "https://external-api.kalshi.com/trade-api/v2"

private_key = serialization.load_pem_private_key(
    PRIVATE_KEY_PEM, password=None
)

def sign(method: str, path: str) -> dict[str, str]:
    # path must include /trade-api/v2 and exclude any query string.
    path_no_query = path.split("?")[0]
    timestamp_ms = str(int(time.time() * 1000))
    message = (timestamp_ms + method.upper() + path_no_query).encode("utf-8")

    signature = private_key.sign(
        message,
        padding.PSS(
            mgf=padding.MGF1(hashes.SHA256()),
            salt_length=padding.PSS.DIGEST_LENGTH,
        ),
        hashes.SHA256(),
    )

    return {
        "KALSHI-ACCESS-KEY": KEY_ID,
        "KALSHI-ACCESS-TIMESTAMP": timestamp_ms,
        "KALSHI-ACCESS-SIGNATURE": base64.b64encode(signature).decode("utf-8"),
    }

def call(method: str, path: str, **kwargs):
    # Pull the full path (including /trade-api/v2) for signing.
    full_path = urlparse(BASE_URL + path).path
    headers = sign(method, full_path)
    return requests.request(method, BASE_URL + path, headers=headers, **kwargs)

print(call("GET", "/portfolio/balance").json())
import { createSign, constants } from 'node:crypto';
import { readFileSync } from 'node:fs';

const KEY_ID = 'your-api-key-id';
const PRIVATE_KEY_PEM = readFileSync('kalshi_private_key.pem', 'utf8');
const BASE_URL = 'https://external-api.kalshi.com/trade-api/v2';

function sign(method: string, path: string) {
  const pathNoQuery = path.split('?')[0];
  const timestampMs = String(Date.now());
  const message = timestampMs + method.toUpperCase() + pathNoQuery;

  // Node's 'RSA-SHA256' + RSA_PKCS1_PSS_PADDING produces RSA-PSS, not v1.5.
  // The "PKCS1" in the constant name only refers to the underlying RSA primitive.
  const signer = createSign('RSA-SHA256');
  signer.update(message);
  signer.end();

  const signature = signer.sign({
    key: PRIVATE_KEY_PEM,
    padding: constants.RSA_PKCS1_PSS_PADDING,
    saltLength: constants.RSA_PSS_SALTLEN_DIGEST,
  });

  return {
    'KALSHI-ACCESS-KEY': KEY_ID,
    'KALSHI-ACCESS-TIMESTAMP': timestampMs,
    'KALSHI-ACCESS-SIGNATURE': signature.toString('base64'),
  };
}

export async function call(method: string, path: string, init?: RequestInit) {
  const fullPath = new URL(BASE_URL + path).pathname;
  const headers = { ...sign(method, fullPath), ...(init?.headers as object) };
  return fetch(BASE_URL + path, { ...init, method, headers });
}

const res = await call('GET', '/portfolio/balance');
console.log(await res.json());
KEY_ID="your-api-key-id"
KEY_FILE="kalshi_private_key.pem"
METHOD="GET"
PATH_PART="/trade-api/v2/portfolio/balance"

# date +%s%N is GNU-only; on macOS use python3 or `gdate` (coreutils).
TS_MS=$(python3 -c 'import time; print(int(time.time()*1000))')
PAYLOAD="${TS_MS}${METHOD}${PATH_PART}"

SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 \
  -sigopt rsa_padding_mode:pss \
  -sigopt rsa_pss_saltlen:digest \
  -sign "$KEY_FILE" | openssl base64 -A)

curl -sS "https://external-api.kalshi.com${PATH_PART}" \
  -H "KALSHI-ACCESS-KEY: ${KEY_ID}" \
  -H "KALSHI-ACCESS-TIMESTAMP: ${TS_MS}" \
  -H "KALSHI-ACCESS-SIGNATURE: ${SIG}"

If you get a 200 from GET /portfolio/balance, signing is working. If you get a 401, jump straight to Step 3.

Auth · Step 3 — Three real traps

Most signing failures fall into one of these three buckets. The error message Kalshi returns is always the same generic 401 — the cause is what differs.

Trap A — clock drift. Kalshi rejects timestamps that are too far from server time, but the rejection comes back as the same opaque 401 you'd get for a wrong key. If your machine isn't NTP-synced (or you're inside a container with a frozen clock), you'll fail signing on otherwise-correct code. Run timedatectl on Linux or sntp -sS time.apple.com on macOS, and make sure your CI runners enable NTP.

Trap B — PEM format. When you generate a key, you'll get one of two PEM headers: -----BEGIN PRIVATE KEY----- (PKCS#8, modern OpenSSL default) or -----BEGIN RSA PRIVATE KEY----- (PKCS#1, older). Python's cryptography library handles both, and so does Node's createPrivateKey, but if you're piping the PEM through a third tool (Vault, Kubernetes Secret, copy-paste through Slack) you sometimes lose the line breaks or the header. Always verify with openssl rsa -in kalshi_private_key.pem -check -noout before debugging signing logic.

Trap C — key management in production. Kalshi does not store your private key. If you lose it, you regenerate the key pair and re-upload the new public key — there is no recovery. Treat the key like an SSH key: in production, store it in a real secrets manager (AWS Secrets Manager, Google Secret Manager, HashiCorp Vault), not in a Kubernetes ConfigMap or a .env file checked into git. Rotate every 90 days as a habit, even though Kalshi doesn't force it.

Auth · Step 4 — Where this guide goes next

The signing block above is undifferentiated infrastructure work — every Kalshi consumer ends up writing it, and the official kalshi_python_sync SDK already encapsulates it. The interesting part of a Kalshi integration starts after authentication: how to navigate the endpoint catalogue without burning rate-limit budget, how to read the orderbook correctly given that there are no asks, and how to handle real-time data over WebSocket. The next four sections cover those.

Auth · Step 5 — Same call, three layers of abstraction

Before moving on, it's worth seeing how the same query — list every active market in a series — looks at three layers: raw HTTP, the official Kalshi SDK (kalshi_python_sync on PyPI), and Parlay's Model Context Protocol (MCP) server. The point is not to argue Parlay against the official SDK; the point is that Parlay is the only one of the three that also covers Polymarket, Limitless, and Manifold Markets behind the same research interface. If your application only ever touches Kalshi, the official SDK is great. If it touches more than one venue, the case for Parlay is the cross-market unification.

import base64, time
from urllib.parse import urlparse
import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding

KEY_ID = "..."
private_key = serialization.load_pem_private_key(
    open("key.pem", "rb").read(), password=None
)
BASE = "https://external-api.kalshi.com/trade-api/v2"

def sign(method, path):
    ts = str(int(time.time() * 1000))
    msg = (ts + method + path.split("?")[0]).encode()
    sig = private_key.sign(
        msg,
        padding.PSS(mgf=padding.MGF1(hashes.SHA256()),
                    salt_length=padding.PSS.DIGEST_LENGTH),
        hashes.SHA256(),
    )
    return {
        "KALSHI-ACCESS-KEY": KEY_ID,
        "KALSHI-ACCESS-TIMESTAMP": ts,
        "KALSHI-ACCESS-SIGNATURE": base64.b64encode(sig).decode(),
    }

markets, cursor = [], None
while True:
    path = f"/markets?series_ticker=KXHIGHNY&status=open&limit=100"
    if cursor:
        path += f"&cursor={cursor}"
    full_path = urlparse(BASE + path).path
    resp = requests.get(BASE + path, headers=sign("GET", full_path))
    resp.raise_for_status()
    data = resp.json()
    markets.extend(data["markets"])
    cursor = data.get("cursor")
    if not cursor:
        break
print(f"{len(markets)} open markets")
from kalshi_python_sync import Configuration, KalshiClient

config = Configuration(
    host="https://external-api.kalshi.com/trade-api/v2"
)
with open("key.pem", "r") as f:
    config.private_key_pem = f.read()
config.api_key_id = "..."

client = KalshiClient(config)
response = client.get_markets(
    series_ticker="KXHIGHNY",
    status="open",
    limit=100,
)
print(f"{len(response.markets)} open markets on this page")
Connect Parlay MCP:
https://mcp.parlay.run/mcp

Ask your AI client:
Find active Kalshi weather markets and compare any similar Polymarket markets.
Include market links, current prices if available, and wording differences.

Expect:
A normalized cross-venue market list from Parlay, with source links.

Raw API and SDK paths vs hosted MCP research workflow.

3. Endpoints that actually matter

The working subset of the Kalshi API, in one paragraph. Public discovery lives under /series, /events, /markets, and per-market and batched orderbook endpoints (no auth required, no asks in the orderbook — see §3.1). Authenticated portfolio endpoints live under /portfolio/*: balance, positions, settlements, orders, fills, and the recommended V2 order surface at /portfolio/events/orders (create, amend, decrease, cancel, batched). A separate historical tier (/historical/*) holds settled markets, fills, and orders older than the timestamp returned by GET /historical/cutoff. Live token-bucket budget and per-endpoint cost are at GET /account/limits and GET /account/endpoint_costs. Most integrations only touch ten of these endpoints day-to-day; the rest are good to know but rarely hot paths.

Kalshi's API surface is broad but the day-to-day surface area is narrow: a handful of public discovery endpoints, a handful of authenticated portfolio endpoints, and a recently-introduced historical-data tier for anything older than a few weeks. Here's the working subset.

3.1 Public discovery — /series, /events, /markets, and the orderbook

These four endpoint families let you walk Kalshi's catalogue from broadest (a series like "Highest temperature in NYC today") down to a single tradeable market (today's specific contract on whether the high will exceed 75°F).

  • GET /series — paginated list of series, filterable by category.
  • GET /series/{ticker} — metadata for one series.
  • GET /events — paginated list of events; does not return multivariate events anymore. Use GET /events/multivariate for those.
  • GET /events/{ticker} — one event with its child markets.
  • GET /markets — paginated list of markets (filter by series_ticker, event_ticker, status).
  • GET /markets/{ticker} — single market metadata.
  • GET /markets/{ticker}/orderbook — the per-market orderbook (read carefully, see below).
  • GET /markets/orderbooks?tickers=... — batched orderbook fetch, up to 100 tickers in one call (added 2026-03).
  • GET /markets/trades — recent fills across markets.

Two non-obvious things about the orderbook response. Kalshi's binary markets exhibit a duality (YES + NO = $1.00), and the API leans on it: each side of the book is expressed as bids only. There are no asks anywhere in the response.

{
  "orderbook_fp": {
    // fixed-point variant; replaces legacy cents fields
    "yes_dollars": [
      // bids to buy YES, ascending by price
      ["0.3900", "5.00"], // [price_dollars, count_fp]
      ["0.4000", "13.00"],
      ["0.4200", "13.00"], // ← best YES bid (last element)
    ],
    "no_dollars": [
      // bids to buy NO, ascending by price
      ["0.5400", "8.00"],
      ["0.5600", "17.00"], // ← best NO bid → implies YES ask = 1.00 − 0.5600
    ],
  },
}

Three things to read from the shape above. (1) Both prices and counts are strings (fixed-point) to support sub-penny pricing and fractional contracts — old integer fields (yes_bid, count) were removed during the 2026 Q1 migration. (2) The arrays are sorted ascending by price; the best bid is the last element, not the first. (3) The YES ask is not in the response — derive it from the duality: YES_ask = 1.00 − best_NO_bid. In the example above, the best YES bid is $0.4200 and the best YES ask is 1.00 − 0.5600 = $0.4400, giving a $0.02 spread.

3.2 Portfolio (authenticated)

Once signing is working, the portfolio endpoints are uneventful:

  • GET /portfolio/balance — cash balance in cents.
  • GET /portfolio/positions — current open positions; use settlements/history endpoints for resolved positions.
  • GET /portfolio/settlements — settlement records for resolved markets.
  • GET /portfolio/orders — your order history; supports cursor pagination.
  • POST /portfolio/events/orders — recommended V2 endpoint for creating an order. Legacy POST /portfolio/orders remains supported but is no longer the forward-looking surface.
  • POST /portfolio/events/orders/{id}/amend — recommended V2 endpoint for amending a resting order.
  • POST /portfolio/events/orders/{id}/decrease — recommended V2 endpoint for reducing quantity.
  • DELETE /portfolio/events/orders/{id} — recommended V2 endpoint for cancelling a single order.
  • POST /portfolio/events/orders/batched — place many orders in one call. Note: each batched order still costs 10 tokens; batch is for round-trip efficiency, not for rate-limit savings.
  • DELETE /portfolio/events/orders/batched — bulk cancel.
  • GET /portfolio/fills — your individual fills.
  • GET /portfolio/subaccounts/balances — sub-account balances if you have them.

3.3 Historical data (the new partition)

Starting 2026-02-19, Kalshi split data into a live tier and a historical tier. Recent data lives at the regular endpoints; once a market settles or a fill ages past the cutoff, it moves to dedicated historical endpoints:

  • GET /historical/cutoff — returns the timestamp at which the live/historical boundary sits. Read this before deciding which family of endpoints to query.
  • GET /historical/markets — settled markets older than the cutoff.
  • GET /historical/trades — public trades older than the cutoff.
  • GET /historical/fills — your fills from before the cutoff.
  • GET /historical/orders — your cancelled or completed orders from before the cutoff.

Most third-party tutorials don't mention the historical tier yet. If you're backtesting against multi-month data, you'll need it.

3.4 Endpoint cheat sheet

EndpointMethodAuthNotes
/seriesGETPaginated, cursor-based
/eventsGETExcludes multivariate; use /events/multivariate
/marketsGETFilter by series_ticker, event_ticker, status
/markets/{ticker}/orderbookGETBids only; read backwards for best price
/markets/orderbooksGETUp to 100 tickers per call, added 2026-03
/markets/tradesGETRecent cross-market fills
/portfolio/balanceGETRSA-PSSCash position
/portfolio/positionsGETRSA-PSSCurrent open positions
/portfolio/settlementsGETRSA-PSSSettlement records
/portfolio/ordersGETRSA-PSSOrder history; live tier only for older completed/cancelled orders
/portfolio/events/ordersPOSTRSA-PSSRecommended V2 order create endpoint
/portfolio/events/orders/{id}DELETERSA-PSSRecommended V2 cancel endpoint
/portfolio/events/orders/{id}/amendPOSTRSA-PSSRecommended V2 amend endpoint
/portfolio/events/orders/{id}/decreasePOSTRSA-PSSRecommended V2 quantity decrease endpoint
/portfolio/events/orders/batchedPOST / DELETERSA-PSSRound-trip win, not a rate-limit win
/portfolio/fillsGETRSA-PSSYour fills
/historical/cutoffGETRSA-PSSLive/historical boundary
/historical/{markets,trades,fills,orders}GETMixedAnything older than cutoff; fills/orders are user-scoped
/account/limitsGETRSA-PSSYour current rate-limit budget
/account/endpoint_costsGETRSA-PSSToken cost per endpoint

4. Pagination, rate limits, and token costs

Kalshi API rate limits and pagination, in one paragraph. Pagination is cursor-based: every list endpoint accepts cursor and limit, returns a cursor field in the response, and signals "done" by an empty or missing cursor — there is no total count, so progress bars are approximate. Rate limiting is a token bucket since 2026-04-23, with independent Read and Write buckets and five tiers (Basic 200/100, Advanced 300/300, Premier 1000/1000, Paragon 2000/2000, Prime 4000/4000 tokens/second). Default cost per request is 10 tokens. Two traps: 429 responses carry no Retry-After and no X-RateLimit-* headers (use exponential backoff with jitter), and batched orders do not save tokens — 25 orders in one batched call still cost 250 tokens. Live budget is at GET /account/limits; per-endpoint cost is at GET /account/endpoint_costs.

Pagination is straightforward. Rate limits are the part everyone underestimates. Kalshi switched to a token-bucket model on 2026-04-23 with five tiers, two independent buckets per tier, and no Retry-After header. If you're writing anything that traverses thousands of markets — a backtest, a daily snapshot, an arbitrage scanner — it's worth understanding the bucket model precisely so your code doesn't get throttled into uselessness.

Rate · Step 1 — How pagination and the new bucket model work

Pagination is cursor-based. Every list endpoint that supports it (/markets, /events, /series, /markets/trades, /portfolio/history, /portfolio/fills, /portfolio/orders, and the historical list endpoints) accepts cursor and limit. Defaults and maximums vary by endpoint — for example, /events defaults to 200 while /markets can page up to 1,000. The response includes a cursor field; when it's empty or missing, you've reached the end. There is no total count in list responses — you cannot show a deterministic progress bar, only "fetched N so far."

Rate limiting is token-bucket since 2026-04-23. Each tier has a Read budget and a Write budget, both expressed in tokens-per-second; default cost per request is 10 tokens (cheaper for cancels and single-order reads). The two buckets are independent — saturating reads doesn't slow down writes.

TierRead tokens/sWrite tokens/sHow to qualify
Basic200100Just complete account onboarding
Advanced300300Fill out an application
Premier1,0001,000TBD by Kalshi
Paragon2,0002,000TBD
Prime4,0004,000TBD

So a Basic-tier user gets 20 reads/second and 10 writes/second by default. Most Write buckets can accumulate about two seconds of burst headroom, but Basic is the exception: its Write bucket holds only one second of budget. Live bucket refill rates and capacities are at GET /account/limits; per-endpoint costs are at GET /account/endpoint_costs.

The two practical surprises:

  1. There is no Retry-After header on a 429. There are also no X-RateLimit-* headers. The body is {"error": "too many requests"}. You're flying blind and have to rely on exponential backoff with jitter.
  2. Batched orders don't save tokens. Submitting 25 orders in one batched call costs 25 × 10 = 250 tokens, the same as 25 individual calls. Batching saves round-trip latency, not rate-limit budget.

Rate · Step 2 — A production-grade paginator

Below is the kind of paginator you want for any "fetch all markets in a series" job: cursor-aware, exponential-backoff for 429s, jitter to avoid thundering-herd retries, distinguishes a 429 (rate limit) from a 503 (transient server error) so you can log them differently, and persists the cursor so a crash doesn't restart from zero.

import json
import os
import random
import time
from pathlib import Path
from typing import Iterator
import requests

CURSOR_FILE = Path(".kalshi_cursor.json")

def paginate(
    session: requests.Session,
    base_url: str,
    path: str,
    headers_fn,
    *,
    item_key: str,
    job_id: str,
) -> Iterator[dict]:
    """Yield every item in a paginated Kalshi list, surviving 429s and crashes."""
    state = json.loads(CURSOR_FILE.read_text()) if CURSOR_FILE.exists() else {}
    cursor = state.get(job_id)

    while True:
        full_path = path + (f"&cursor={cursor}" if cursor else "")
        backoff = 0.5
        for attempt in range(8):
            resp = session.get(
                base_url + full_path,
                headers=headers_fn("GET", full_path.split("?")[0]),
                timeout=15,
            )
            if resp.status_code == 429:
                # No Retry-After. Exponential backoff + jitter.
                sleep = backoff + random.uniform(0, backoff)
                time.sleep(min(sleep, 30))
                backoff *= 2
                continue
            if 500 <= resp.status_code < 600:
                # Treat 5xx as transient but separate from rate-limiting.
                time.sleep(backoff)
                backoff = min(backoff * 2, 30)
                continue
            resp.raise_for_status()
            break
        else:
            raise RuntimeError(f"giving up on {full_path} after 8 retries")

        data = resp.json()
        for item in data.get(item_key, []):
            yield item

        cursor = data.get("cursor")
        state[job_id] = cursor
        CURSOR_FILE.write_text(json.dumps(state))
        if not cursor:
            return

Two implementation notes worth carrying around. First, the state[job_id] = cursor persistence write is not atomic — for a single-process script that's fine, but if you're running multiple paginators in parallel, switch to os.replace() on a tempfile. Second, the timeout of 15 seconds is deliberate: Kalshi sometimes takes 5–10 seconds to return on cold-cache series queries, and you don't want a 5-second timeout retrying into your own rate-limit ceiling.

Rate · Step 3 — Three real traps

Trap A — assuming one bucket. Read and Write share nothing. If you saturate reads (a backfill job), your writes (live trading) are unaffected, and vice versa. Conversely, building a single global semaphore against "Kalshi rate" wastes capacity. Track the two buckets separately.

Trap B — believing batching saves tokens. Programmers reach for batched endpoints expecting cheaper rate-limit cost. They're right about latency (one TCP round trip vs 25), wrong about tokens. If the goal is more orders per second under your write budget, batching only helps because of round-trip parallelism, not bucket math.

Trap C — wanting a progress bar. Cursor responses don't carry a total count, full stop. Some integrators have hacked together a count by walking the page list in parallel with a count probe — don't bother. Surface "fetched N so far" in your logs and accept that you don't know the size of the universe until you've enumerated it.

Rate · Step 4 — Where this goes next

The paginator above is correct and reusable, but it's also another piece of generic plumbing. The differentiating value of a Kalshi integration is whatever lives downstream of "got the data" — the trading logic, the surfacing in a UI, the cross-venue comparison.

Rate · Step 5 — Same job, two abstraction levels

# See Step 2 above — ~60 lines including cursor persistence,
# exponential backoff, jitter, 429 vs 503 differentiation, and
# the loop that consumes pages until cursor=null.
Connect Parlay MCP:
https://mcp.parlay.run/mcp

Ask your AI client:
Search Kalshi and Polymarket for active NYC weather markets.
Return the best matches with source links and note any missing venue coverage.

Expect:
Parlay handles venue-specific search and returns normalized research results.

Use the raw paginator for exhaustive backfills; use Parlay for normalized cross-venue research calls.

5. Real-time data via WebSocket

The Kalshi WebSocket in one paragraph. Connect to wss://external-api-ws.kalshi.com/trade-api/ws/v2; authentication is the same RSA-PSS signature as REST, attached as HTTP headers on the upgrade request, not on the subscribe message and not in the URL query string. Subscribe by sending JSON {"id":1,"cmd":"subscribe","params":{"channels":["orderbook_delta"],"market_tickers":["..."]}}. Public channels (ticker, trade, market_lifecycle_v2, multivariate) broadcast to everyone; private channels (orderbook_delta, fill, market_positions, user_orders, order_group_updates, communications) require an authenticated connection. Heartbeats are server-driven — Kalshi sends a Ping every 10 seconds and your client must respond Pong (most libraries do this automatically). To recover from a network blip without dropping the subscription, use the get_snapshot action added 2026-04-20. The deprecated ticker_v2 channel was retired 2026-02-12; use ticker.

Most non-trivial Kalshi integrations end up needing the WebSocket. Polling /markets/{ticker}/orderbook every second is wasteful and gives you stale views of any market that moves quickly. The WebSocket gives you incremental orderbook deltas, fills, and lifecycle events with sub-second latency.

How the WebSocket works

The recommended WebSocket URL is wss://external-api-ws.kalshi.com/trade-api/ws/v2. The older shared host wss://api.elections.kalshi.com/trade-api/ws/v2 remains supported for compatibility. Authentication during the handshake uses HTTP headers — not query-string-encoded credentials. Sign the path /trade-api/ws/v2 with the same RSA-PSS algorithm as for REST, and attach the same three headers (KALSHI-ACCESS-KEY, KALSHI-ACCESS-TIMESTAMP, KALSHI-ACCESS-SIGNATURE) to the upgrade request. Old blog posts that show a query-string signature are outdated.

Once connected, you subscribe by sending JSON commands:

{
  "id": 1,
  "cmd": "subscribe",
  "params": {
    "channels": ["orderbook_delta"],
    "market_tickers": ["KXHIGHNY-23DEC25-T75"]
  }
}

Channels split into two groups. Public channels — ticker, trade, market_lifecycle_v2, multivariate_market_lifecycle, multivariate — broadcast across all users. Private channels — orderbook_delta, fill, market_positions, communications, order_group_updates, user_orders — return data scoped to your account; these require connection-level authentication (which you already did during the handshake). Note that orderbook_delta is technically a private channel because the response includes your own client_order_id, but the underlying market data is public — the privacy is in the per-user enrichment.

Heartbeats are server-driven: every 10 seconds, Kalshi sends a Ping with body heartbeat, and your client must respond with a Pong. Python's websockets library does this automatically; in other ecosystems (Go, raw browser WebSocket) you may need to wire it explicitly. If you stop responding to Pings, the server closes the connection.

A note on ticker_v2: this channel was retired on 2026-02-12. Use ticker. Also new in April 2026: orderbook_delta now supports a get_snapshot action — useful when you've reconnected and want to resync without dropping the existing subscription. Several message families now include millisecond timestamps such as ts_ms or created_ts_ms; prefer those where present.

Minimal Python WebSocket client

import asyncio
import json
import time
from urllib.parse import urlparse

import websockets
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
import base64

KEY_ID = "..."
private_key = serialization.load_pem_private_key(
    open("key.pem", "rb").read(), password=None
)
WS_URL = "wss://external-api-ws.kalshi.com/trade-api/ws/v2"

def auth_headers():
    ts = str(int(time.time() * 1000))
    path = urlparse(WS_URL).path
    msg = (ts + "GET" + 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-KEY", KEY_ID),
        ("KALSHI-ACCESS-TIMESTAMP", ts),
        ("KALSHI-ACCESS-SIGNATURE", base64.b64encode(sig).decode()),
    ]

async def main():
    async with websockets.connect(WS_URL, additional_headers=auth_headers()) as ws:
        await ws.send(json.dumps({
            "id": 1,
            "cmd": "subscribe",
            "params": {
                "channels": ["orderbook_delta"],
                "market_tickers": ["KXHIGHNY-23DEC25-T75"],
            }
        }))
        async for raw in ws:
            msg = json.loads(raw)
            if msg.get("type") == "orderbook_snapshot":
                print("snapshot:", msg["msg"]["yes_dollars_fp"][-1])  # best YES bid
            elif msg.get("type") == "orderbook_delta":
                print("delta:", msg["msg"])

asyncio.run(main())

The library handles Pong responses for you. The first message after subscribe is a orderbook_snapshot with the full book; every subsequent message is an orderbook_delta describing a single change.

WebSocket · Three real traps

Trap A — confusing connection auth with channel auth. A private channel like orderbook_delta requires the connection to be authenticated, but you authenticate the connection during the handshake — not by re-signing the subscribe message. This catches people who saw a WS error code 9: Authentication required after subscribing and started looking for a way to attach auth to the JSON payload. There isn't one; fix the handshake.

Trap B — sequence gaps after a disconnect. The orderbook_delta stream uses sequence numbers. If you reconnect after even a brief network blip, you may have missed deltas. The new get_snapshot action (added 2026-04-20) is the clean fix — request a fresh snapshot and resume processing deltas from there. Don't try to manually replay missed deltas; they aren't replayable.

Trap C — the connection cap. Maximum WebSocket connections per account is tier-based; default is 200. That sounds generous, but if you're running an arbitrage scanner that opens a connection per market, 200 is small. Plan for multiplexing many subscriptions onto fewer connections — subscribe accepts arrays of market_tickers for a reason.

Same stream, fewer lines

# See the Python example above — ~50 lines for a single
# subscription, no reconnect, no gap recovery, no multiplexing.
Connect Parlay MCP:
https://mcp.parlay.run/mcp

Ask your AI client:
Inspect this Kalshi market and compare it with any similar Polymarket market
you can find. Include source links and note whether live pricing is available.

Expect:
Snapshot-style research output. For raw live deltas, use Kalshi WebSocket directly.

Raw streaming client for live deltas; Parlay covers normalized snapshot-style research calls.

6. Common errors and how to fix them

These are the failures every Kalshi integration runs into eventually. Grouped by category, each entry is the symptom you'll see, the root cause, and the smallest fix.

Authentication failures

Rate-limit failures

Order-placement failures (new error codes, 2026-01-26)

WebSocket failures

Data-migration failures (the 2026-Q1 trap)

7. Beyond Kalshi: querying across markets

If your application only ever talks to Kalshi, the official kalshi_python_sync, kalshi_python_async, and kalshi-typescript SDKs already cover the core API surface and request signing. There's no point reimplementing an RSA-PSS wrapper unless you need tight control over retries, observability, or generated client behavior.

The case for a different approach starts when your application needs to span multiple prediction-market venues. Kalshi is one of Parlay's primary venue sources alongside Polymarket (on-chain, EIP-712 signing, Polygon-based, settling in USDC), Limitless, and Manifold (play-money, REST-oriented). Opinion.trade is tracked as a limited-liquidity status source rather than a primary venue in the current MVP. Each venue has its own auth scheme, pagination semantics, rate-limit model, and market-identifier format. Writing multiple integrations and a unifying layer on top is real work once you account for testing, error handling, and the long tail of edge cases.

That's the layer Parlay's MCP server fills for research workflows. The read-only tools — market search, discovery, comparison, discrepancy scans, platform inspection, and source-aware briefs — work across the primary venues with normalized market identifiers and a single client-facing schema, callable from Claude or any MCP-aware client. The Kalshi-specific affordances — RSA-PSS signing, fixed-point fields, and the bids-only orderbook quirk — still matter under the hood, but you don't have to normalize them by hand for every comparison.

If you're building a single-venue Kalshi tool, you don't need Parlay; the official SDK is the right answer. If you're building anything that asks "what does this market price across venues?" or "where is the arbitrage opportunity right now?", Parlay is the layer you'd otherwise have to build yourself.

8. Frequently asked questions

9. Next steps

If you're building on Kalshi specifically, the next things worth your time are the API environments page, the official rate-limits page, and the fixed-point migration page — all contain edge cases this guide didn't cover. If you're building a multi-venue tool, the Polymarket API guide walks through the equivalent pieces for Polymarket's CLOB API on Polygon. For a venue-level comparison, read Polymarket vs Kalshi to compare regulation, fees, liquidity, APIs, and cross-venue arbitrage tradeoffs side by side. To call any of this through Claude or another MCP-aware client without writing the signing code yourself, install the Parlay MCP server and ask the assistant directly.