Breaking: the V1 order-write API is gone. Kalshi removed the V1 order
endpoints from the OpenAPI spec in 3.22.0, so the SDK removed the matching
methods and models. Order writes now go exclusively through the V2
/portfolio/events/orders family (the *_v2 methods, available since 3.18.0).
Read endpoints are unchanged: orders.get, orders.list, orders.list_all,
orders.queue_positions, orders.queue_position, and portfolio.fills all keep
working exactly as before.
| Removed (v4) | Use instead (v5) |
|---|---|
orders.create(...) |
orders.create_v2(request=CreateOrderV2Request(...)) |
orders.cancel(order_id) |
orders.cancel_v2(order_id, ...) |
orders.amend(order_id, ...) |
orders.amend_v2(order_id, request=AmendOrderV2Request(...)) |
orders.decrease(order_id, ...) |
orders.decrease_v2(order_id, request=DecreaseOrderV2Request(...)) |
orders.batch_create(orders=[...]) |
orders.batch_create_v2(request=BatchCreateOrdersV2Request(orders=[...])) |
orders.batch_cancel(orders=[...]) |
orders.batch_cancel_v2(request=BatchCancelOrdersV2Request(orders=[...])) |
The V2 models use a single price with a side of "bid"/"ask" (book side),
rather than the V1 yes_price / no_price + yes/no:
from decimal import Decimal
from kalshi.models import CreateOrderV2Request
# v4 (removed)
# order = client.orders.create(ticker="MKT-A", side="yes", action="buy", count=1, yes_price="0.56")
# v5
resp = client.orders.create_v2(
request=CreateOrderV2Request(
ticker="MKT-A",
client_order_id="my-idempotency-key",
side="bid", # book side, not yes/no
count=Decimal("1"),
price=Decimal("0.56"),
time_in_force="good_till_canceled",
self_trade_prevention_type="taker_at_cross",
)
)These are no longer exported from kalshi / kalshi.models:
CreateOrderRequest, AmendOrderRequest, AmendOrderResponse,
DecreaseOrderRequest, BatchCreateOrdersRequest, BatchCreateOrdersResponse,
BatchCreateOrdersResponseEntry, BatchCancelOrdersRequest,
BatchCancelOrdersResponse, BatchCancelOrdersResponseEntry,
BatchCancelOrdersRequestOrder, and the ActionLiteral alias. Use the …V2…
models instead.
communications.quotes.list/list_all(and thecommunications.list_quotes/list_all_quotesfacades) no longer acceptevent_ticker/market_ticker— those filters were removed fromGET /communications/quotesupstream.
- RFQ-scoped quote actions:
communications.quotes.accept_for_rfq,confirm_for_rfq, anddelete_for_rfq(take bothrfq_idandquote_id). cancel_v2(..., market_ticker=...)andBatchCancelOrdersV2RequestOrder.market_tickerfor-1(auto-route) exchange indices.SubaccountBalance.exchange_index; perps SCMMarketSettlementEstimate,SettlementEstimate.positions,GetSettlementEstimateResponse.prev_settlement_prices.
v4.0.0 has one breaking change — the Self-Clearing-Member "Klear" API
migrated from cookie-session login to a pre-generated Bearer token, because
upstream removed POST /log_in. Everything else in v4.0.0 is additive
(cfbenchmarks_value WS channel, AccountResource.upgrade(),
AccountApiLimits.grants, perps api_limits(), perps market notional/leverage
fields). The prediction and perps trade-api surfaces are unchanged.
For full BEFORE/AFTER snippets see v3-to-v4.md. Quick summary of the Klear break:
| Was (v3.x) | Now (v4.0.0) |
|---|---|
KlearClient(demo=True) then client.login(email=..., password=..., code=...) |
KlearClient(admin_user_id=..., access_token=..., demo=True) |
KlearClient.from_env() (URL routing only — no credential env vars) |
KlearClient.from_env() reads KALSHI_KLEAR_ADMIN_USER_ID / KALSHI_KLEAR_ACCESS_TOKEN |
client.is_authenticated, client.auth, LogInRequest, LogInResponse |
removed |
Generate the token / find your admin user id at https://klearing.kalshi.com (the "Security" page).
v3.0.0 is the first major release in the v3 line. It renames three groups of
public methods (issues #348, #349, #351). The wire protocol is
unchanged from v2.7.0; v3 is purely a Python-API ergonomics break.
One-release deprecation window. All old names continue to work in v3.0.0
as @typing_extensions.deprecated aliases. Each emits DeprecationWarning
on every call. Aliases will be removed no sooner than v3.1.0.
For full BEFORE/AFTER snippets and a one-page search-and-replace cheat sheet, see v2-to-v3.md. Quick summary:
| Rename | Old (deprecated v3.0.0) | New (v3.0.0+) |
|---|---|---|
| Communications sub-namespaces | client.communications.list_rfqs(...) |
client.communications.rfqs.list(...) |
client.communications.get_quote(id) |
client.communications.quotes.get(id) |
|
| (12 forwarders total across rfqs/quotes) | (same; sub-resource methods) | |
*_all naming |
client.markets.list_trades_all(...) |
client.markets.list_all_trades(...) |
fills relocation |
client.orders.fills(...) |
client.portfolio.fills(...) |
client.orders.fills_all(...) |
client.portfolio.fills_all(...) |
v2.6 ships two behavioral fences, both surface bugs that were already wrong:
bool no longer slips through on integer request fields (#295), and
extra_headers cannot carry KALSHI-ACCESS-* keys (#298). Wire
format is unchanged.
Per #295, every integer field on every Request model that lacks a
dedicated validator now rejects bool at construction. bool is an
int subclass, so a caller passing True/False silently became
1/0 and money-routing fields (subaccount, from_subaccount,
amount_cents, count, etc.) silently misrouted. The fix mirrors
CreateOrderRequest.buy_max_cost (#243) across all request models on
both V1 and V2 surfaces:
# v2.5 (silent True → 1)
ApplySubaccountTransferRequest(
client_transfer_id=uuid4(),
from_subaccount=True, # → 1, transfer from subaccount 1
to_subaccount=True, # → 1, transfer TO subaccount 1
amount_cents=True, # → 1, one cent
)
# v2.6 (raises ValidationError on each bool)
ApplySubaccountTransferRequest(
client_transfer_id=uuid4(),
from_subaccount=True, # ➜ ValidationError: bool is not a valid int here
)A new public alias kalshi.StrictInt = Annotated[int, BeforeValidator(...)]
is exported for downstream models that want the same guard.
Per #298, the SDK promised in docs that auth headers could not be forged
via extra_headers. That promise relied on a case-sensitive Python dict
merge: a caller-supplied lowercase "kalshi-access-key" co-existed with
the SDK-signed uppercase "KALSHI-ACCESS-KEY" and httpx shipped both raw
header lines on the wire — a forge surface even though the documented
contract said otherwise. Now both the per-request extra_headers kwarg
and KalshiConfig(extra_headers=...) reject any key (case-insensitive)
that starts with kalshi-access- at the construction boundary:
# v2.5 (silently shipped two KALSHI-ACCESS-KEY header lines)
client.markets.list(
extra_headers={"kalshi-access-key": "forged"},
)
# v2.6 (raises ValueError naming the leaked keys)
client.markets.list(
extra_headers={"kalshi-access-key": "forged"},
)
# ValueError: extra_headers must not include KALSHI-ACCESS-* keys ...
# Same rejection now fires at construction:
KalshiConfig(extra_headers={"kalshi-access-key": "x"})
# ValueErrorCompanion behavior: _post(json=...) / _put(json=...) /
_delete_with_body(json=...) (and async mirrors) now pin
Content-Type: application/json explicitly. A caller-supplied
extra_headers={"content-type": "text/plain"} previously suppressed
httpx's implicit inference and shipped a JSON body labelled as plain
text; the explicit pin prevents that. Most callers are unaffected.
Per #296, OrderbookManager._apply_snapshot_inplace now adopts
msg.msg.yes / .no by identity for the recv-loop hot path. Raw
consumers of subscribe_orderbook_delta who receive an
OrderbookSnapshotMessage and hold a reference to msg.msg.yes /
.no will observe future delta mutations through that reference:
# v2.6 raw subscriber
async for msg in stream:
if isinstance(msg, OrderbookSnapshotMessage):
held = msg.msg.yes # ← LIVE dict, mutated by next deltaThe high-level client.orderbook(ticker) / subscribe_book(ticker)
iterators are unaffected — both materialize fresh Orderbook instances
that are safe to retain. The public OrderbookManager.apply_snapshot()
is also unaffected: it defensively copies after the inplace adoption so
the caller-supplied msg keeps its original dicts. If you need an
immutable snapshot view from the raw stream, copy explicitly:
async for msg in stream:
if isinstance(msg, OrderbookSnapshotMessage):
immutable_yes = dict(msg.msg.yes)v2.5 ships two user-visible breaking changes — both surface bugs that v2.4's audit missed — plus performance wins on the WS hot path, REST per-request headers, and a pluggable REST JSON loader. Wire format is unchanged.
Per #242, the kwarg-form client.orders.create(ticker=..., side=...)
previously defaulted action to "buy" and count to 1 if you
forgot them — placing a 1-contract live buy with no error. Now both are
required on the kwarg path and the model: CreateOrderRequest.count
no longer has a default.
# v2.4 (silent 1-contract buy on the missing kwargs)
client.orders.create(ticker="EXAMPLE-25-T", side="yes")
# v2.5 (raises TypeError before any HTTP request)
client.orders.create(ticker="EXAMPLE-25-T", side="yes")
# TypeError: create() requires `ticker`, `side`, `count`, and `action`
# v2.5 (explicit, works)
client.orders.create(
ticker="EXAMPLE-25-T",
side="yes",
action="buy",
count=10,
yes_price="0.65",
)The request=... overload is unaffected — CreateOrderRequest(...) is
the recommended path for programmatic order construction.
Per #258 and #259, nine model fields previously typed as str or
float are now Decimal-backed via DollarDecimal /
FixedPointCount / Decimal-via-_coerce_decimal. This brings them
under the same coercion contract every other money/count field in the
SDK has used since v2.0:
| Field | Was | Now |
|---|---|---|
OrderGroupPayload.contracts_limit (WS) |
str | None |
FixedPointCount | None |
TickerPayload.dollar_volume (WS) |
str |
DollarDecimal |
TickerPayload.dollar_open_interest (WS) |
str |
DollarDecimal |
Market.floor_strike |
Decimal | None (bare) |
DollarDecimal | None |
Market.cap_strike |
Decimal | None (bare) |
DollarDecimal | None |
Event.fee_multiplier_override |
Decimal | None (bare) |
Decimal | None (coerced) |
MarketLifecyclePayload.floor_strike (WS) |
Decimal | None (bare) |
DollarDecimal | None |
Series.fee_multiplier |
float |
Decimal (coerced) |
SeriesFeeChange.fee_multiplier |
float |
Decimal (coerced) |
Wire format is unchanged — the spec already specified these as decimal strings or fixed-point. The behavior change is at the Python boundary:
# v2.4
ticker_payload.dollar_volume # "1234.5600" (str)
ticker_payload.dollar_volume + 1.0 # TypeError
# v2.5
ticker_payload.dollar_volume # Decimal("1234.5600")
ticker_payload.dollar_volume + Decimal("1.00") # Decimal arithmeticIf you were already wrapping these in Decimal(...) at the consumer
side, that wrapper now becomes a no-op identity coercion — safe to
keep or remove. Float arithmetic against Series.fee_multiplier will
now raise TypeError; coerce to Decimal first.
Per #253, every public REST resource method now accepts an
extra_headers kwarg for distributed-tracing, idempotency, or
per-call routing. KALSHI-ACCESS-* signing headers always win, so
callers cannot forge them via this surface.
import uuid
client.orders.create(
ticker="EXAMPLE-25-T", side="yes", action="buy",
count=10, yes_price="0.65",
extra_headers={"Idempotency-Key": str(uuid.uuid4())},
)Client-wide defaults still go via KalshiConfig.extra_headers; per-call
values merge on top (later wins).
Per #260, KalshiConfig.rest_json_loads mirrors the existing
ws_json_loads (v2.4 #209). Set to orjson.loads for ~2–3× faster
list-endpoint parsing:
import orjson
from kalshi import KalshiClient, KalshiConfig
config = KalshiConfig(rest_json_loads=orjson.loads)
client = KalshiClient(auth=..., config=config)Default (None) falls back to httpx.Response.json().
Per #250, KalshiConfig now rejects base_url / ws_base_url hosts
outside {api.elections.kalshi.com, demo-api.kalshi.co, localhost, 127.0.0.1, ::1} by default. A typo like kalsi.com no longer silently
delivers signed requests to the wrong endpoint. Opt-in for mock servers
or custom proxies:
# Either:
config = KalshiConfig(
base_url="https://my-mock-server.test/trade-api/v2",
allow_unknown_host=True,
)
# Or process-wide:
os.environ["KALSHI_ALLOW_UNKNOWN_HOST"] = "1"Per #239, KalshiClient(demo=True, base_url="https://api.elections.kalshi.com/...")
(or the env-var equivalent) now raises ValueError at construction
instead of silently producing a config where REST hits production but
WS hits demo. If you genuinely need mixed environments, build the
KalshiConfig explicitly with both base_url and ws_base_url
pointing at hosts in the same environment.
-
KalshiNetworkError(#240) — exhausted retries onhttpx.ConnectError,NetworkError,RemoteProtocolError,ReadError, orWriteError. Transport now retries these on idempotent verbs (GET/HEAD/OPTIONS) and onConnectErrorfor POST/DELETE (request never reached the wire). -
KalshiOrderbookUnavailableError(#257) — raised by the high-levelsubscribe_bookiterator when the local book is missing between a gap-recovery teardown and the new snapshot. Catch and reattach to a fresh iterator:while True: try: async for book in session.subscribe_book(ticker="EXAMPLE-25-T"): ... except KalshiOrderbookUnavailableError: # gap recovery in flight; reattach continue
-
KalshiBackpressureErrornow carrieschannel,sid,client_id, andmaxsize(#256). Consumers iterating multiple subscriptions can route the error by channel:try: async for msg in session.subscribe_ticker(tickers=[...]): ... except KalshiBackpressureError as e: logger.warning("backpressure on %s (sid=%s)", e.channel, e.sid)
Per #266, KalshiClient.from_env and AsyncKalshiClient.from_env now
expose a typing.Unpack-driven TypedDict signature so typos like
time_out=10 trip mypy strict at the call site. No runtime change.
Per #269, both endpoints now ship cursor-iterating *_all() helpers
matching the rest of the SDK's pagination convention.
for position in client.portfolio.positions_all():
print(position.ticker, position.position)Per #269, multivariate.lookup_tickers, multivariate.lookup_history,
and multivariate.create_market (sync + async) carry
@typing_extensions.deprecated decorators citing the spec's "should
not be used for new integrations" guidance. Use RFQs instead. The
endpoints still work; calls just emit a DeprecationWarning on first
use.
multivariate.lookup_history also now validates lookback_seconds
locally against the spec enum {10, 60, 300, 3600} and raises
ValueError for any other value before the round trip.
Per #269, event_ticker accepts list[str] | str | None on
OrdersResource.list and list_all (sync + async). Lists are joined
via _join_tickers with a spec-enforced max_items=10:
for order in client.orders.list_all(event_ticker=["EV1", "EV2", "EV3"]):
...- WS recv loop no longer allocates a
Task+shieldper frame (#245). Pause/resume now uses a cooperativeEvent+ 50 ms poll; lower allocation pressure on high-volume markets. subscribe_bookiterator caches the materializedOrderbookper ticker (#244); consecutivemgr.get(ticker)calls without intervening mutations return the same object identity.OrderbookSnapshotPayload.yesand.noare nowdict[Decimal, Decimal]rather thanlist[tuple[Decimal, Decimal]](#263). External consumers reading these fields directly will see a dict; iterate.items()if you need the prior tuple shape.OrderbookSnapshotPayload.yes/.noare now required (nodefault=[]); a malformed snapshot raisesValidationError(#268).Decimal('NaN')/Decimal('Infinity')rejected at parse and at serialize (#270); construct fresh values from finite inputs.- WS
user_ordersandcommunicationspayloads now usepydantic.AwareDatetime(#270); naive RFC3339 strings raiseValidationError(REST was already strict per v2.4 #234). - V1
CreateOrderRequestenum-style fields (side,action,time_in_force,self_trade_prevention_type) are nowLiteral[...]typed (#270); typos fail at construction instead of server-side. Retry-After: -5andRetry-After: <past HTTP-date>now both clamp to 0.0 (retry immediately) — the delta-seconds form previously fell back to computed backoff while the date form already clamped (#267).
v2.4 ships one user-visible breaking change — the V1 batch order
return shape (#194) — plus several additive surfaces: new typed
exceptions, passphrase-protected PEMs, opt-in HTTP/2, and per-request
extra_headers.
Per #194, orders.batch_create previously returned list[Order] and
crashed with ValidationError on the first failed leg;
orders.batch_cancel returned None and discarded the per-leg
reduced_by_fp counts the server actually sent. Both now return
typed envelopes matching the V2 family — pair legs by
client_order_id and check entry.error per leg.
# v2.3
orders: list[Order] = client.orders.batch_create(request=req)
for order in orders:
print(order.order_id, order.status)
# v2.4
from kalshi.models import BatchCreateOrdersResponse
resp: BatchCreateOrdersResponse = client.orders.batch_create(request=req)
for entry in resp.orders:
if entry.error is not None:
logger.error("leg %s failed: %s", entry.client_order_id, entry.error)
continue
assert entry.order is not None
print(entry.order.order_id, entry.order.status)batch_cancel follows the same shape: each resp.orders[i] exposes
order_id, reduced_by_fp (the count of contracts that actually
canceled — load-bearing for risk reconciliation), plus
order/error for the affected leg.
Per #201 / #204 / #226, three new subclasses of KalshiError join
the existing hierarchy:
KalshiConflictError— HTTP 409 (e.g. duplicateclient_order_id).KalshiTimeoutError—httpx.TimeoutExceptionat the request level. The server may or may not have processed the request. POST/DELETE still never retry on this; reconcile by querying with the originalclient_order_idbefore issuing a fresh attempt.KalshiPoolExhaustedError—httpx.PoolTimeout. The request never reached the wire and IS safe to retry on POST/DELETE.
422 now also routes to KalshiValidationError (was 400 only).
from kalshi import (
KalshiConflictError,
KalshiTimeoutError,
KalshiPoolExhaustedError,
)
try:
client.orders.create(request=req)
except KalshiConflictError:
existing = client.orders.get_by_client_order_id(req.client_order_id)
except KalshiPoolExhaustedError:
# never touched the wire — safe to retry
client.orders.create(request=req)
except KalshiTimeoutError:
# reconcile before retrying — server may have committed
existing = client.orders.get_by_client_order_id(req.client_order_id)Per #217 / #233, KalshiAuth.from_pem, from_key_path, from_env,
and try_from_env accept a password= kwarg (str / bytes / a
zero-arg callable returning either — the callable form lets you
defer fetching the secret from a vault until load-time). from_env
/ try_from_env also read KALSHI_PRIVATE_KEY_PASSPHRASE from the
environment; the explicit password= wins when both are set.
Encrypted PEMs no longer have to be stripped to disk first.
auth = KalshiAuth.from_key_path(
"your-key-id",
"~/.kalshi/private_key.pem",
password=lambda: vault.get_secret("kalshi-pem-pass"),
)Per #233, KalshiConfig(http2=True) enables HTTP/2 on the REST
client (off by default for compat). The h2 dependency is not
installed by default; KalshiConfig.__post_init__ fail-fasts with a
clear message when http2=True is set without it:
pip install 'kalshi-sdk[http2]'config = KalshiConfig(http2=True)
client = KalshiClient(auth=auth, config=config)Per #220, KalshiConfig.extra_headers sets client-wide default
headers, and SyncTransport.request(..., extra_headers=...) /
AsyncTransport.request(..., extra_headers=...) merge per-call
overrides on top. KALSHI-ACCESS-* signing headers always win, so
callers cannot forge them via this surface. Resource-method plumbing
— so e.g. client.orders.create(..., extra_headers=...) works
directly — is tracked in #253 and not yet shipped in v2.4; only the
transport-level entry point is public.
v2.3 is additive on the REST surface and soft-breaking on the WebSocket
surface in one place: KalshiWebSocket.run_forever() no longer returns
silently when nothing has been subscribed. Everything else is opt-in.
Per closed #175 / #185, KalshiWebSocket.run_forever() now raises
KalshiSubscriptionError at the call site when no subscribe_* call has
landed on the session. The previous silent no-op masked a common mistake —
registering an @ws.on(channel) callback without subscribing, then
wondering why no frames arrived (the server only sends frames for channels
the client explicitly subscribed to).
# v2.2 — silently returned, recv loop never ran
async with ws.connect() as session:
await session.run_forever()
# v2.3 — wrap a subscribe_* call before run_forever()
async with ws.connect() as session:
await session.subscribe_ticker(tickers=["EXAMPLE-25-T"])
await session.run_forever()No production usage of the silent shape is known; the foot-gun was the bug.
Per closed #177 / #186, run_forever() now accepts an optional
stop_event: asyncio.Event | None = None. When set, the recv loop
closes the connection and exits cleanly without raising CancelledError
— typically wired to SIGINT so Ctrl+C drains in-flight dispatches:
import asyncio
import signal
stop = asyncio.Event()
asyncio.get_running_loop().add_signal_handler(signal.SIGINT, stop.set)
async with ws.connect() as session:
await session.subscribe_ticker(tickers=["EXAMPLE-25-T"])
await session.run_forever(stop_event=stop)Omitting stop_event preserves v2.2 behavior — external cancellation
still propagates.
Per #176, the reconnect path used to silently drop data frames that
arrived between the moment SubscriptionManager cleared its
sid → client_id map and the moment the new sids landed in the
subscribe-response handler. Under burst reconnects on high-volume
channels (ticker, trade, fill), this lost tens of messages per
reconnect.
SubscriptionManager now stashes those frames in a per-sid bounded
collections.deque(maxlen=stash_maxlen) (default 1000 per sid). After
resubscribe_all completes, the stash is drained through the normal
dispatch path so seq trackers advance, orderbook state applies, and
iterator consumers receive the frames in arrival order. No API change —
behavior change only.
Per #178, KalshiAuth.sign_request_async() now routes the RSA-PSS sign
through a dedicated ThreadPoolExecutor(max_workers=2) lazy-initialised
on first use, so signs don't queue behind getaddrinfo / file I/O / other
to_thread() work during WS reconnect storms.
If you use KalshiAuth through KalshiClient / AsyncKalshiClient,
client.close() already tears the executor down for you and no migration
is required. If you instantiate KalshiAuth standalone (e.g. signing
requests through a custom transport), call auth.close() to release the
executor:
from kalshi import KalshiAuth
auth = KalshiAuth.from_key_path("your-key-id", "~/.kalshi/private_key.pem")
try:
headers = await auth.sign_request_async("GET", "/trade-api/v2/markets")
...
finally:
auth.close()v2.2 is soft-breaking at the response-parse boundary only (per
#157 / #172). The wire format is unchanged. The behavior change: server
omission of a previously-optional spec-required field now raises
pydantic.ValidationError at parse time, instead of silently producing
field=None and pushing the surprise to a downstream NoneType
attribute access.
226 fields across REST models, WS payloads, and helper classes flipped
from Optional[X] = None to required, matching what the OpenAPI /
AsyncAPI specs already declared.
Affected response model categories include Market, Order, Fill,
Event, EventMetadata, Settlement, Trade, IncentiveProgram,
RFQ, Quote, OrderGroup plus its three group-response shapes, and
11 WebSocket payloads — including the new *_ts_ms millisecond
timestamps and the outcome_side / book_side direction encoding the
server already emits. See CHANGELOG.md for the full per-model
breakdown.
If you parse live responses and previously branched on field is None
for an optional you read from the SDK, you may now hit
pydantic.ValidationError at the parse site when the server omits the
field on a malformed event. Wrap the call site:
from pydantic import ValidationError
try:
market = client.markets.get(ticker)
except ValidationError as exc:
logger.warning("skipping malformed market %s: %s", ticker, exc)
continueThe vast majority of fields were already populated in practice — only
models with legitimate live-server omission gaps need the try/except.
Two known cases (Event.product_metadata, EventMetadata.market_details)
ship in v2.3 with server_omits_despite_required exclusions baked in
and need no caller action.
All WS envelope and helper classes (and the five REST helpers from the
v2.0 sweep — Page, Orderbook, OrderbookLevel, BidAskDistribution,
PriceDistribution) now uniformly use model_config = ConfigDict(extra="allow").
Additive server fields no longer raise; they land on
__pydantic_extra__ and round-trip through model_dump().
v2.1 syncs the SDK to OpenAPI spec v3.18.0. It's additive at the resource surface — eight new endpoints, several new optional kwargs on existing methods, and one soft-breaking model-construction change called out below. Code that only consumes the SDK's responses needs no edits; code that constructs models directly in tests/mocks needs one small update.
Spec v3.18.0 adds balance_dollars: FixedPointDollars to
GetBalanceResponse as a required field. The server now guarantees it, so
callers parsing API responses (client.portfolio.balance()) are unaffected.
But any code that builds Balance(...) directly — typically test mocks
— will hit ValidationError until it adds the field.
# v2.0 — broken in v2.1
Balance(balance=50000, portfolio_value=75000, updated_ts=ts)
# v2.1
Balance(
balance=50000,
balance_dollars=Decimal("500.00"), # new required field
portfolio_value=75000,
updated_ts=ts,
)The accompanying optional balance_breakdown: list[IndexedBalance] | None
splits the total across exchange shards when present. IndexedBalance.balance
is DollarDecimal (matching balance_dollars units), not cents — same
field name as Balance.balance but a different type. Be deliberate when
reading from balance.balance_breakdown[i].balance.
Six new methods on OrdersResource / AsyncOrdersResource hit the new
/portfolio/events/orders/* paths:
create_v2(*, request: CreateOrderV2Request)cancel_v2(order_id, *, subaccount, exchange_index)amend_v2(order_id, *, request: AmendOrderV2Request, subaccount)decrease_v2(order_id, *, request: DecreaseOrderV2Request, subaccount)batch_create_v2(*, request: BatchCreateOrdersV2Request)batch_cancel_v2(*, request: BatchCancelOrdersV2Request)
Legacy /portfolio/orders keeps working and will be deprecated no earlier
than May 6, 2026. No migration is required to stay on v1 paths. New
event-market trading should target the V2 family for the cleaner shape
(single bid/ask side, single price field, explicit idempotency).
Two important differences from V1:
client_order_idis required onCreateOrderV2Requestand acts as the server-side idempotency key. Reusing a value returns the original order rather than placing a new one. Use a fresh UUID4 per call.sideusesBookSideLiteral("bid"/"ask"), not V1'sSideLiteral("yes"/"no").
amend_v2 and decrease_v2 accept subaccount as a resource-method
kwarg (query param on the wire) but read exchange_index from the
request body. cancel_v2 differs again — both are query params there,
because that endpoint has no body. This mirrors the spec exactly:
client.orders.amend_v2(
"ord-1",
subaccount=3, # query param
request=AmendOrderV2Request(
ticker="EVENT-MKT", side="bid",
price=Decimal("0.55"),
count=Decimal("10"),
exchange_index=0, # body field
),
)All additive — existing call sites keep working:
orders.cancel(*, exchange_index),order_groups.delete(*, exchange_index)communications.list_rfqs(*, user_filter),communications.list_all_rfqs(*, user_filter)communications.list_quotes(*, user_filter, rfq_user_filter),communications.list_all_quotes(*, user_filter, rfq_user_filter).user_filter="self"andrfq_user_filter="self"are now standalone satisfiers for the server-side filter requirement (previously onlyquote_creator_user_id/rfq_creator_user_idworked).incentive_programs.list(*, incentive_description)and thelist_allvariant.CreateQuoteRequest.post_only— passpost_only=Truetocreate_quote().exchange_indexon order/amend/decrease/batch-cancel request models.
portfolio.deposits()/portfolio.deposits_all()— deposit history.portfolio.withdrawals()/portfolio.withdrawals_all()— withdrawal history.account.endpoint_costs()— token costs for endpoints whose cost differs from the default.
Added to kalshi.* and kalshi.models.*:
- Literals:
BookSideLiteral,UserFilterLiteral,PaymentStatusLiteral,PaymentTypeLiteral. - Models:
Deposit,Withdrawal,IndexedBalance,AccountEndpointCosts,EndpointTokenCost, and the V2 request/response family.
v2.0 is mostly additive (new max_pages kwarg, KalshiConfig.http2/limits,
RateLimit model export). There are 3 deliberate breaking changes —
all on the response-model surface, all driven by spec-or-server reality.
Migrate by find/replace.
Renamed to avoid shadowing the Python builtin (matching the existing
milestone_type / target_type / incentive_type pattern). Wire format
is unchanged — incoming JSON type still populates the field via
validation_alias.
# v1
order = client.portfolio.orders.get(order_id="...")
print(order.type) # → AttributeError after upgrade
# v2
print(order.order_type) # str | None — "limit" or "market"Replaced with AccountApiLimits.read / .write of type RateLimit. The
published OpenAPI spec declares the limits as ints; the live server
actually returns nested token buckets. v2 matches the server. The old int
fields never worked against the live API.
# v1
limits = client.account.limits()
limits.read_limit # → AttributeError after upgrade
# v2
limits.read.bucket_capacity # int
limits.read.refill_rate # int
# new model exposed: kalshi.RateLimitFields like Market.volume, Fill.count, Trade.count,
MarketPosition.position were annotated DollarDecimal but semantically
represent integer counts. v2 retypes them to FixedPointCount. Runtime
values still come back as Decimal — only the type annotation changed,
so mypy --strict users may need to update narrow assertions.
isinstance(x, Decimal) checks remain valid.
*_all()is now unbounded by default. The previous internal 1000-page cap silently truncated callers iterating beyond ~100k items. Cursor-repeat guard is still the safety net against server bugs. Passmax_pages=Nfor an explicit cap.- Response models uniformly use
extra="allow". 5 models (Page,Orderbook,OrderbookLevel,BidAskDistribution,PriceDistribution) that previously silently dropped unknown fields now preserve them on__pydantic_extra__. - WS callbacks no longer suppress queue delivery. Holding both an
@on()callback and an iterator on the same channel now sees both fire. A WARNING logs at register-time so the change is visible to upgraders.
See CHANGELOG.md for the full list including the WS recv-loop overhaul (5 reconnect races fixed), URL/trade-data log-leak scrubs, and spec-sync supply-chain hardening.
If you're coming from the predecessor community client kalshi_python_async,
this page summarizes the
v1 SDK differences you'll hit. If you don't recognize that name, you can
skip this page — there's nothing here that applies to a greenfield
project on kalshi-sdk.
The v1 SDK is a clean rewrite rather than an in-place upgrade, so the move is closer to a port than a patch. Concrete details below are sourced from the current SDK; predecessor specifics are documented where they exist in-tree and called out as unverified where they don't.
- One transport, two surfaces. v1 ships hand-crafted sync (
KalshiClient) and async (AsyncKalshiClient) clients sharing one transport implementation — neither sync-wraps-async nor vice versa. - Spec-aligned with drift guards. Models are generated from the Kalshi OpenAPI/AsyncAPI specs, and contract tests fail CI when query, request body, or WebSocket payload shapes drift from the spec.
mypy --strictclean,py.typedshipped, Pydantic v2 models end-to-end.- Safe-by-default retries. Only
GET/HEAD/OPTIONSretry on transient errors;POST/DELETEnever do. - v1 stability promise. Public API is stable as of
1.0.0; breaking changes go through a deprecation cycle.
Top-level imports come from kalshi:
# v1
from kalshi import (
KalshiClient,
AsyncKalshiClient,
KalshiAuth,
KalshiConfig,
CreateOrderRequest,
Market, Order, Page,
SideLiteral, ActionLiteral, TimeInForceLiteral,
KalshiError, KalshiNotFoundError, KalshiRateLimitError,
)WebSocket primitives live in kalshi.ws:
from kalshi.ws import KalshiWebSocket, OverflowStrategy, ConnectionStateThe full export list is in
kalshi/__init__.py.
If you previously imported from kalshi_python_async import ..., swap to
from kalshi import .... The exact symbol-by-symbol map is unverified
— the v1 surface is not a renaming of an upstream surface, it's a new one
that happens to talk to the same API.
# v1 — three equivalent paths
from kalshi import KalshiClient, KalshiAuth
# A) key file path
client = KalshiClient(
key_id="your-key-id",
private_key_path="~/.kalshi/private_key.pem",
demo=True,
)
# B) environment
# KALSHI_KEY_ID, KALSHI_PRIVATE_KEY_PATH | KALSHI_PRIVATE_KEY,
# KALSHI_DEMO=true, KALSHI_API_BASE_URL=...
client = KalshiClient.from_env()
# C) pre-built KalshiAuth (useful for sharing auth with the WebSocket client)
auth = KalshiAuth.from_key_path("your-key-id", "~/.kalshi/private_key.pem")
client = KalshiClient(auth=auth, demo=True)Calling private endpoints on an unauthenticated client raises
AuthRequiredError before any network call. Public endpoints (market
data, events, exchange status) work on an unauthenticated client.
demo=True selects the sandbox base URL and WebSocket URL together. There
is no separate "base URL switch" you have to remember.
The signing scheme — RSA-PSS / SHA256 / MGF1(SHA256) / salt = digest length — is identical to what every other Kalshi client uses. You don't touch it yourself; pass the PEM and key id and you're done.
The v1 resource methods accept two equivalent input styles:
# kwargs (default for most call sites)
order = client.orders.create(
ticker="KXPRES-24-DJT",
side="yes",
action="buy",
count=10,
yes_price="0.65",
time_in_force="good_till_canceled",
)
# request model
from kalshi import CreateOrderRequest
order = client.orders.create(request=CreateOrderRequest(
ticker="KXPRES-24-DJT",
side="yes",
action="buy",
count=10,
yes_price="0.65",
time_in_force="good_till_canceled",
))Mixing the two raises TypeError. Phantom kwargs (anything not on the
underlying request model) also raise TypeError. Enum-valued kwargs use
Literal[...] types (SideLiteral, ActionLiteral,
TimeInForceLiteral, etc.) so typos fail mypy and IDEs auto-complete the
values.
Prices and counts are decimal dollars. Pass Decimal, int, or str;
never float. Internally the SDK uses Decimal via the custom
DollarDecimal Pydantic type.
The async surface mirrors sync method-for-method on AsyncKalshiClient.
The WebSocket client is async-only:
import asyncio
from kalshi import KalshiAuth, KalshiConfig
from kalshi.ws import KalshiWebSocket
async def main() -> None:
auth = KalshiAuth.from_key_path("your-key-id", "~/.kalshi/private_key.pem")
config = KalshiConfig.demo()
ws = KalshiWebSocket(auth=auth, config=config)
async with ws.connect() as session:
stream = await session.subscribe_orderbook_delta(tickers=["KXPRES-24-DJT"])
async for msg in stream:
print(msg)
asyncio.run(main())If your predecessor used a sync-style streaming API, wrap the entry point
in asyncio.run(...). The Kalshi feed is push-driven; a real sync API would
have to spawn a thread and bridge a queue, which is exactly what we don't
want to ship as a public surface. See
WebSocket Streaming for the full guide, including
per-channel subscribe methods, sequence-gap recovery, backpressure
strategies, and automatic reconnection semantics.
The exception hierarchy is rooted at KalshiError. Subclasses correspond
to HTTP statuses (KalshiAuthError for 401/403, KalshiNotFoundError for
404, KalshiValidationError for 400, KalshiRateLimitError for 429,
KalshiServerError for 5xx) plus WebSocket-specific siblings
(KalshiConnectionError, KalshiSubscriptionError, etc.). See
Error Handling for the full tree and the status-to-exception
map.
If your predecessor raised string-based errors or untyped exceptions, the common rewrite is to catch the specific class:
# Before (predecessor — unverified shape):
# try:
# market = api.get_market(ticker)
# except Exception as e:
# ...
# After
from kalshi import KalshiNotFoundError, KalshiRateLimitError
try:
market = client.markets.get(ticker)
except KalshiNotFoundError:
...
except KalshiRateLimitError as e:
backoff_seconds = e.retry_afterPage[T] replaces ad-hoc list responses:
page = client.markets.list(status="open", limit=200)
for market in page: # iterable over .items
...
len(page) # items on this page
page.cursor # next-page cursor (None on the last page)
page.has_next # bool
# pandas / polars (optional extras)
df = page.to_dataframe()
df = page.to_polars()For multi-page traversal:
# Sync — Iterator[T]
for market in client.markets.list_all(status="open"):
...
# Async — AsyncIterator[T], works directly with `async for`
async for market in async_client.markets.list_all(status="open"):
...list_all() is unbounded by default — it walks cursors until the server
returns no more pages. Pass max_pages=N for an explicit cap; passing
max_pages=0 raises ValueError. The cursor-repeat detector still raises
KalshiError if the server hands back a duplicate cursor. See
Pagination for the canonical contract.
If you're porting a specific predecessor call and can't find the v1
equivalent, the API Reference is the auto-generated
ground truth. Open an issue on
TexasCoding/kalshi-python-sdk
with the predecessor call and the closest match you found and we'll either
fill in the gap or point to the right method.