NEW: Live arbitrage across 10+ prediction markets.Arbitrage →
← Index
APISep 10, 2026

Kalshi WebSocket API: Rate Limits, Orderbook and Examples

Kalshi WebSocket API: Rate Limits, Orderbook and Examples

The Short Answer

The Kalshi WebSocket API lives at wss://external-api-ws.kalshi.com/trade-api/ws/v2, and every connection is authenticated in the HTTP handshake with three headers, even for public market data. Commands are JSON: subscribe, unsubscribe, list_subscriptions and update_subscription. The orderbook_delta channel answers with one orderbook_snapshot then incremental deltas, each stamped with a seq. Kalshi sends a Ping frame every 10 seconds.

Key Takeaways

  • Production is wss://external-api-ws.kalshi.com/trade-api/ws/v2 and demo is wss://external-api-ws.demo.kalshi.co/trade-api/ws/v2, with the legacy shared hosts still working.
  • Sign timestamp + "GET" + "/trade-api/ws/v2" with RSA-PSS, MGF1(SHA256), salt length PSS.DIGEST_LENGTH, SHA256, and base64 it into three handshake headers.
  • The channel enum holds 13 names. orderbook_delta requires a market filter, market_ticker or market_tickers, and rejects market_id.
  • Keep-alive is protocol level: answer the 10 second Ping (0x9) with a Pong (0xA). No JSON ping command exists.
  • Predictefy exposes one WebSocket at /v1/stream across 15+ venues on a normalized schema, and the Free plan opens 2 streams at 0 USD.

What is the Kalshi WebSocket API and where do you connect?

It is a push feed for Kalshi market data and account events, described by an AsyncAPI 3.0.0 document titled "Kalshi Market Data WebSocket API", version 2.0.0. The spec declares one server: host external-api-ws.kalshi.com, pathname /trade-api/ws/v2, protocol wss, secured by the apiKey scheme. The human reference lives at docs.kalshi.com, and the machine-readable spec behind it, asyncapi.yaml, goes straight into a codegen tool. Four hosts are documented, and each environment issues its own API keys.

EnvironmentURL
Production, recommendedwss://external-api-ws.kalshi.com/trade-api/ws/v2
Demowss://external-api-ws.demo.kalshi.co/trade-api/ws/v2
Production, legacy shared hostwss://api.elections.kalshi.com/trade-api/ws/v2
Demo, legacy shared hostwss://demo-api.kalshi.co/trade-api/ws/v2

How do you authenticate a Kalshi WebSocket connection?

Authentication happens in the HTTP upgrade request, not a JSON message sent afterward. Three headers go out with the socket:

  • KALSHI-ACCESS-KEY, your API key id
  • KALSHI-ACCESS-SIGNATURE, the base64 signature
  • KALSHI-ACCESS-TIMESTAMP, unix milliseconds

The string you sign is the timestamp, then the literal GET, then the path /trade-api/ws/v2, with no separators. Kalshi's own runnable sample signs it with RSA-PSS using MGF1(SHA256), salt_length = PSS.DIGEST_LENGTH and hash SHA256, then base64 encodes the bytes.

The key id and the private key both come from the key pair you create in your Kalshi account settings. Each is shown once, so save the .pem before closing the dialog, and create a separate key inside the demo account for demo. Full walkthrough in the Kalshi API key guide.

The connection always requires authentication. Public channels such as ticker and trade still ride an authenticated session, so "public" means no extra channel-level authorization, not anonymous access.

What does a Kalshi WebSocket example look like in Python?

The smallest client that connects, subscribes and prints messages. Install the two dependencies first with pip install websockets cryptography, then save your key as kalshi-private-key.pem beside the script.

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

KEY_ID = "your-api-key-id"
PATH = "/trade-api/ws/v2"
URL = "wss://external-api-ws.kalshi.com" + PATH

with open("kalshi-private-key.pem", "rb") as f:
    private_key = serialization.load_pem_private_key(f.read(), password=None)

def auth_headers():
    ts = str(int(time.time() * 1000))
    message = (ts + "GET" + PATH).encode()
    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-SIGNATURE": base64.b64encode(signature).decode(),
        "KALSHI-ACCESS-TIMESTAMP": ts,
    }

async def main():
    async with websockets.connect(URL, additional_headers=auth_headers()) as ws:
        await ws.send(json.dumps({
            "id": 1,
            "cmd": "subscribe",
            "params": {
                "channels": ["orderbook_delta"],
                "market_tickers": ["REPLACE-WITH-A-LIVE-TICKER"],
            },
        }))
        async for raw in ws:
            print(raw)

asyncio.run(main())

What this does

It builds a fresh timestamp and signature, opens the socket with the three headers, sends one subscribe command with client id 1, then loops over inbound frames. Older releases of websockets take extra_headers rather than additional_headers, and the library answers Ping frames for you, so this client needs no Pong code. Get a live ticker first: curl -s 'https://api.elections.kalshi.com/trade-api/v2/markets?status=open&limit=1' and read markets[0].ticker. A closed or misspelled ticker subscribes cleanly and then sends nothing, which looks exactly like a broken client.

Kalshi publishes starter Python code at github.com/Kalshi/kalshi-starter-code-python, which wraps the same RSA-PSS signing block in a ready-made WebSocket client class, and third-party clients sit under the kalshi topic. Diff any repo's orderbook field names against asyncapi.yaml first. A repo parsing msg["yes"] or msg["delta"] as whole-cent integers is on the retired shape, since the current fields are the fixed-point strings yes_dollars_fp, no_dollars_fp, price_dollars and delta_fp. Old code reads the wrong shape silently rather than raising.

How do you subscribe to Kalshi WebSocket channels?

Every command is JSON with a cmd, a client-generated integer id unique within the session, and usually params. An id of 0 counts as none. The server replies with a subscribed message carrying a server-generated sid, which you use from then on.

{
  "id": 1,
  "cmd": "subscribe",
  "params": {
    "channels": ["orderbook_delta"],
    "market_ticker": "CPI-22DEC-TN0.1"
  }
}

{
  "id": 1,
  "type": "subscribed",
  "msg": {
    "channel": "orderbook_delta",
    "sid": 1
  }
}

Four commands exist: subscribe, unsubscribe, list_subscriptions and update_subscription, whose action enum is add_markets, delete_markets and get_snapshot, plus CF Benchmarks index and Pyth underlying variants. Both unsubscribe and update_subscription target subscriptions through a params.sids array.

{ "id": 3, "cmd": "list_subscriptions" }

{ "id": 124, "cmd": "unsubscribe", "params": { "sids": [1, 2] } }

{ "id": 125, "cmd": "update_subscription",
  "params": { "sids": [456], "market_tickers": ["NEW-1"], "action": "add_markets" } }

The channel enum holds 13 names: orderbook_delta, ticker, trade, fill, market_positions, market_lifecycle_v2, multivariate_market_lifecycle, communications, order_group_updates, user_orders, cfbenchmarks_value, cfbenchmarks_value_5hz and pyth_value. Kalshi groups orderbook_delta, fill, market_positions, communications and order_group_updates as private, and ticker, trade, market_lifecycle_v2 and multivariate_market_lifecycle as public market data. user_orders is private too and reports order state changes, resting, cancelled, expired, where fill reports only executions, so an order tracker wants both. cfbenchmarks_value, its 5Hz variant and pyth_value carry index and underlying prices rather than book updates.

Two channels are gone. The old multivariate channel and its multivariate_lookup message were removed on August 6, 2026, so subscribing returns an unknown-channel error, and experimental ticker_v2 went too. For lifecycle events, market_lifecycle_v2 covers everything except KXMVE*, which multivariate_market_lifecycle covers. Both emit event_lifecycle, plus event_fee_update on market_lifecycle_v2.

How does the Kalshi WebSocket orderbook work?

Subscribe to orderbook_delta and you get one orderbook_snapshot, then incremental deltas. The channel needs a market filter, market_ticker for one market or market_tickers for a list, and rejects market_id and market_ids. Omitting the filter returns error 14. The spec does not say whether ticker and trade accept an unfiltered subscription, so check asyncapi.yaml before planning to stream a whole venue. Error 26 caps how many markets one subscription holds.

{
  "type": "orderbook_snapshot",
  "sid": 2,
  "seq": 2,
  "msg": {
    "market_ticker": "FED-23DEC-T3.00",
    "yes_dollars_fp": [["0.0800", "300.00"], ["0.2200", "333.00"]],
    "no_dollars_fp": [["0.5400", "20.00"], ["0.5600", "146.00"]]
  }
}

{
  "type": "orderbook_delta",
  "sid": 2,
  "seq": 3,
  "msg": {
    "market_ticker": "FED-23DEC-T3.00",
    "price_dollars": "0.9600",
    "delta_fp": "-54.00",
    "side": "yes",
    "ts": "2022-11-22T20:44:01Z",
    "ts_ms": 1669149841000
  }
}

What this does

The snapshot gives both sides as arrays of [price_in_dollars, contract_count_fp] string pairs, and a side with no offers has its key absent rather than an empty array. Each delta is a signed adjustment, not a replacement: add delta_fp to the quantity standing at price_dollars on that side, treat a missing level as zero, and delete the level when it reaches zero rather than keeping a zero-quantity price. yes and no are two independent books, so a yes delta never touches the no side. Parse the strings as Decimal, since float arithmetic on cent-level prices drifts. Use ts_ms, since ts is deprecated, and expect an optional client_order_id only when your own order moved the book.

Every snapshot and delta carries a required seq, described in the spec as the sequential number to check if you want to guarantee you received all messages, minimum 1. The spec's own snapshot example arrives at seq 2, so do not assume a snapshot restarts numbering at 1, and the docs never say whether numbering runs per subscription, connection or market. Treat a jump as a stale book.

Two documented mechanisms let you reseed, though the docs never prescribe them as the seq-gap procedure. The cheap one is update_subscription with action: "get_snapshot", which returns a fresh orderbook_snapshot for the markets you name without changing the subscription.

{
  "id": 127,
  "cmd": "update_subscription",
  "params": {
    "sids": [456],
    "market_tickers": ["MARKET-1", "MARKET-2"],
    "action": "get_snapshot"
  }
}

The second is forced on you. Codes 10 (Channel error) and 25 (Subscription buffer overflow) end the subscription, so the old sid is gone and there is nothing to unsubscribe from. The socket stays up: send a fresh subscribe with a new id, discard your local book, and rebuild it from the snapshot that arrives under the new sid. Code 25 means you were reading too slowly, so drain frames into a queue and process them off the socket thread.

What are the Kalshi WebSocket rate limits?

Kalshi publishes no WebSocket connection limit. The token bucket table in the rate limits guide covers REST and FIX, and the AsyncAPI spec carries no connection ceiling of any kind. A default of 200 connections per user circulates widely in third party guides, but it does not appear in Kalshi’s own documentation, so confirm it with Kalshi before you design around it.

Two error codes prove message-level limits exist. Code 27 is "Too many requests, the subscription exceeded its command rate limit" and code 26 is "Subscription market limit exceeded". Neither carries a number, so space out commands, split large market lists across subscriptions, and treat both as backpressure.

The token bucket page covers REST and FIX, which drain the same buckets, and never mentions WebSocket. Those budgets still matter, since most socket clients also poll REST: reads run from 200 per second on Basic to 10,000 on Prestige, most requests cost 10 tokens, and a limited request returns 429 with the body {"error": "too many requests"} and no Retry-After header, so your client needs its own backoff. Full per-tier table in the Kalshi API rate limits guide.

How do you keep a Kalshi WebSocket connection alive?

Kalshi sends a Ping control frame (0x9) every 10 seconds with the body heartbeat, and clients should reply with Pong (0xA). Your own Pings get a Pong back. This is protocol-level framing, not a JSON heartbeat, so there is no {"cmd":"ping"} to add to your command handler.

Python's websockets answers Pings automatically, hand-rolled clients often do not, and a client that never Pongs goes quiet without an obvious error. Kalshi publishes no idle timeout, missed-Pong threshold or maximum connection lifetime, so key reconnect logic off observed silence. Exponential backoff is recommended, with no enforced numbers.

What do the Kalshi WebSocket error codes mean?

Errors arrive as their own message type carrying the numeric code:

{
  "type": "error",
  "msg": {
    "code": 7,
    "msg": "Unknown subscription ID"
  }
}
CodeMeaning
1Unable to process message
2Params required
3Channels required
4Subscription IDs required
5Unknown command
7Unknown subscription ID
8Unknown channel name
9Authentication required
10Channel error, terminal, resubscribe
11Invalid parameter
12Exactly one subscription ID required
13Unsupported action
14Market ticker required
15Action required
18Command timeout
19 to 22Communications sharding errors
23Match IDs required
24Index IDs required
25Subscription buffer overflow, terminal, resubscribe
26Subscription market limit exceeded
27Too many requests, command rate limit
28Underlying tickers required

Codes 6, 16 and 17 are retired and never emitted, so drop them from any inherited switch statement. Codes 10 and 25 need their own branch, since they end the subscription rather than reject one command.

How do you stream Kalshi and other venues on one WebSocket?

Everything above buys you one venue. Add a second and the work is duplicated, not extended: another auth scheme, another envelope, another heartbeat convention, another reconnect state machine, then a normalization layer over two orderbook shapes. Kalshi sends fixed-point dollar strings with a seq per message. The next venue will not, as the Polymarket API guide shows.

Predictefy collapses that into one connection. The reads API sits at https://data.predictefy.com behind an Authorization: Bearer pk_live_... header, REST paths follow /api/{venue}/{verb}, and the WebSocket is one stream at /v1/stream on your stream origin, carrying 15+ venues on a normalized schema. Stored history covers 11 venues, and the arbitrage API is free.

wscat -c wss://<your-stream-origin>/v1/stream -H "Authorization: Bearer pk_live_..."

The Free plan is 0 USD with 25,000 credits a month and 2 WebSocket streams, enough to hold a live cross-venue book open while you build, and paid plans start at 49 USD a month when you need more. Install the client with npm i @predictefy/sdk@1.0.0-beta.6 or pip install predictefy==1.0.0b4, both available in beta. Agents connect through the MIT licensed MCP server with npx -y @predictefy/mcp, built so that no tool both builds and submits an order. Execution is a separate non-custodial service with orders signed client side, so key custody stays with you.

Frequently Asked Questions

Does the Kalshi WebSocket have a rate limit?

Yes, though the numbers are not published. Code 27 fires when a subscription exceeds its command rate limit, and code 26 when adding markets would exceed the per-subscription market limit. Kalshi documents no WebSocket connection limit. The token bucket table covers REST and FIX only, so the widely repeated figure of 200 connections per user is not something Kalshi publishes.

How do I authenticate to the Kalshi WebSocket in Python?

In Python, load your RSA private key with cryptography, build a unix millisecond timestamp, and sign the string timestamp plus GET plus /trade-api/ws/v2 using PSS with MGF1(SHA256) and SHA256. Base64 the signature, then pass KALSHI-ACCESS-KEY, KALSHI-ACCESS-SIGNATURE and KALSHI-ACCESS-TIMESTAMP to websockets.connect as additional_headers. Signing happens in the handshake, never after connecting.

Does the Kalshi WebSocket time out if it goes idle?

Kalshi publishes no idle timeout, no missed-Pong threshold and no maximum connection lifetime. What is documented is a Ping control frame every 10 seconds with the body heartbeat, which your client answers with a Pong frame. Build reconnect logic around observed silence and exponential backoff.

What is seq in a Kalshi orderbook message?

It is the sequence number on every orderbook_snapshot and orderbook_delta, documented as the value to check if you want to guarantee you received all messages, minimum 1. The spec does not say whether numbering runs per subscription, per connection or per market, and its own snapshot example shows seq 2.

Can I use my demo API key on the production WebSocket?

No. Demo and production are separate environments with separate API keys. Demo runs at wss://external-api-ws.demo.kalshi.co/trade-api/ws/v2 and production at wss://external-api-ws.kalshi.com/trade-api/ws/v2, with the legacy hosts demo-api.kalshi.co and api.elections.kalshi.com still supported. Point each key at its matching environment, and keep the two key files clearly separated in config.