PREDICTION MARKET TOOLS

Polymarket & Kalshi APIs: Python, Historical Data and WebSocket Examples

Runnable read-only Python examples for Polymarket and Kalshi market data, plus a Polymarket WebSocket sample and historical-data guidance.

Examples checked against public endpoints on September 29, 2026. They read market data only: no account keys, wallet access or order submission. Response values change continuously; the examples do not establish trading eligibility or execution quality.

Choose the right data source

Use Polymarket’s market-discovery interface to find events and outcome IDs, then the appropriate order-book or streaming interface for price updates. On Kalshi, public REST market endpoints support unauthenticated research. Private portfolio data, order entry and Kalshi WebSockets have separate authentication requirements.

For a shared schema across venues, compare OddsPipe and Oddpool. Normalization saves integration work but does not make two contracts equivalent.

Python: public market data without dependencies

Save the script and run it with Python 3.10 or later. Select polymarket, kalshi or kalshi-history. It handles an empty result, HTTP errors, timeouts and rate-limit messages.

Download market_data.py
python3 market_data.py polymarket
python3 market_data.py kalshi
python3 market_data.py kalshi-history
Read the Python source
#!/usr/bin/env python3
"""Read-only market data examples. Python 3.10+, standard library only.
Run: python3 market_data.py polymarket|kalshi|kalshi-history
No credentials, wallet access, order creation or account endpoints.
"""
import argparse
import json
import sys
from decimal import Decimal, InvalidOperation
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

ENDPOINTS = {
    'polymarket': 'https://gamma-api.polymarket.com/markets?limit=5&active=true&closed=false&order=volume24hr&ascending=false',
    'kalshi': 'https://external-api.kalshi.com/trade-api/v2/markets?limit=5&status=open&series_ticker=KXHIGHNY',
    'kalshi-history': 'https://external-api.kalshi.com/trade-api/v2/historical/cutoff',
}

def read_json(url):
    request = Request(url, headers={'Accept': 'application/json', 'User-Agent': 'PMTools-read-only-example/1.0'})
    try:
        with urlopen(request, timeout=20) as response:
            return json.load(response)
    except HTTPError as error:
        if error.code == 429:
            raise RuntimeError('Rate limited. Wait for Retry-After: ' + str(error.headers.get('Retry-After', 'unspecified')) + '; do not retry in a tight loop.') from error
        raise RuntimeError(f'HTTP {error.code}; check the endpoint and current venue documentation.') from error
    except (URLError, TimeoutError, json.JSONDecodeError) as error:
        raise RuntimeError(f'Network or response error: {error}') from error


def array(value):
    return json.loads(value) if isinstance(value, str) else value


def main(venue):
    data = read_json(ENDPOINTS[venue])
    if venue == 'kalshi-history':
        if not isinstance(data, dict):
            raise RuntimeError('Unexpected cutoff response.')
        print(json.dumps(data, indent=2))
        print('Use the cutoff for the required data type, then query the matching /historical endpoint.')
        return
    rows = data if venue == 'polymarket' else data.get('markets', [])
    if not isinstance(rows, list):
        raise RuntimeError('Unexpected market-list response; check for a schema change.')
    if not rows:
        print('No matching markets. An empty set is not a price of zero.')
    for market in rows:
        if venue == 'polymarket':
            outcomes, prices = array(market.get('outcomes', [])), array(market.get('outcomePrices', []))
            if len(outcomes) != len(prices):
                raise RuntimeError('Outcome labels and prices have different lengths.')
            print(market['id'], market['question'])
            for label, price in zip(outcomes, prices):
                print(f'  {label}: {Decimal(str(price)) * 100}% quoted probability')
            print('  Updated:', market.get('updatedAt', 'unknown'))
        else:
            print(market['ticker'], market['title'])
            # _dollars values are decimal strings, not legacy integer cents.
            bid, ask = market.get('yes_bid_dollars'), market.get('yes_ask_dollars')
            print('  YES bid/ask ($):', bid, '/', ask)
            print('  A zero/missing side can mean no quote; inspect the order book.')
    if venue == 'kalshi' and data.get('cursor'):
        print('More results available: pass the returned cursor in the next request.')
    print('Snapshot only. Quotes are not guaranteed fills.')

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('venue', choices=ENDPOINTS)
    args = parser.parse_args()
    try:
        main(args.venue)
    except (RuntimeError, KeyError, TypeError, InvalidOperation, json.JSONDecodeError) as error:
        print(str(error), file=sys.stderr)
        sys.exit(1)

Polymarket’s outcome labels and prices can arrive as JSON-encoded strings. Parse them and preserve their order. Kalshi’s _dollars fields are decimal strings, not integer cents. A zero or missing quote is not evidence that you can execute at zero.

Kalshi historical data: check the cutoff first

Kalshi separates current data from archived datasets. Read /historical/cutoff, select the cutoff field for the data you need, and route older records to the matching historical endpoint. Do not assume the same cutoff for trades, settled markets, orders and positions.

The example prints current cutoff metadata. It does not download a complete historical dataset. Follow cursor pagination, retain timestamps, deduplicate across boundaries and check access requirements on each endpoint. Missing records in a live endpoint do not necessarily mean the data never existed.

Official historical-data routing guide ↗

Polymarket WebSocket example

This Node.js 22+ example discovers a live outcome token, subscribes to its public market stream and sends a heartbeat every ten seconds. It prints up to three messages or stops after thirty seconds. It is a bounded sample, not a production feed collector.

Download polymarket_stream.mjs
node polymarket_stream.mjs
Read the WebSocket source
// Read-only public market stream. Run with Node.js 22+: node polymarket_stream.mjs
// No wallet, credentials or orders. Prints at most three messages, then exits.
const response = await fetch(
  'https://gamma-api.polymarket.com/markets?limit=5&active=true&closed=false&order=volume24hr&ascending=false',
  { signal: AbortSignal.timeout(15000) },
);
if (!response.ok)
  throw new Error(`Market discovery failed: HTTP ${response.status}`);
const markets = await response.json();
const market = markets.find((row) => row.clobTokenIds && row.acceptingOrders);
if (!market) throw new Error('No suitable open market returned. Retry later.');
const ids =
  typeof market.clobTokenIds === 'string'
    ? JSON.parse(market.clobTokenIds)
    : market.clobTokenIds;
if (!Array.isArray(ids) || !ids[0])
  throw new Error('Missing outcome token ID.');
console.log('Market:', market.question, '| first outcome token:', ids[0]);
const socket = new WebSocket(
  'wss://ws-subscriptions-clob.polymarket.com/ws/market',
);
let heartbeat,
  messages = 0;
const stop = () => {
  clearInterval(heartbeat);
  clearTimeout(deadline);
  socket.close();
};
const deadline = setTimeout(() => {
  console.log('30-second sample complete. Messages:', messages);
  stop();
}, 30000);
socket.addEventListener('open', () => {
  socket.send(JSON.stringify({ assets_ids: [ids[0]], type: 'market' }));
  heartbeat = setInterval(() => {
    if (socket.readyState === WebSocket.OPEN) socket.send('PING');
  }, 10000);
});
socket.addEventListener('message', ({ data }) => {
  if (data === 'PONG') return;
  try {
    console.log(JSON.stringify(JSON.parse(String(data))).slice(0, 1500));
  } catch {
    console.error('Unexpected non-JSON message; inspect the feed schema.');
  }
  if (++messages >= 3) stop();
});
socket.addEventListener('error', () => {
  console.error(
    'Stream error. Check network access and the documented endpoint.',
  );
  process.exitCode = 1;
  stop();
});
socket.addEventListener('close', () => {
  clearInterval(heartbeat);
  clearTimeout(deadline);
});

For a long-running collector, implement reconnect backoff, snapshot recovery, schema validation and stale-data detection. A reconnect can leave a gap. Preserve source timestamps and treat missing updates separately from an unchanged price.

Before turning a notebook into a product

  • Use decimal arithmetic for monetary fields and keep units explicit.
  • Respect rate limits, timeouts and Retry-After. Avoid tight retry loops.
  • Store the contract rules and identifiers with observations.
  • Use bid/ask and depth for execution analysis; last price is not a guaranteed fill.
  • Keep read-only research separate from trading permissions and secrets.

Go deeper

Follow the Kalshi API quickstart for bounded pagination, order-book parsing and authentication guidance. Then run the offline paper bot tutorial to exercise decision and exposure limits.

Primary documentation