< Back to Blog

Signing Kalshi API Requests in Python: RSA-PSS Authentication From Scratch

TL;DR / Key Takeaways

  • Kalshi signs every API request with RSA-PSS, so the private key must be managed like production infrastructure.
  • The signature message includes key ID, timestamp, method, and path, including query strings when present.
  • Clock drift, wrong key IDs, and mismatched paths are the main causes of 401 authentication failures.
  • A reusable session class keeps signing behavior consistent across market data, orders, and portfolio calls.

Most Kalshi bot tutorials stop at "generate your API key in the dashboard" and hand you a Bearer token. That works for manual testing. For a production trading system running 24/7, it is the wrong approach.

The Kalshi API uses RSA-PSS asymmetric key authentication. You generate a 2048-bit RSA key pair on your machine. You upload the public key to Kalshi. Every API request your bot sends is signed with your private key using the PSS padding scheme. Kalshi verifies the signature server-side. No password, no shared secret sitting in an environment variable, no token that expires at 3 AM and silently takes your bot offline.

Here is how to implement it correctly.

Why RSA-PSS and not a simple API key

| Authentication model | Secret location | Replay resistance | Common bot failure | | --- | --- | --- | --- | | Bearer token | Shared token in environment or config | Depends on token rules | Expiration, leakage, or stale refresh logic | | RSA-PSS | Private key stays local, public key uploaded | Timestamped signature per request | Clock drift, path mismatch, or wrong key ID | | Session wrapper | Key loaded once in process memory | Fresh signature per call | Bugs centralize in one testable layer |

A static API key is a secret you can accidentally expose. It lives in your .env file, shows up in your shell history if you are not careful, and gets committed to GitHub if you have a bad day. RSA-PSS sidesteps this entirely. The private key never leaves your machine. What you upload to Kalshi is the public key, which is useless to anyone trying to impersonate you.

PSS — Probabilistic Signature Scheme — adds randomized salting to each signature operation. The same message signed twice produces two different signatures. This protects against certain classes of signature forgery attacks that plague deterministic schemes. For a financial API, that matters.

Generating the key pair

First, generate a 2048-bit RSA key pair. Do this once, store it securely, and do not regenerate it unless you have reason to believe the private key was compromised.

# Source: predictandprofit.io
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization

private_key = rsa.generate_private_key(
    public_exponent=65537,
    key_size=2048,
)

# Write the private key to disk — PEM format, no passphrase
with open("kalshi_private_key.pem", "wb") as f:
    f.write(private_key.private_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PrivateFormat.PKCS8,
        encryption_algorithm=serialization.NoEncryption(),
    ))

# Write the public key — this is what you upload to Kalshi
with open("kalshi_public_key.pem", "wb") as f:
    f.write(private_key.public_key().public_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PublicFormat.SubjectPublicKeyInfo,
    ))

Keep kalshi_private_key.pem off version control. Add it to .gitignore before you do anything else. Do not store it in the project root where you might accidentally drag it into a repository. I keep mine in a dedicated ~/.kalshi/ directory with chmod 600 permissions.

Loading the key at runtime

Your bot loads the private key once at startup, not on every request. Key loading is expensive. Do it once and hold the key object in memory.

# Source: predictandprofit.io
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from pathlib import Path

def load_private_key(key_path: str):
    key_bytes = Path(key_path).read_bytes()
    return load_pem_private_key(key_bytes, password=None)

private_key = load_private_key("/home/steve/.kalshi/kalshi_private_key.pem")

If your key file has a passphrase — which adds protection if someone gets access to the file — pass the passphrase bytes as the password argument. I run on a locked-down Ubuntu VM so I skip the passphrase to keep startup clean.

Building the signature

Kalshi expects a specific string to be signed. The format is:

{key_id}{timestamp}{method}{path}

Where:

  • key_id is the API key ID from your Kalshi account (not the key itself, just the ID string)
  • timestamp is the current Unix timestamp in milliseconds as a string
  • method is the HTTP verb uppercased: GET, POST, DELETE
  • path is the full request path including any query string

The signature is computed over this concatenated string using SHA-256 as the hash function and PSS padding with a maximum salt length.

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

def sign_request(private_key, key_id: str, method: str, path: str) -> tuple[str, str]:
    timestamp_ms = str(int(time.time() * 1000))
    message = f"{key_id}{timestamp_ms}{method.upper()}{path}"
    
    signature_bytes = private_key.sign(
        message.encode("utf-8"),
        padding.PSS(
            mgf=padding.MGF1(hashes.SHA256()),
            salt_length=padding.PSS.MAX_LENGTH,
        ),
        hashes.SHA256(),
    )
    
    signature_b64 = base64.b64encode(signature_bytes).decode("utf-8")
    return timestamp_ms, signature_b64

The return values go into request headers.

Attaching the signature to requests

# Source: predictandprofit.io
import httpx

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

def make_kalshi_request(private_key, method: str, path: str, body: dict = None):
    timestamp, signature = sign_request(private_key, KEY_ID, method, path)
    
    headers = {
        "KALSHI-ACCESS-KEY": KEY_ID,
        "KALSHI-ACCESS-TIMESTAMP": timestamp,
        "KALSHI-ACCESS-SIGNATURE": signature,
        "Content-Type": "application/json",
    }
    
    url = f"{BASE_URL}{path}"
    
    with httpx.Client() as client:
        if method.upper() == "GET":
            response = client.get(url, headers=headers)
        elif method.upper() == "POST":
            response = client.post(url, headers=headers, json=body)
        elif method.upper() == "DELETE":
            response = client.delete(url, headers=headers)
        
        response.raise_for_status()
        return response.json()

The timestamp window problem

Kalshi rejects requests where the timestamp is more than 1000 milliseconds old. This sounds like a generous window. In practice, if your system clock drifts, you will start getting 401 errors with no obvious explanation.

The first time this happened to me, the bot was running fine and then started rejecting all API calls at 2 AM. Not crashing, just silently returning 401 on every order attempt. I spent an hour looking at the signature code before checking timedatectl and finding the VM's NTP sync had fallen behind by 2 seconds.

Enable NTP time sync on whatever machine runs your bot and verify it periodically.

# Ubuntu/Debian
sudo systemctl enable --now systemd-timesyncd
timedatectl status

If you are running in AWS EC2 or similar, the hypervisor keeps the clock synced by default. On a local VM, configure it explicitly.

Common failures and how to identify them

401 with "invalid signature": The path in your signature does not match the actual request path. Query parameters must be included. If you are calling /portfolio/positions?limit=100, the path you sign is /portfolio/positions?limit=100, not /portfolio/positions.

401 with "timestamp out of range": Clock drift. Check NTP.

403 with "unauthorized key": Your public key is not uploaded in the Kalshi dashboard, or you used the wrong key ID in the header. The key ID is the UUID Kalshi assigns to the key after upload, not a fingerprint you generate yourself.

Signature verification passing locally but failing on Kalshi: You are probably encoding the message as UTF-8 but computing the signature over a bytes object with different encoding. Be explicit: message.encode("utf-8").

Wrapping it into a session class

For production use, I wrap all of this into a session class that initializes once and handles re-signing on every call.

# Source: predictandprofit.io
class KalshiSession:
    def __init__(self, key_id: str, private_key_path: str):
        self.key_id = key_id
        self.private_key = load_private_key(private_key_path)
        self.base_url = "https://api.elections.kalshi.com/trade-api/v2"
    
    def _headers(self, method: str, path: str) -> dict:
        timestamp, signature = sign_request(
            self.private_key, self.key_id, method, path
        )
        return {
            "KALSHI-ACCESS-KEY": self.key_id,
            "KALSHI-ACCESS-TIMESTAMP": timestamp,
            "KALSHI-ACCESS-SIGNATURE": signature,
            "Content-Type": "application/json",
        }
    
    def get(self, path: str) -> dict:
        headers = self._headers("GET", path)
        with httpx.Client() as client:
            r = client.get(f"{self.base_url}{path}", headers=headers)
            r.raise_for_status()
            return r.json()
    
    def post(self, path: str, body: dict) -> dict:
        headers = self._headers("POST", path)
        with httpx.Client() as client:
            r = client.post(
                f"{self.base_url}{path}", headers=headers, json=body
            )
            r.raise_for_status()
            return r.json()

Initialize it once at startup and pass it through your bot's execution layers. Do not reinstantiate it per trade — the key loading cost adds up and there is no reason to reload what you already have in memory.

Why this matters for reliability

Bearer token authentication fails when the token expires. RSA-PSS authentication fails when your clock drifts or your key file disappears. Both are solvable problems, but the failure modes are different.

With RSA-PSS, there is no token refresh to schedule, no OAuth dance to manage, and no expiring credential sitting in a database. The bot either has a valid key and a synced clock, or it does not trade. That is a clean failure mode.

For a system running unattended at 3 AM, clean failure modes matter.


The full Kalshi authentication implementation is part of the Predict & Profit source code.

Get the Source Code — $67

How It Works — Full System Overview

Frequently Asked Questions

Q: What exactly is signed in a Kalshi request?

A: The signature is computed from the key ID, timestamp, HTTP method, and path according to the API format used by the client. Any mismatch between signed path and requested path causes authentication failure.

Q: Why does system clock drift break RSA-PSS authentication?

A: Kalshi rejects timestamps outside its accepted window. If the bot machine drifts by even a few seconds, valid signatures can still fail because the timestamp is stale or too far ahead.

Q: Why wrap authentication in a session class?

A: A session class loads the private key once, signs every request consistently, and keeps request construction uniform across GET, POST, and DELETE calls.

Related Reading