Kalshi API Integration Guide
How to authenticate with Kalshi's trading API using RSA-PSS signatures
Note: This guide is written for developers comfortable with cryptography libraries and API integration. Most Kalshi traders won't implement this themselves—that's the gap AfterTrade is designed to fill.
Table of Contents
1. The Authentication Problem
Most trading APIs use OAuth2 or JWT tokens. Kalshi doesn't. Their API uses RSA-PSS digital signatures to authenticate every single request.
This means:
- There's no "login" endpoint that returns a token
- You must cryptographically sign each request individually
- The signature proves you possess the private key
- Standard HTTP libraries won't work out of the box
Why RSA-PSS? Kalshi uses RSA-PSS because it's more secure for financial APIs. Signatures prove possession of the private key without transmitting a token that could be intercepted or replayed.
2. How Kalshi Authentication Works
For each API request, you need to:
- Create a message to sign:timestamp + method + path
Example:
1704398400000GET/trade-api/v2/portfolio/fills - Sign the message using your private key with RSA-PSS (SHA-256)
- Base64-encode the signature
- Add three headers to your HTTP request:
KALSHI-ACCESS-KEY: Your public API key IDKALSHI-ACCESS-TIMESTAMP: Unix timestamp in millisecondsKALSHI-ACCESS-SIGNATURE: Base64-encoded signature
💡 Technical Details
Signature Algorithm: RSA-PSS with SHA-256 hashing
Timestamp Format: Unix epoch in milliseconds (13 digits)
3. Step-by-Step Implementation
Step 1: Get API Credentials
Log into Kalshi via a browser (not the mobile app) and navigate to Account & Security and then scroll down to API Keys and select Create Key. Nickname it, select either Read-only or Read and Write, then copy both the API key ID and private key file and save them somewhere safe.
Recommendation: For analytics, journaling, or trade review use cases, always select Read-only. Read-and-write should only be used by code that actually places trades.
Step 2: Load Your Private Key
Store your private key securely as an environment variable or secret. Do NOT commit it to version control.
# Store in .env file KALSHI_API_KEY_ID=your-key-id-here KALSHI_PRIVATE_KEY="-----BEGIN PRIVATE KEY----- MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAo... -----END PRIVATE KEY-----"
Step 3: Create Signature Function
Implement a function that takes the HTTP method and path, generates a timestamp, creates the message string, signs it with RSA-PSS, and returns the required headers.
Step 4: Sign Each Request
Call your signature function before every API request and include the headers. The signature is only valid for that specific request (timestamp + method + path).
Step 5: Test Your Integration
Start with a simple GET request to /trade-api/v2/portfolio/balance. If you get authentication errors, verify your key format and message construction.
4. Code Examples
Python Example
Using the cryptography library:
import os
import time
import base64
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
import requests
# Load credentials
API_KEY_ID = os.environ['KALSHI_API_KEY_ID']
PRIVATE_KEY_PEM = os.environ['KALSHI_PRIVATE_KEY']
BASE_URL = 'https://api.elections.kalshi.com'
# Parse private key
private_key = serialization.load_pem_private_key(
PRIVATE_KEY_PEM.encode(),
password=None
)
def create_signature(method: str, path: str):
"""Generate Kalshi API signature headers"""
timestamp = str(int(time.time() * 1000))
message = f"{timestamp}{method}{path}"
# Sign with RSA-PSS
signature = private_key.sign(
message.encode(),
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH
),
hashes.SHA256()
)
# Base64 encode
signature_b64 = base64.b64encode(signature).decode()
return {
'KALSHI-ACCESS-KEY': API_KEY_ID,
'KALSHI-ACCESS-TIMESTAMP': timestamp,
'KALSHI-ACCESS-SIGNATURE': signature_b64
}
# Example: Get all portfolio fills (with pagination)
def get_all_fills():
all_fills = []
cursor = None
while True:
path = '/trade-api/v2/portfolio/fills'
if cursor:
path += f'?cursor={cursor}'
headers = create_signature('GET', path)
response = requests.get(f"{BASE_URL}{path}", headers=headers)
data = response.json()
all_fills.extend(data.get('fills', []))
cursor = data.get('cursor')
if not cursor:
break
return all_fills
fills = get_all_fills()
print(f"Fetched {len(fills)} fills")Node.js/TypeScript Example
Using Node's built-in crypto module:
import crypto from 'crypto';
import axios from 'axios';
const API_KEY_ID = process.env.KALSHI_API_KEY_ID!;
const PRIVATE_KEY_PEM = process.env.KALSHI_PRIVATE_KEY!;
const BASE_URL = 'https://api.elections.kalshi.com';
function createSignature(method: string, path: string) {
const timestamp = Date.now().toString();
const message = `${timestamp}${method}${path}`;
// Sign with RSA-PSS
const signature = crypto.sign(
'sha256',
Buffer.from(message),
{
key: PRIVATE_KEY_PEM,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
}
);
// Base64 encode
const signatureB64 = signature.toString('base64');
return {
'KALSHI-ACCESS-KEY': API_KEY_ID,
'KALSHI-ACCESS-TIMESTAMP': timestamp,
'KALSHI-ACCESS-SIGNATURE': signatureB64,
};
}
// Example: Get all portfolio fills (with pagination)
async function getAllFills() {
const allFills = [];
let cursor: string | undefined;
while (true) {
const path = '/trade-api/v2/portfolio/fills' + (cursor ? `?cursor=${cursor}` : '');
const headers = createSignature('GET', path);
const response = await axios.get(`${BASE_URL}${path}`, { headers });
const data = response.data;
allFills.push(...(data.fills || []));
cursor = data.cursor;
if (!cursor) break;
}
return allFills;
}
getAllFills().then(fills => console.log(`Fetched ${fills.length} fills`));4.5 Response Shape
Each fill object returned from /trade-api/v2/portfolio/fills has the following shape:
{
"action": "buy", // "buy" or "sell"
"count": "10", // STRING, not number - contracts traded
"created_time": "2025-03-14T...", // ISO 8601 timestamp
"is_taker": true, // boolean
"no_price": "45", // STRING cents (0-100), or null
"order_id": "abc-123-...", // UUID
"side": "yes", // "yes" or "no"
"ticker": "KXBTC-25MAR-50000", // market ticker
"trade_id": "def-456-...", // UUID
"yes_price": "55" // STRING cents (0-100), or null
}Important: Numeric fields are strings
Fields like count, yes_price, and no_price are returned as strings, not numbers. Always parse them: parseInt(fill.count, 10).
5. Common Pitfalls
❌ Using JWT Instead of RSA-PSS
Kalshi does NOT use JWT tokens. You must use RSA-PSS signature algorithm with SHA-256. Libraries like jsonwebtoken will not work.
❌ Modifying the Private Key Format
Use the private key exactly as downloaded. Do not strip newlines, remove headers/footers, or reformat it. The key should include -----BEGIN PRIVATE KEY----- and -----END PRIVATE KEY-----.
❌ Wrong Message Format
The message MUST be exactly: timestamp + method + path. No spaces, no separators, uppercase method. Path must include query parameters if present.
❌ Timestamp Format
Timestamp must be Unix epoch in milliseconds (13 digits), not seconds (10 digits). Use Date.now() in JavaScript or int(time.time() * 1000) in Python.
❌ Assuming Numeric Fields Are Numbers
Fields like count, yes_price, and no_price are strings, not numbers. Adding them directly (e.g., total += fill.count) will produce "01010" instead of 30. Always parse: parseInt(fill.count, 10).
❌ Forgetting to Paginate
The fills endpoint returns up to 1000 items per request. If cursor is present in the response, there are more results. You must loop until cursor is null or missing to fetch all data.
6. Faster Path: Use Claude Code
If you'd rather not implement this yourself, Claude Code can build the integration for you in about 20 minutes. Paste the prompt below into Claude Code, provide your API credentials when prompted, and you'll have a working trade history export. This is the fastest way to get your Kalshi data into a usable format if you're comfortable running a command-line tool.
Requirements: Node.js installed, Claude Code CLI installed, Kalshi API credentials (read-only).
Create a Node.js script that connects to the Kalshi API and exports my trade history as a CSV. Requirements: - Use Node's built-in crypto module (no external signing libraries) - Read KALSHI_API_KEY_ID and KALSHI_PRIVATE_KEY from a .env file - Authenticate using RSA-PSS with SHA-256, salt length RSA_PSS_SALTLEN_DIGEST - The signature message format is: timestamp + method + path (no separators) - Timestamp must be Unix epoch in milliseconds - Required headers: KALSHI-ACCESS-KEY, KALSHI-ACCESS-TIMESTAMP, KALSHI-ACCESS-SIGNATURE - Base URL: https://api.elections.kalshi.com - Fetch all fills from /trade-api/v2/portfolio/fills - IMPORTANT: Paginate using the cursor field - loop while cursor exists - IMPORTANT: Numeric fields (count, yes_price, no_price) are STRINGS, not numbers - parse them - Write the results to trade-history.csv with columns: trade_id, ticker, side, action, count, yes_price, no_price, created_time - Include clear error handling for auth failures and rate limits - Add a README explaining how to set up the .env file and run the script Before writing parsing code, log one raw fill object to see actual field names and types. Do not include any trading or write operations. Read-only access only.
Note: Review the generated code before running it, especially the credential handling. Your Kalshi private key should never be committed to version control or shared.
7. Quick Reference
API Base URL
https://api.elections.kalshi.comRequired Headers
- KALSHI-ACCESS-KEY: <your-api-key-id>
- KALSHI-ACCESS-TIMESTAMP: <unix-ms>
- KALSHI-ACCESS-SIGNATURE: <base64-signature>
Message Format
timestamp + method + pathSignature Algorithm
RSA-PSS with SHA-256Salt Length
Python: PSS.MAX_LENGTH
Node.js: RSA_PSS_SALTLEN_DIGEST
Changelog
April 2026
- Fixed base URL from
trading-api.kalshi.comtoapi.elections.kalshi.com - Fixed Node.js salt length from
RSA_PSS_SALTLEN_MAX_SIGNtoRSA_PSS_SALTLEN_DIGEST - Added pagination to code examples (loop while
cursorexists) - Added Section 4.5: Response Shape with fill object structure
- Added pitfall: numeric fields (
count,yes_price,no_price) are strings, not numbers - Added pitfall: forgetting to paginate
- Updated Claude Code prompt with correct parameters and field inspection guidance
- Added read-only key recommendation for analytics use cases