< Back to Blog

Kalshi API Authentication in Python: RSA-PSS Signing from Scratch

TL;DR / Key Takeaways

  • Kalshi API authentication requires RSA-PSS request signing rather than a simple Bearer token.
  • Every signed request depends on a fresh millisecond timestamp, HTTP method, path, and private-key signature.
  • Reusable GET and POST helpers reduce signature mistakes and keep order-management code consistent.
  • Pagination, rate limits, positions, and order placement all need explicit handling for a production bot.

The Kalshi REST API is well-designed, but the authentication is not the simple Bearer token pattern most developers are used to. Every request has to be signed with your RSA private key using the PSS padding scheme. Get it wrong and you get a 401 with no explanation. Get it right and the whole API opens up cleanly.

This is the implementation I built for the Predict & Profit automated weather trading bot. Here is how it works.

The Kalshi REST API

| API concern | Simple-token API | Kalshi RSA-PSS API | Bot implication | | --- | --- | --- | --- | | Credential | Static shared secret | Private key signs each request | Key storage and file permissions matter | | Replay protection | Usually token expiration | Timestamp is part of signature | Clock sync is operationally required | | Failure mode | Expired or leaked token | Invalid signature, path mismatch, or clock drift | Helpers should centralize signing |

Kalshi exposes a REST API for market data, order management, and portfolio queries. The base URL is https://api.elections.kalshi.com/trade-api/v2. All endpoints use standard JSON over HTTPS. The data model is clean and the documentation is reasonably complete.

The authentication is where most developers hit a wall. Kalshi uses RSA-PSS signature-based authentication. There is no simpler mode, no API key header, no session cookie. Every single request must be signed with your private key.

RSA key generation and registration

Before writing any code, generate a 2048-bit RSA key pair and register the public key in your Kalshi dashboard.

# Generate private key
openssl genrsa -out kalshi_private.pem 2048

# Extract public key
openssl rsa -in kalshi_private.pem -pubout -out kalshi_public.pem

Upload the contents of kalshi_public.pem to the Kalshi API Keys section in your account dashboard. Kalshi will give you a key ID — you will need that ID in every request header.

RSA-PSS signing

For each API request you need to sign a message containing the timestamp and the HTTP method + path. The signature uses PSS padding with SHA-256.

# Source: predictandprofit.io
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend
import base64
import time

def load_private_key(path: str):
    with open(path, "rb") as f:
        return serialization.load_pem_private_key(
            f.read(), password=None, backend=default_backend()
        )

def sign_request(private_key, method: str, path: str) -> tuple[str, str]:
    timestamp_ms = str(int(time.time() * 1000))
    msg = timestamp_ms + method.upper() + path
    signature = private_key.sign(
        msg.encode("utf-8"),
        padding.PSS(
            mgf=padding.MGF1(hashes.SHA256()),
            salt_length=padding.PSS.DIGEST_LENGTH
        ),
        hashes.SHA256()
    )
    return timestamp_ms, base64.b64encode(signature).decode("utf-8")

The timestamp is in milliseconds. The signed message is the concatenation of timestamp, method (uppercase), and path — no spaces, no separators. The signature goes into the KALSHI-ACCESS-SIGNATURE header.

Building authenticated requests

The cleanest approach is a pair of helper functions — one for GET, one for POST — that handle signing and return consistent results.

# Source: predictandprofit.io
import requests

BASE_URL = "https://api.elections.kalshi.com/trade-api/v2"
KEY_ID = "your-key-id-here"

private_key = load_private_key("kalshi_private.pem")

def build_auth_headers(method: str, path: str) -> dict:
    timestamp, signature = sign_request(private_key, method, path)
    return {
        "KALSHI-ACCESS-KEY": KEY_ID,
        "KALSHI-ACCESS-TIMESTAMP": timestamp,
        "KALSHI-ACCESS-SIGNATURE": signature,
        "Content-Type": "application/json"
    }

def authenticated_get(path: str, params: dict = {}) -> tuple:
    headers = build_auth_headers("GET", path)
    response = requests.get(BASE_URL + path, headers=headers, params=params)
    return response, response.json()

def authenticated_post(path: str, body: dict = {}) -> tuple:
    headers = build_auth_headers("POST", path)
    response = requests.post(BASE_URL + path, headers=headers, json=body)
    return response, response.json()

Always pass an explicit empty dict {} for params when there are no query parameters. Omitting the argument entirely can cause signature mismatches on some endpoints because the signed path must exactly match the actual request path.

Common pitfalls

Timestamp drift. The Kalshi API rejects requests with timestamps more than a few seconds old. If your server clock is not NTP-synced, every request fails with a 401. Use time.time() directly each time — never cache the timestamp at startup.

Query string in the signed path. When a GET request has query parameters, the signed message uses the path without the query string. The parameters are passed separately to requests.get(). This is different from some APIs that include the full URL in the signature. Getting this wrong produces 401 errors that only appear on requests with parameters, which can take a while to debug.

Tuple unpacking. Every helper function returns (response, payload). It is easy to assign the whole tuple to one variable and then try to call .json() on it. Unpack immediately: res, data = authenticated_get(...). The bot calls these hundreds of times per day — making this a consistent pattern from the start saves debugging time later.

Key ID format. The key ID Kalshi gives you is a UUID. Copy it exactly. There is no validation error if you get it wrong — you just get a 401 on every request.

Rate limiting and pagination

Kalshi enforces rate limits. For paginated endpoints like market listings, add a 0.25-second sleep between pages to stay well under the limit.

# Source: predictandprofit.io
import time

def get_all_markets() -> list:
    cursor = None
    markets = []
    while True:
        params = {"limit": 100}
        if cursor:
            params["cursor"] = cursor
        _, data = authenticated_get("/markets", params)
        markets.extend(data.get("markets", []))
        cursor = data.get("cursor")
        if not cursor:
            break
        time.sleep(0.25)
    return markets

The bot scans every 5 minutes, so a 0.25-second sleep per page has essentially no impact on execution latency.

Fetching open positions

Open positions live at /portfolio/positions. The response includes ticker symbols, current YES/NO holdings, average entry price, and current market value.

# Source: predictandprofit.io
def get_positions() -> list:
    _, data = authenticated_get("/portfolio/positions", {})
    return data.get("market_positions", [])

positions = get_positions()
for pos in positions:
    ticker = pos["ticker"]
    quantity = pos["position"]
    avg_price = pos.get("market_exposure", 0)
    print(f"{ticker}: {quantity} contracts @ {avg_price}")

The bot queries this at startup and after every order to keep its internal state accurate. Stale position data can cause the concentration limits to miscalculate and let the bot over-allocate to a single city or series.

Placing an order

Order placement goes to /portfolio/orders as a POST request.

# Source: predictandprofit.io
def place_order(ticker: str, side: str, count: int, price: float) -> dict:
    body = {
        "ticker": ticker,
        "side": side,          # "yes" or "no"
        "count": count,
        "type": "limit",
        "action": "buy",
        "yes_price": int(price * 100) if side == "yes" else None,
        "no_price": int(price * 100) if side == "no" else None,
        "expiration_ts": None  # GTC
    }
    res, data = authenticated_post("/portfolio/orders", body)
    if res.status_code != 201:
        print(f"Order failed: {res.status_code} — {data}")
    return data

Prices are in cents as integers. A price of 0.72 goes in as 72. The expiration_ts of None means good-till-canceled. The bot sets a reasonable expiration in practice to avoid stale limit orders sitting on the book overnight.


The full working implementation — including RSA key loading, retry logic for 429 rate limit errors, order management, and WebSocket market data ingestion — is included in the Predict & Profit Python source code.

Get the Source Code — $67

Frequently Asked Questions

Q: What fields are required for Kalshi RSA-PSS authentication?

A: Each request needs the key ID, millisecond timestamp, RSA-PSS signature, method, and request path. The signature must match the exact path the API receives.

Q: Why should authentication be wrapped in helper functions?

A: Helpers centralize timestamp generation, signing, headers, and error handling. That reduces the chance of one endpoint using a slightly different signature pattern.

Q: How should a bot handle paginated Kalshi endpoints?

A: It should iterate cursors, sleep between page requests, and keep pagination logic separate from trading decisions. That prevents market scans from hitting avoidable rate-limit errors.

Related Reading