
Kyro is an async Python client library for the Kalshi REST API.
It uses aiohttp for async HTTP requests and Pydantic for request and response validation. The library mirrors the API surface closely and exposes a typed, low-level interface.
API areas are grouped into:
exchangemarketseventssearchordersportfolio
Errors are surfaced as explicit exception types: KyroError (base), KyroHTTPError, KyroTimeoutError, KyroConnectionError, KyroValidationError — with status codes, response bodies, and error codes attached so you can debug and branch without re-calling the API.
- Python ≥ 3.10 (3.10–3.12 supported)
- aiohttp ≥ 3.9
- pydantic ≥ 2
From PyPI:
pip install kyroFrom the repo (development / unreleased):
pip install -e .Authentication (request signing, .env loading) is included in the core package. See Authentication.
On Homebrew Python (macOS) and other PEP 668 setups, use a virtual environment first:
python3 -m venv .venv && source .venv/bin/activate
pip install kyro # or: pip install -e . for developmentfrom kyro import KyroConfig, config_from_env
# Production (default). Despite "elections" in the host, this serves all Kalshi markets.
cfg = KyroConfig(base_url="https://api.elections.kalshi.com/trade-api/v2")
# Demo
cfg = KyroConfig(base_url="https://demo-api.kalshi.co/trade-api/v2")
# From environment (base URL and optional auth). See env vars below.
cfg = config_from_env() # production by default
cfg = config_from_env(default_demo=True) # demo when KALSHI_* not set
# Timeouts and headers
cfg = KyroConfig(
request_timeout=15.0,
connect_timeout=5.0,
default_headers={"User-Agent": "MyApp/1.0"},
)Environment variables (for config_from_env()): put these in a .env in the current directory (copy from .env.example) or export them. .env is loaded automatically when config_from_env() is used.
| Variable | Description |
|---|---|
KALSHI_BASE_URL |
Override API base URL |
KALSHI_DEMO=1 |
Use demo base URL |
KALSHI_PRODUCTION=1 |
Use production base URL |
KALSHI_ACCESS_KEY or KALSHI_ACCESS_KEY_ID |
API key ID for request signing |
KALSHI_PRIVATE_KEY |
PEM string (use \n for newlines in env) |
KALSHI_PRIVATE_KEY_PATH |
Path to .key or .pem file |
Kalshi uses RSA-PSS request signing. Each authenticated request must include:
KALSHI-ACCESS-KEY— your API key IDKALSHI-ACCESS-TIMESTAMP— Unix millisecondsKALSHI-ACCESS-SIGNATURE— base64‑encoded signature oftimestamp + method + path(path without query string), signed with your private key.
Kyro supports three ways to supply auth; config_from_env() is the usual choice.
Set keys in .env or the environment (.env is loaded automatically by config_from_env()). In .env (or export):
KALSHI_ACCESS_KEY=your-key-id
KALSHI_PRIVATE_KEY_PATH=/path/to/your.pem
# or inline PEM (use \n for newlines):
# KALSHI_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
Then:
from kyro import config_from_env, RestClient
cfg = config_from_env() # or config_from_env(default_demo=True)
async with RestClient(cfg) as client:
bal = await client.get("/portfolio/balance") # auth added automaticallycryptographyandpython-dotenvare core dependencies; signing and.envloading work with a plainpip install kyro.- If both
KALSHI_ACCESS_KEY(orKALSHI_ACCESS_KEY_ID) and a private key (fromKALSHI_PRIVATE_KEYorKALSHI_PRIVATE_KEY_PATH) are set, Kyro builds an auth signer and attaches the three headers to every request. No extra code. KALSHI_PRIVATE_KEY_PATHcan be relative to the current working directory (e.g.kal_key.pemor.kalshi/kal_key.pem).- For inline PEM in
.env, use\nfor newlines:KALSHI_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----".
If you generate the three headers yourself (e.g. for testing or a custom pipeline):
from kyro import KyroConfig, RestClient
cfg = KyroConfig(
base_url="https://api.elections.kalshi.com/trade-api/v2",
auth_headers={
"KALSHI-ACCESS-KEY": "your-key-id",
"KALSHI-ACCESS-TIMESTAMP": "1737654321000",
"KALSHI-ACCESS-SIGNATURE": "base64-signature...",
},
)
async with RestClient(cfg) as client:
...Caveat: the timestamp must be fresh for each request. Kalshi rejects old timestamps, so static auth_headers are only suitable for short-lived runs or when you refresh them yourself. For normal use, prefer config_from_env() or an auth_signer.
You can pass a callable that returns the auth headers per request:
from kyro import KyroConfig, RestClient
def my_signer(method: str, path: str, body: bytes | None) -> dict[str, str]:
# path is the full path (e.g. /trade-api/v2/portfolio/balance), no query string.
# Return {"KALSHI-ACCESS-KEY": "...", "KALSHI-ACCESS-TIMESTAMP": "...", "KALSHI-ACCESS-SIGNATURE": "..."}
...
cfg = KyroConfig(base_url="...", auth_signer=my_signer)
async with RestClient(cfg) as client:
...auth_signeroverridesauth_headers: if both are set, only the signer is used.- The signer is called on every request with
(method, path, body). Kyro sends whatever headers it returns.
| Requires auth | Endpoints |
|---|---|
| No | exchange.get_exchange_status, get_exchange_announcements, get_exchange_schedule, get_series_fee_changes; all of markets.*, events.*, and search.* |
| Yes | exchange.get_user_data_timestamp; all of orders.* and portfolio.* |
Without auth, public endpoints work as usual. Auth-required calls return 401 if the headers are missing or invalid.
- Log in at kalshi.com → Account → API (or API Keys).
- Create an API key and download the
.pem(private key). Keep the key ID shown there. - Put
KALSHI_ACCESS_KEY=<key-id>andKALSHI_PRIVATE_KEY_PATH=/path/to/file.pemin.env, or useKALSHI_PRIVATE_KEYwith the PEM string.
Security: Do not commit .env or .pem files. Prefer KALSHI_PRIVATE_KEY_PATH to a file outside the repo; avoid storing the raw PEM in env if you can.
from kyro import RestClient, KyroConfig
from kyro.rest import exchange, markets, events, search, orders, portfolio
async with RestClient(KyroConfig()) as client:
# Exchange (no auth except get_user_data_timestamp)
status = await exchange.get_exchange_status(client)
await exchange.get_exchange_announcements(client)
await exchange.get_exchange_schedule(client)
await exchange.get_series_fee_changes(client, series_ticker="KXBTC")
await exchange.get_user_data_timestamp(client) # auth
# Markets — filters: series_ticker, event_ticker, status, tickers, min/max_*_ts, cursor
ms = await markets.get_markets(
client, series_ticker="KXBTC", limit=10, status="open"
)
await markets.get_markets(
client, event_ticker="INXD-25", limit=5, status="open"
)
m = await markets.get_market(client, "KXBTC-24JAN15")
ob = await markets.get_market_orderbook(client, "KXBTC-24JAN15", depth=10)
trades = await markets.get_trades(
client,
ticker="KXBTC-24JAN15",
limit=50,
min_ts=1704067200,
max_ts=1735689600,
)
await markets.get_market_candlesticks(
client,
"KXBTC-24JAN15",
series_ticker="KXBTC",
period_interval=60,
limit=100,
)
await markets.get_live_data(client, "KXBTC-24JAN15")
await markets.get_multiple_live_data(client, "KXBTC-24JAN15,INXD-25")
await markets.get_series(client, "KXBTC")
await markets.get_series_list(client, limit=20) # cursor= for pagination
# Events — filters: series_ticker, status, with_nested_markets, with_milestones, min_close_ts
evs = await events.get_events(
client,
limit=20,
status="open",
series_ticker="KXBTC",
with_nested_markets=True,
)
ev = await events.get_event(client, "INXD-25", with_nested_markets=True)
await events.get_event_metadata(client, "INXD-25")
await events.get_event_candlesticks(
client, "KXBTC", "INXD-25", period_interval=60, limit=100
)
await events.get_multivariate_events(client, limit=10)
# Search (no auth)
await search.get_sports_filters(client)
await search.get_tags_by_categories(client)
# Orders (auth) — filters: ticker, event_ticker, status, min_ts, max_ts, cursor, subaccount
ords = await orders.get_orders(
client, ticker="KXBTC-24JAN15", status="resting", limit=50
)
o = await orders.get_order(client, "order-id")
await orders.create_order(
client,
ticker="KXBTC-24JAN15",
side="yes",
action="buy",
count=1,
yes_price=50,
time_in_force="good_till_canceled",
)
await orders.cancel_order(client, "order-id")
await orders.amend_order(
client, "order-id", ticker="KXBTC-24JAN15", side="yes", action="buy", yes_price=55
)
await orders.decrease_order(client, "order-id", reduce_by=1)
await orders.batch_create_orders(
client,
[{"ticker": "KXBTC-24JAN15", "side": "yes", "action": "buy", "count": 1, "yes_price": 50}],
)
await orders.batch_cancel_orders(client, order_ids=["id1", "id2"])
# Portfolio (auth) — filters: ticker, event_ticker, min_ts, max_ts, cursor, subaccount
await portfolio.get_portfolio(client)
bal = await portfolio.get_balance(client)
pos = await portfolio.get_positions(
client, ticker="KXBTC-24JAN15", limit=100
)
await portfolio.get_fills(
client,
ticker="KXBTC-24JAN15",
min_ts=1704067200,
max_ts=1735689600,
limit=50,
)
await portfolio.get_settlements(
client, event_ticker="INXD-25", limit=50
)
await portfolio.get_total_resting_order_value(client)Full request/response docs for every method (exchange, markets, events, search, orders, portfolio):
API_REFERENCE.md
The examples/ directory has standalone scripts that use kyro. They are not part of the library.
From repo root with kyro installed (venv activated, pip install -e . or .[dev]):
-
fetch_orderbook_example.py— Fetches an event, a market, and an orderbook; parses the book (best bid/ask, mid, spread). Uses the demo API by default (no keys); production may require auth.python examples/fetch_orderbook_example.py # production: KALSHI_PRODUCTION=1 in .env, or: KALSHI_PRODUCTION=1 python examples/fetch_orderbook_example.py
All exceptions inherit from KyroError. Use the specific types to branch on API errors, timeouts, connection failures, or validation (Pydantic) issues:
from kyro import RestClient, KyroConfig
from kyro.rest import markets
from kyro import (
KyroError,
KyroHTTPError,
KyroConnectionError,
KyroTimeoutError,
KyroValidationError,
)
async with RestClient(KyroConfig()) as client:
try:
await markets.get_market(client, "NONEXISTENT-TICKER")
except KyroHTTPError as e:
# e.status, e.response_body, e.error_code — all set from the Kalshi response
if e.status == 404:
print("Not found:", e.error_code)
elif e.status in (401, 403):
print("Auth failed:", e.response_body)
else:
print(e)
except KyroConnectionError:
print("Network error (DNS, connection refused, etc.)")
except KyroTimeoutError as e:
print("Request timed out", e.timeout)
except KyroValidationError as e:
print("Invalid request/response:", e.details)Real tracebacks from a run. Each exception carries the relevant attributes (e.status, e.response_body, e.error_code, e.timeout, e.details)—branch or log right away, no parsing.
KyroHTTPError (4xx/5xx from Kalshi):
Traceback (most recent call last):
File "app/main.py", line 12, in fetch_market
m = await markets.get_market(client, "NONEXISTENT-TICKER")
File "kyro/rest/api/markets.py", line 65, in get_market
return await client.get(f"/markets/{ticker}")
File "kyro/rest/client.py", line 134, in _request
raise KyroHTTPError("Kalshi API error", status=status, response_body=parsed, error_code=err_code)
kyro.exceptions.KyroHTTPError: Kalshi API error: status=404, error_code='MarketNotFound', response_body="{'code': 'MarketNotFound', 'message': 'Market not found'}"KyroTimeoutError (request exceeded request_timeout):
Traceback (most recent call last):
File "app/main.py", line 8, in main
await markets.get_markets(client, limit=100)
File "kyro/rest/api/markets.py", line 59, in get_markets
return await client.get("/markets", params=params or None)
File "kyro/rest/client.py", line 119, in _request
raise KyroTimeoutError(str(e) or "Request timed out", timeout=30.0) from e
kyro.exceptions.KyroTimeoutError: Request timed outKyroConnectionError (DNS, connection refused, etc.):
Traceback (most recent call last):
File "app/main.py", line 7, in main
await exchange.get_exchange_status(client)
File "kyro/rest/api/exchange.py", line 19, in get_exchange_status
return await client.get("/exchange/status")
File "kyro/rest/client.py", line 130, in _request
raise KyroConnectionError(str(e)) from e
kyro.exceptions.KyroConnectionError: Cannot connect to host demo-api.kalshi.co:443 ssl:True [Connection refused]KyroValidationError (Pydantic schema mismatch, invalid JSON, or bad request body):
Traceback (most recent call last):
File "app/main.py", line 9, in main
m = await client.get("/markets/KXBTC", response_model=Market)
File "kyro/rest/client.py", line 139, in _request
return loads_model(raw, response_model)
File "kyro/_serialization.py", line 110, in loads_model
raise KyroValidationError(f"Validation failed for {model.__name__}: {e}", details=e.errors()) from e
kyro.exceptions.KyroValidationError: Validation failed for Market: 1 validation error for Market
ticker
Field required [type=missing, input_value={}, input_type=dict]kyro/
├── src/kyro/
│ ├── __init__.py
│ ├── _auth.py # config_from_env, request signing
│ ├── _config.py
│ ├── _session.py
│ ├── _serialization.py
│ ├── _version.py
│ ├── exceptions.py # KyroError, KyroHTTPError, KyroTimeoutError, KyroConnectionError, KyroValidationError
│ └── rest/
│ ├── __init__.py # RestClient, exchange, markets, events, search, orders, portfolio
│ ├── client.py
│ └── api/
│ ├── exchange.py
│ ├── markets.py
│ ├── events.py
│ ├── search.py
│ ├── orders.py
│ └── portfolio.py
├── benchmarks/ # pytest-benchmark: serialization, REST client vs local mock
│ ├── conftest.py # bench_config, mock server fixture
│ ├── mock_server.py # Kalshi-like mock for benchmarks
│ ├── bench_serialization.py
│ └── bench_rest_client.py
├── examples/
│ ├── README.md
│ └── fetch_orderbook_example.py
├── scripts/
│ └── live_api_smoke.py # smoke test every endpoint against live API
├── tests/
├── pyproject.toml
├── README.md
├── API_REFERENCE.md # Request/response docs for every modular method
└── TESTING.md
Create a venv, install with dev extras, then run tests (required on Homebrew Python; see PEP 668):
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev]" # install only; does not run tests
ruff check . # lint
black --check . # format check (black . to fix)
pytest tests/ -v # run testsTests: See TESTING.md. Quick runs (venv activated, .[dev] already installed):
pytest tests/ -v
pytest tests/ -v --cov=kyro --cov-report=term-missingBenchmarks (serialization + REST client vs a local mock Kalshi server; no live API or auth):
pip install -e ".[dev,bench]"
pytest benchmarks/ -v --benchmark-onlySee benchmarks/README.md for the mock server and options.
Live API smoke (every endpoint against the real Kalshi API): python scripts/live_api_smoke.py — see TESTING.md.
If pip install -e ".[dev]" fails with externally-managed-environment, create and activate a venv first; do not use --break-system-packages.
The author accepts no responsibility for any use of this software. Kyro is provided as-is. You must adhere to all Kalshi API rules and terms. When trading or using live funds, use caution and understand the risks. Prefer the demo environment for testing.
MIT