The Kalshi API provides documented programmatic market-data, order, and portfolio workflows through REST and WebSocket interfaces. It is not identical to the website, and endpoints, permissions, payloads, and limits change. This tutorial covers a practical subset; verify every production call against the current official documentation.
This is a reference guide. If you want a start-to-finish bot tutorial, read our Build a Kalshi Bot with Python guide first, then come back here for the API deep dive.
REST vs. WebSocket: the production split
A reliable Kalshi bot does not choose one interface. It gives each interface the work it is designed to do:
| Job | Use | Why |
|---|---|---|
| Discover events and markets | REST | Paginated snapshots are easy to filter, cache, and resume. |
| Bootstrap an order book | REST or the WebSocket snapshot | Start from a complete state before applying incremental changes. |
| Follow orderbook, trade, and lifecycle updates | WebSocket | Streaming avoids wasteful tight polling and reduces stale-data risk. |
| Place, amend, or cancel orders | REST | Commands need explicit responses, stable client IDs, and bounded retries. |
| Reconcile orders, positions, balance, and fills | REST | The exchange's current snapshot and fill ledger are the source of truth. |
| Recover after a disconnect | Both | Reconnect and resubscribe, then rebuild any uncertain state before acting. |
Use REST for setup, snapshots, order placement, cancellation, fills, and account state. Use WebSockets when the strategy depends on live order-book or trade updates. A production bot usually uses both: REST to place and reconcile orders, WebSocket to avoid polling every market every few seconds.
The recommended production REST base URL is https://external-api.kalshi.com/trade-api/v2; demo uses https://external-api.demo.kalshi.co/trade-api/v2. The dedicated WebSocket hosts are wss://external-api-ws.kalshi.com/trade-api/ws/v2 for production and wss://external-api-ws.demo.kalshi.co/trade-api/ws/v2 for demo. Older shared hosts remain supported for compatibility, but new integrations should use the dedicated Trade API hosts.
Authentication
Kalshi authenticated requests use an API key ID plus an RSA private key. Every private request carries three headers: KALSHI-ACCESS-KEY, KALSHI-ACCESS-TIMESTAMP, and KALSHI-ACCESS-SIGNATURE. The signature is built from the timestamp, HTTP method, and full request path from the API root, without query parameters.
import base64
import datetime
import requests
from urllib.parse import urlparse
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
class KalshiAPI:
BASE_URL = "https://external-api.kalshi.com/trade-api/v2"
def __init__(self, key_id, private_key_path):
self.key_id = key_id
self.session = requests.Session()
with open(private_key_path, "rb") as f:
self.private_key = serialization.load_pem_private_key(
f.read(),
password=None,
)
def _headers(self, method, path):
timestamp = str(int(datetime.datetime.now().timestamp() * 1000))
sign_path = urlparse(self.BASE_URL + path).path.split("?")[0]
message = f"{timestamp}{method.upper()}{sign_path}".encode("utf-8")
signature = self.private_key.sign(
message,
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.DIGEST_LENGTH,
),
hashes.SHA256(),
)
return {
"Content-Type": "application/json",
"KALSHI-ACCESS-KEY": self.key_id,
"KALSHI-ACCESS-TIMESTAMP": timestamp,
"KALSHI-ACCESS-SIGNATURE": base64.b64encode(signature).decode("utf-8"),
}
def get(self, path, params=None):
resp = self.session.get(
f"{self.BASE_URL}{path}",
params=params,
headers=self._headers("GET", path),
)
resp.raise_for_status()
return resp.json()
def post(self, path, json=None):
resp = self.session.post(
f"{self.BASE_URL}{path}",
json=json,
headers=self._headers("POST", path),
)
resp.raise_for_status()
return resp.json()
def delete(self, path):
resp = self.session.delete(
f"{self.BASE_URL}{path}",
headers=self._headers("DELETE", path),
)
resp.raise_for_status()
return resp.json()
Market Data Endpoints
List Markets
# Get open markets
markets = api.get("/markets", params={
"status": "open",
"limit": 50,
"cursor": None, # for pagination
})
for m in markets["markets"]:
print(f"{m['ticker']}: {m['title']}")
print(f" YES bid/ask: ${m['yes_bid_dollars']}/${m['yes_ask_dollars']}")
print(f" Volume: {m['volume_fp']} contracts")
Get Single Market
market = api.get("/markets/TICKER-HERE")
print(market["market"]["title"])
print(f"Status: {market['market']['status']}")
print(f"Settle time: {market['market']['close_time']}")
Get Orderbook
book = api.get("/markets/TICKER-HERE/orderbook")
print("YES bids:", book["orderbook_fp"]["yes_dollars"])
print("NO bids:", book["orderbook_fp"]["no_dollars"])
Authenticated WebSocket streaming and recovery
Every WebSocket connection is authenticated during the HTTP upgrade, even when you subscribe to public market-data channels. Sign the exact path /trade-api/ws/v2 with method GET; do not sign the REST path or include a query string. Once connected, send a subscribe command for the channels and market tickers you need.
For orderbook_delta, treat the initial orderbook_snapshot as state and each later delta as an ordered mutation. Track seq. If a number is skipped, stop acting on the local book, reconnect, and wait for a fresh snapshot rather than guessing what changed. Kalshi also sends a WebSocket Ping control frame about every 10 seconds; the websockets client responds with Pong automatically.
import asyncio
import base64
import json
import random
import time
import websockets
from websockets.exceptions import ConnectionClosed
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
WS_URL = "wss://external-api-ws.kalshi.com/trade-api/ws/v2"
WS_PATH = "/trade-api/ws/v2"
class SequenceGap(RuntimeError):
pass
def websocket_headers(api):
timestamp = str(int(time.time() * 1000))
message = f"{timestamp}GET{WS_PATH}".encode("utf-8")
signature = api.private_key.sign(
message,
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.DIGEST_LENGTH,
),
hashes.SHA256(),
)
return {
"KALSHI-ACCESS-KEY": api.key_id,
"KALSHI-ACCESS-TIMESTAMP": timestamp,
"KALSHI-ACCESS-SIGNATURE": base64.b64encode(signature).decode("utf-8"),
}
async def stream_orderbook(api, ticker):
attempt = 0
while True:
try:
async with websockets.connect(
WS_URL,
additional_headers=websocket_headers(api),
ping_interval=20,
ping_timeout=20,
) as ws:
await ws.send(json.dumps({
"id": 1,
"cmd": "subscribe",
"params": {
"channels": ["orderbook_delta"],
"market_tickers": [ticker],
},
}))
last_seq = None
async for raw in ws:
event = json.loads(raw)
if event.get("type") == "orderbook_snapshot":
rebuild_book(event["msg"]) # your local state
last_seq = event["seq"]
attempt = 0 # healthy resync
elif event.get("type") == "orderbook_delta":
if last_seq is None or event["seq"] != last_seq + 1:
raise SequenceGap("orderbook sequence gap")
apply_delta(event["msg"]) # mutate local state
last_seq = event["seq"]
except (OSError, ConnectionClosed, SequenceGap):
# Re-authenticate and resubscribe on the next loop. A new snapshot
# replaces any uncertain local book before trading resumes.
delay = min(0.5 * (2 ** attempt), 30) + random.uniform(0, 0.25)
await asyncio.sleep(delay)
attempt = min(attempt + 1, 6)
The example deliberately does not place an order from inside the message handler. Update validated state first, let your strategy evaluate that state, and send any order through the REST client with its own risk checks and idempotency key. That separation keeps a malformed or duplicated stream message from becoming a trade.
See Kalshi's subscription schema, snapshot and delta payloads, and keep-alive behavior for the current wire format.
Order Management
Place an Order
For event markets, use the V2 event-order endpoints. The V2 shape uses a single-book bid/ask side and fixed-point dollar prices, plus an optional client_order_id for idempotent retries.
import uuid
order = api.post("/portfolio/events/orders", json={
"ticker": "TICKER-HERE",
"side": "bid",
"count": "1",
"price": "0.3500",
"time_in_force": "good_till_canceled",
"self_trade_prevention_type": "taker_at_cross",
"client_order_id": str(uuid.uuid4()),
})
print(f"Order ID: {order['order_id']}")
Cancel an Order
api.delete(f"/portfolio/events/orders/{order_id}")
Get Fills
fills = api.get("/portfolio/fills", params={"ticker": "TICKER-HERE"})
for f in fills["fills"]:
print(
f"Filled {f['count_fp']} @ ${f['yes_price_dollars']} "
f"— {f['created_time']}"
)
Order Lifecycle Checklist
- Generate a unique
client_order_idbefore submit so retry logic cannot duplicate an order. - Submit a limit order instead of crossing a thin book blindly.
- Persist both IDs: your
client_order_idand Kalshi's returnedorder_id. - Reconcile fills from the fills endpoint rather than assuming your limit price was the execution price.
- Cancel stale orders explicitly when the signal expires or the market moves away.
Portfolio Endpoints
# Balance
balance = api.get("/portfolio/balance")
print(f"Available: ${balance['balance'] / 100:.2f}")
# Positions
positions = api.get("/portfolio/positions")
for p in positions["market_positions"]:
print(f"{p['ticker']}: {p['position_fp']} contracts")
Kalshi API rate limits, tiers, and safe retries
Kalshi uses token-based limits with separate read and write buckets. Your API tier sets each bucket's per-second refill budget, and each endpoint deducts its documented token cost. Most requests currently cost 10 tokens; GET /account/endpoint_costs is the authoritative list of operations that differ. Effective calls per second are therefore bucket budget / endpoint cost, not the headline token number.
These were the published per-second budgets when we checked the official reference on July 16, 2026:
| Tier | Read tokens/second | Write tokens/second |
|---|---|---|
| Basic | 200 | 100 |
| Advanced | 300 | 300 |
| Expert | 600 | 600 |
| Premier | 1,000 | 1,000 |
| Paragon | 2,000 | 2,000 |
| Prime | 4,000 | 4,000 |
| Prestige | 6,000 | 8,000 |
Do not freeze those values into production logic. The authenticated GET /account/limits endpoint returns your actual usage tier, refill rates, and bucket capacities:
limits = api.get("/account/limits")
print("tier:", limits["usage_tier"])
print("read:", limits["read"]["refill_rate"], limits["read"]["bucket_capacity"])
print("write:", limits["write"]["refill_rate"], limits["write"]["bucket_capacity"])
- Reads and writes refill independently. A market-data burst does not consume the order-write bucket, and an order burst does not consume the read bucket.
- Batch endpoints charge per item. A batch of 25 default-cost creates consumes 250 tokens. Batching saves round trips, not token spend.
- Burst capacity depends on the bucket. Basic and Advanced Predictions Read buckets and Write buckets above Basic hold up to two seconds of budget. Higher Predictions Read buckets and Basic Write hold one second. There is no reason to spend the entire burst on background work; reserve write capacity for cancels and risk actions.
- A 429 is not a timed lockout. The next request can succeed as soon as the relevant bucket refills enough tokens.
Bounded exponential backoff with jitter
A rate-limited request returns HTTP 429 with {"error": "too many requests"}. Kalshi does not currently send Retry-After or X-RateLimit-* headers, so the client must choose a bounded delay. Jitter prevents several workers from retrying on the same millisecond.
import random
import time
def request_with_backoff(
api, method, path, *, params=None, json_body=None, max_attempts=6
):
for attempt in range(max_attempts):
response = api.session.request(
method,
f"{api.BASE_URL}{path}",
params=params,
json=json_body,
headers=api._headers(method, path), # fresh timestamp/signature
timeout=15,
)
if response.status_code != 429:
response.raise_for_status()
return response.json()
if attempt == max_attempts - 1:
response.raise_for_status()
delay = min(0.25 * (2 ** attempt), 2.0)
time.sleep(delay + random.uniform(0, 0.10))
raise RuntimeError("unreachable")
Never turn a retry into a duplicate order. Create one client_order_id before the first write attempt, keep it unchanged across retries, and reconcile the returned or existing order from Kalshi. A network timeout can happen after Kalshi accepts a request but before your client receives the response; generating a new ID inside the retry loop can double the position.
For multiple bots, put one limiter and one request queue in front of the shared account rather than giving every worker its own imaginary full budget. Cache slow-changing REST data, move live book and trade monitoring to WebSockets, record every 429 and retry delay, and alert when risk-critical writes are competing with background traffic. The current primary references are Kalshi's rate-limit and tier guide, account-limits endpoint, and non-default endpoint-cost list.
Best Practices
- Cache market data — Don't fetch the same market every second. Cache for 5-30 seconds depending on your strategy's time sensitivity.
- Use limit orders — Market orders in thin markets can fill at bad prices.
- Handle errors gracefully — The API will return errors for insufficient balance, closed markets, invalid tickers, etc. Parse and handle each.
- Log everything — Every API call, every response. You'll need this for debugging and tax reporting.
For a complete bot from scratch, continue with our Python bot tutorial. Before trusting it with capital, use the production deployment guide to test restarts, stale-feed stops, idempotent retries, reconciliation, and alerts.
Frequently Asked Questions
Quick answers to common questions about Kalshi API Tutorial: Auth, WebSockets, Rate Limits & Orders.
Does Kalshi have an API?
Yes. Kalshi offers a REST API for market data, order management, and portfolio queries, plus a WebSocket feed for real-time orderbook and trade streaming. The recommended production REST base URL is https://external-api.kalshi.com/trade-api/v2; demo uses https://external-api.demo.kalshi.co/trade-api/v2.
Is the Kalshi API free to use?
Kalshi doesn't charge a separate fee for API access — you authenticate with an API key pair generated in your account settings. You still pay normal per-trade fees on any orders the API places. Be mindful of rate limits.
How do I authenticate with the Kalshi API?
Generate an API key ID and RSA private-key file in Kalshi account settings. For every private request, sign the millisecond timestamp, uppercase HTTP method, and full request path without query parameters using RSA-PSS SHA-256, then send the key ID, timestamp, and Base64 signature in the three KALSHI-ACCESS headers.
Can I use the Kalshi API to place automated trades?
Yes — the API supports creating, listing, and canceling orders, so you can fully automate entries and exits. If you'd rather not write and host the code yourself, a no-code builder can run rules-based strategies against the same API.
What can the Kalshi API do?
The API documents market, order-book, order, position, balance, fill, and WebSocket workflows. A production bot still needs permissions checks, persistence, idempotency, reconciliation, monitoring, security, and handling for unsupported or changed endpoints.
What are the Kalshi API rate limits?
Kalshi uses separate token buckets for reads and writes. Your account tier determines each bucket's per-second refill budget, while every endpoint has a token cost. Check GET /account/limits for your live refill rates and capacities, and handle HTTP 429 with bounded exponential backoff. The published tier values can change, so verify the official rate-limit reference before deployment.
Should a Kalshi bot use REST or WebSockets?
Usually both. Use REST for discovery, snapshots, order placement, cancellation, positions, and fill reconciliation. Use the authenticated WebSocket connection for live orderbook, trade, market-status, and fill updates. After a disconnect or sequence gap, rebuild state from a fresh snapshot before processing deltas again.
Spin to win up to 30% off your first month
Every spin wins 10–30% off Complete ($99/month) — the wheel decides how big. Enter your email in the game to spin.
First month only. No account is created until you purchase. See our Privacy Policy.