Kalshi REST API Authentication and Token Management
Kalshi's API now accepts Ed25519 keys alongside RSA signatures.

A developer who builds against an older Kalshi guide will, at some point, start getting 401 responses from code that used to work, and nothing in the error body will explain why. That failure mode is the subject of this piece: Kalshi's authentication surface has moved through at least two distinct paradigms, and a third signing option now sits alongside the one most existing guides describe. The old model issued session cookies that expired on a timer. The current model, version 2 of the API, requires every request to carry its own cryptographic proof of identity, built from a precise string of bytes and signed with either RSA-PSS or, as of a more recent change, Ed25519. None of this is exotic by the standards of API security design, but the coexistence of old guides, old SDKs, and a changed default signing algorithm creates a narrow, specific set of failure conditions that this piece walks through one at a time.
The legacy session-token model
Kalshi's original v1 API worked the way most consumer-facing web APIs still do: a client logged in once, received a session cookie, and reused that cookie until it expired. Because the cookie expired on a short timer, any application built on top of it needed its own re-login logic, so it could catch the expiration and re-authenticate before the next request could succeed. That pattern, session issuance followed by periodic renewal, is the one most engineers already carry in their heads when they approach a new trading API, even though it no longer applies to Kalshi's current interface.
The v2 API has no session server, no token to refresh, and no renewal cycle to manage. Each request authenticates itself: the client signs a short string derived from the current timestamp, the HTTP method, and the request path, and attaches that signature directly to the request. There's no state for the server to track between calls. That means there's no expiration event for application code to catch. The pre-sign string is deterministic, built from fixed inputs, so a correctly signed request is verifiable on its own without any reference to a prior login. Carrying v1's re-login assumptions into v2 code is the single most common source of confusion for developers returning to the API after time away, and it is worth closing off cleanly before getting into how the current signature is actually built.
RSA-PSS signing: the exact message, headers, and parameters that Kalshi validates
The v2 signature starts with a pre-sign string built from three pieces concatenated in order: the Unix timestamp in milliseconds, the HTTP method, and the request path. The path must include the /trade-api/v2 prefix, and it must exclude any query string. A GET request to the balance endpoint, for instance, produces a pre-sign string that looks like [1703123456789](https://docs.kalshi.com/getting_started/quick_start_authenticated_requests)GET/trade-api/v2/portfolio/balance. If you leave off the prefix, include a query string, or use a timestamp in seconds instead of milliseconds, you get a signature that looks plausible but still fails verification.
For RSA keys, that string is signed using RSA-PSS with SHA-256 as the hash function, SHA-256 again as the MGF1 mask generation function, and a salt length equal to the digest length. In Python, using the cryptography library, the signing call looks like this:
signature = private_key.sign(
message.encode("utf-8"),
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.DIGEST_LENGTH,
),
hashes.SHA256(),
)
The resulting signature is base64-encoded and sent along with two other headers that together prove the request's identity and timing. A complete authenticated request, built with cURL, takes this shape:
curl \
-H "KALSHI-ACCESS-KEY: a952bcbe-ec3b-4b5b-b8f9-11dae589608c" \
-H "KALSHI-ACCESS-TIMESTAMP: 1703123456789" \
-H "KALSHI-ACCESS-SIGNATURE: <base64_encoded_signature>"
KALSHI-ACCESS-KEY carries the Key ID, a UUID issued from the Kalshi dashboard. KALSHI-ACCESS-TIMESTAMP carries the same millisecond timestamp used to build the pre-sign string, and KALSHI-ACCESS-SIGNATURE carries the base64-encoded output of the RSA-PSS operation above. Keys can also be created programmatically: a POST to /trade-api/v2/api_keys with an RSA public key in PEM format and the desired scopes attached will register a new key without requiring a trip through the dashboard UI. Scopes themselves are granular. The broad scopes are read and write, but child scopes such as read::portfolio_balance, write::trade, write::transfer, write::fcm_risk, and write::block_trade_accept can each be granted independently, without needing the parent scope attached.
The PSS-versus-PKCS#1 v1.5 confusion that causes silent 401s
The most common signing error comes from the padding mode; it isn't about key handling. Kalshi's current algorithm is RSA-PSS, but the algorithm in place as of early 2025 or earlier was RSA-SHA256 using PKCS#1 v1.5 padding, an older and more widely known RSA signing scheme. Any code snippet, tutorial, or SDK version written against that earlier scheme will construct a signature that looks structurally correct, encode it in base64, attach it to the right headers, and still fail, because PKCS#1 v1.5 and PSS produce entirely different signature bytes from the same message and key. Kalshi's API returns a 401 in that case with no indication that padding is the problem.
The fastest way to confirm whether signing logic works at all is to call GET /portfolio/balance and check for a 200 response. A successful response there confirms the entire chain, timestamp formatting, path construction, padding, and key handling, all at once. A 401 instead narrows the search to four candidates: the padding mode (PSS versus PKCS#1 v1.5), clock drift between the client and Kalshi's servers, a missing /trade-api/v2 prefix in the signed path, or a query string left in the signed path that should have been stripped. Clock drift deserves particular attention because it produces the same opaque failure as a wrong key or wrong padding: a machine without NTP synchronization, or a container with a frozen or stale clock, can run otherwise-correct signing code and still get rejected, because Kalshi rejects timestamps that fall too far outside server time. If you work through those four candidates in order, you isolate the actual fault faster than if you just guess.
If you want to test this chain end to end against Kalshi's live endpoints, you need real credentials and a real rate-limit budget, a cost many teams would rather not pay just to confirm a padding fix. Platforms like Rystic, which ship with stateful, pre-seeded replicas of exchange APis including Kalshi's, let developers run exactly this diagnostic sequence locally, without live credentials, and confirm the signing construction is correct before it ever touches production.
Ed25519 as the lower-cost alternative
Kalshi's web UI now defaults to generating Ed25519 keys rather than RSA keys when a developer creates a new API key through the dashboard. That default reflects a preference, not a deprecation: RSA-PSS remains fully supported, and clients limited to RSA-PSS, including SDK versions before 3.31.0, continue to work without any change. The choice between the two comes down to a tradeoff between broader compatibility, which favors RSA, and smaller, cheaper signatures, which favors Ed25519. Ed25519 signatures are smaller than the output of a 2048-bit RSA signature, and the signing operation itself costs less computationally than RSA-PSS.
What doesn't change between the two key types is the message being signed. The pre-sign text, timestamp in milliseconds concatenated with the HTTP method and the path, excluding any query string, is identical regardless of which algorithm generated the key. RSA-PSS wraps that string in the SHA-256, MGF1, and digest-length salt construction described above; Ed25519 signs the string directly, with no equivalent padding step. The three request headers, [KALSHI](https://docs.kalshi.com/getting_started/quick_start_authenticated_requests)-ACCESS-KEY, KALSHI-ACCESS-TIMESTAMP, and KALSHI-ACCESS-SIGNATURE, stay the same in both cases. Only the bytes inside the signature header differ.
Ed25519 keys work across REST, WebSocket, and FIX (specifically the RawData variant), and existing RSA keys are unaffected by the change in UI default. Programmatic key generation reflects the same default: a POST to /trade-api/v2/api_keys/generate produces an RSA key if the key_type field is omitted, and produces an Ed25519 key only when key_type: "ed25519" is passed explicitly. The official Python and TypeScript SDKs support both key types starting from version 3.31.0, so if you're working from an older pinned SDK version, check that number before you assume Ed25519 key generation will work out of the box.
That check matters in practice, not just in theory. A GitHub issue filed against the pmxt-dev/pmxt project documented a case, detected September 24, 2026, where a cached OpenAPI spec file, Kalshi.yaml version 3.7.0, had no key_type field at all in its GenerateApiKeyRequest schema. Clients that generated their API code from that cached spec could only produce RSA keys programmatically; generating an Ed25519 key required going around the generated client entirely, either through the dashboard UI or a direct, hand-built API call. Spec currency, in other words, is itself part of the authentication surface. A stale schema can quietly remove a code path that the live API fully supports.
WebSocket authentication as a distinct surface with its own signing requirements
Kalshi's WebSocket connection requires authentication at the point of the HTTP upgrade request, and it requires that authentication even for public market-data channels that carry no sensitive account information. That's a meaningful departure from the REST API's behavior, where public endpoints can be called without authentication. Developers who assume the WebSocket connection behaves like REST's public surface, open and unauthenticated by default, will find their upgrade request rejected before any subscription message is ever sent.
The signed path for WebSocket authentication is a fixed value, /trade-api/ws/v2, signed with method GET, regardless of which channels the client intends to subscribe to after the connection opens. Signing the REST resource path instead of this fixed WebSocket path, or including a query string in the signed message, produces the same kind of silent rejection described earlier in the REST context. The production WebSocket host is wss://external-api-ws.kalshi.com/trade-api/ws/v2, and Kalshi also maintains a separate demo host, so you can build and test streaming logic, including the authentication handshake itself, without ever pointing a client at the production environment.
Once the connection is open, the server drives the heartbeat. Kalshi's server sends a Ping message periodically with the body heartbeat, and the client is responsible for replying with a Pong. That server-driven model replaced an earlier client-driven heartbeat pattern, and a client built against the older assumption, sending its own unsolicited pings rather than waiting to respond to the server's, will not authenticate incorrectly, but it may find its connection treated as unresponsive if it never answers the server's heartbeat correctly.
Rate limits, token buckets, and the hidden costs that exhaust budgets faster than expected
Once signing is correct and a connection stays open, the next place integrations run into trouble is Kalshi's rate limiting, which is built as a token bucket rather than a fixed per-second request quota. That distinction matters operationally: a token bucket can absorb a burst of requests up to its current balance, but sustained throughput over time is bounded by the refill rate, not by the size of any single burst. A client that front-loads a large batch of requests at the start of a session may succeed initially and then stall, because the bucket has been drawn down faster than it refills, even though no single request failed.
Kalshi tracks two independent token budgets, one for Read operations, covering GET endpoints and anything not explicitly routed to the Write bucket, and one for Write operations, covering order placement, amends, cancels, order groups, RFQ quote flow, and block trade accepts. REST and FIX traffic draw from the same two buckets instead of being tracked separately by protocol, so if a high-frequency FIX order flow and a REST-based monitoring script are both active under the same account, they pull against the same Write budget. Most authenticated requests cost a fixed number of tokens by default, which makes the budget relatively predictable under steady load but unforgiving under bursty load, since the bucket doesn't distinguish between an urgent order cancellation and a routine status poll.
Tier sizes vary considerably. The tier table itself is useful context, but the real cost traps sit elsewhere: in batch operations that silently consume many tokens per logical action, and in legacy or indirect endpoints that route through the same Write bucket as direct order placement without being obviously labeled as expensive. A script built to poll frequently, cancel aggressively, or re-fetch state in a loop can exhaust a Write budget well before it exhausts a Read budget, because order lifecycle operations are concentrated entirely on the smaller of the two buckets.
If you run a locally hosted, stateful simulator that enforces these same token-bucket rules deterministically, your team can find that kind of exhaustion pattern before it happens against a live account. If you catch a Write-bucket-draining loop in a replica environment, where hitting the limit costs nothing but a log line, that's considerably cheaper than discovering it mid-session against production capital.
Testing Kalshi authentication flows without real credentials or live capital
The sources checked for this guide are listed below.
