Kalshi API Getting Started: Python, Order Books & Authentication

by Prediction Market Tools

Fetch Kalshi market data without an API key, paginate results and read a YES/NO order book. Start with a runnable Python script, then learn where authentication and historical endpoints fit.


Public documentation checked and the read-only Python example exercised against Kalshi on September 29, 2026. Authenticated account access and order entry were not tested. Sources, review limits and corrections.

1. Make your first public request

You do not need an API key to read the public market and order-book endpoints used here. The production base URL is https://external-api.kalshi.com/trade-api/v2. Start with a small request:

curl --fail --max-time 15 'https://external-api.kalshi.com/trade-api/v2/markets?series_ticker=KXHIGHNY&status=open&limit=5'

The response contains a markets array and may include a cursor. KXHIGHNY is the New York daily high-temperature series used in Kalshi’s own quickstart. An empty array means there are no matching open markets at that moment; choose another current series rather than treating missing quotes as zero.

2. Run the Python quickstart

Download kalshi_quickstart.py. It uses Python 3.10+ and the standard library, with no packages or keys to install. Save it, inspect its source below, then run:

python3 kalshi_quickstart.py --series KXHIGHNY --pages 2

The script requests at most two pages of five markets, removes duplicate tickers, reads one order book and prints the current historical cutoffs. Increase --pages up to five for a bounded experiment. more_available: true explicitly means the result is incomplete. This is a learning sample, not an exhaustive data download.

A collector should persist its cursor and deduplication key with each completed batch. Keep the original filters when adding the returned cursor. Our sample stops on a repeated cursor instead of looping, and has a timeout on every request.

3. Understand prices and available depth

Read orderbook_fp.yes_dollars and no_dollars as arrays of price/quantity pairs. Both values are decimal strings. Use decimal arithmetic rather than interpreting "0.4200" as 42 dollars or rounding fractional quantities to integers.

Kalshi returns bids on each outcome. For a binary dollar contract, the implied YES ask is $1 minus the best NO bid. If the best YES bid is $0.42 and the best NO bid is $0.56, the YES ask is $0.44 and the spread is $0.02. The quantity at that NO bid is the displayed quantity behind the implied YES ask.

Our script selects the highest bid, ignores zero-sized levels, rejects non-finite values and returns null for an empty side. A negative spread deserves investigation; do not silently convert it into an arbitrage signal. Quotes can move before execution, and a top-of-book price does not describe the cost of a larger order.

4. Know when authentication becomes necessary

Private portfolio endpoints and order entry require authentication. Kalshi documents three headers: KALSHI-ACCESS-KEY, KALSHI-ACCESS-TIMESTAMP and KALSHI-ACCESS-SIGNATURE. The signature covers the timestamp in milliseconds, the HTTP method and the path, including /trade-api/v2 but excluding the query string.

Use the signing algorithm that matches your registered key: current documentation covers Ed25519 and RSA-PSS with SHA-256. A PEM header alone is insufficient to distinguish them. Check SDK compatibility before selecting a key type, keep the private key on your server and follow the official authenticated quickstart for your environment. This example deliberately contains no signing or order-submission code.

5. Route old data to the historical API

Call GET /historical/cutoff before planning a historical download. Markets, trades, orders and positions have different cutoff fields. An older settled market absent from /markets may be available through /historical/markets; that absence is not proof it never existed.

Use the cutoff matching your dataset and paginate the corresponding historical endpoint. If a requested time range crosses the boundary, combine both datasets using stable identifiers. Preserve the original timestamps, contract rules and raw values so you can audit later transformations.

Common integration failures

  • 401 on a private request: check the environment, key ID, key algorithm, clock and exact signed path. Repeating the same invalid signature will not help.
  • 429: stop and respect Retry-After when supplied. For a collector, add bounded backoff with jitter and an overall retry budget. Do not assume one permanent requests-per-second allowance for every tier or endpoint.
  • Missing older records: inspect the relevant historical cutoff and both cursor chains.
  • Unexpected schema: stop the calculation, retain a redacted response and check the changelog. Missing fields should not become zero-valued trades.
  • Timeout during an order request: the outcome can be unknown. Reconcile by client/order identifier before considering a retry; a blind retry can create duplicate exposure.

Build on a working data adapter

Next, use the offline prediction-market bot tutorial to test stale quotes, depth limits and a kill switch without connecting an account. For Polymarket discovery and streaming, use our cross-platform API examples. Keep live-data ingestion separate from the strategy and execution layers.

Runnable example source

Download kalshi_quickstart.py
Read kalshi_quickstart.py
#!/usr/bin/env python3
"""Kalshi public data quickstart. Python 3.10+, no dependencies or credentials.
python3 kalshi_quickstart.py --series KXHIGHNY --pages 2
At most five market-list pages, one order book and one historical cutoff request.
No account access, signing, order submission, retries or background polling.
"""
import argparse
import json
import sys
from decimal import Decimal, InvalidOperation
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode, quote
from urllib.request import Request, urlopen

BASE = 'https://external-api.kalshi.com/trade-api/v2'


def get_json(route, params=None):
    url = BASE + route + ('?' + urlencode(params) if params else '')
    request = Request(url, headers={'Accept': 'application/json', 'User-Agent': 'PMTools-quickstart/1.0'})
    try:
        with urlopen(request, timeout=15) as response:
            data = json.load(response)
    except HTTPError as error:
        if error.code == 429:
            raise ValueError('Rate limited; Retry-After: ' + str(error.headers.get('Retry-After', 'not supplied')) + '. Stop and wait before retrying.') from error
        raise ValueError(f'HTTP {error.code} from {route}; check the current API documentation.') from error
    except (URLError, TimeoutError, json.JSONDecodeError) as error:
        raise ValueError(f'Network or JSON error: {error}') from error
    if not isinstance(data, dict):
        raise ValueError('Expected a JSON object; response schema may have changed.')
    return data


def best_bid(levels):
    """Do not rely on array ordering or turn an empty book into a zero quote."""
    if not isinstance(levels, list):
        raise ValueError('Expected an array of order-book levels.')
    parsed = []
    for level in levels:
        if not isinstance(level, list) or len(level) != 2:
            raise ValueError('Expected [price_dollars, count_fp].')
        price, size = (Decimal(str(value)) for value in level)
        if not price.is_finite() or not size.is_finite() or not 0 <= price <= 1 or size < 0:
            raise ValueError('Invalid order-book price or size.')
        if size > 0:
            parsed.append((price, size))
    return max(parsed, key=lambda level: level[0]) if parsed else None


def summarize_book(data):
    book = data['orderbook_fp']
    yes = best_bid(book['yes_dollars'])
    no = best_bid(book['no_dollars'])
    ask = Decimal('1') - no[0] if no else None
    return {
        'yes_bid_dollars': str(yes[0]) if yes else None,
        'yes_bid_contracts': str(yes[1]) if yes else None,
        'yes_ask_dollars': str(ask) if ask is not None else None,
        'yes_ask_contracts': str(no[1]) if no else None,
        'spread_dollars': str(ask - yes[0]) if ask is not None and yes else None,
        'note': 'YES ask = $1 minus best NO bid. Snapshot quantities are not guaranteed fills.',
    }


def collect(series, pages, request=get_json):
    cursor, seen_cursors, seen_tickers, rows = '', set(), set(), []
    for _ in range(pages):
        params = {'series_ticker': series, 'status': 'open', 'limit': 5}
        if cursor:
            params['cursor'] = cursor
        data = request('/markets', params)
        if not isinstance(data.get('markets'), list):
            raise ValueError('Missing markets array.')
        for market in data['markets']:
            ticker = market['ticker']
            if ticker not in seen_tickers:
                seen_tickers.add(ticker)
                rows.append({key: market.get(key) for key in ['ticker', 'title', 'status', 'yes_bid_dollars', 'yes_ask_dollars', 'volume_fp']})
        cursor = data.get('cursor') or ''
        if not isinstance(cursor, str):
            raise ValueError('Expected a string cursor.')
        if not cursor:
            break
        if cursor in seen_cursors:
            raise ValueError('Repeated cursor; stopping to avoid a pagination loop.')
        seen_cursors.add(cursor)
    result = {'markets': rows, 'more_available': bool(cursor), 'historical_cutoff': request('/historical/cutoff')}
    if rows:
        ticker = rows[0]['ticker']
        result['orderbook'] = {'ticker': ticker, **summarize_book(request('/markets/' + quote(ticker, safe='') + '/orderbook'))}
    else:
        result['note'] = 'No open markets for this series. Choose a current series; empty does not mean zero price.'
    return result


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--series', default='KXHIGHNY')
    parser.add_argument('--pages', type=int, choices=range(1, 6), default=2)
    args = parser.parse_args()
    try:
        print(json.dumps(collect(args.series, args.pages), indent=2))
    except (ValueError, InvalidOperation, KeyError, TypeError) as error:
        print('Stopped: ' + str(error), file=sys.stderr)
        sys.exit(1)

Sources & references

Primary sources used for the figures, mechanics, and regulatory statements in this guide. Where a fact is time-bound, the source date is shown — verify the latest version before relying on it for trading or compliance decisions.

  1. Public market-data quickstart — Kalshi
  2. Order-book response format — Kalshi
  3. API keys and request signing — Kalshi
  4. Historical data and cutoffs — Kalshi
  5. Rate limits and tiers — Kalshi

Related Articles

Learn More