< Back to Blog

How to Query Kalshi's Metadata API for Correct Settlement Specifications

TL;DR / Key Takeaways

  • Kalshi weather contracts do NOT always settle on the airport you think. Chicago is Midway, not O'Hare. Houston is Hobby, not Bush.
  • The correct settlement station for every market is in the Kalshi metadata API. Query it. Don't guess.
  • The /events and /markets endpoints give you everything you need. I'll show you exactly how to call them.
  • The code below produces a complete station-to-market mapping you can pin in your test suite and trust.

I burned trades getting this wrong. I'm going to save you the same pain.

When I audited 112 completed trades from the v2.1 Weather Bot, one of the 7 defects I found was using the wrong settlement airports. Chicago contracts settle on Midway (KMDW), not O'Hare (KORD). Houston settles on Hobby (KHOU), not Bush Intercontinental (KIAH). Those are different weather stations with meaningfully different temperature readings. I was pulling forecasts for the wrong physical location and had no idea.

The fix was embarrassingly simple: query the Kalshi metadata API instead of assuming.

This post shows you exactly how to do that. Every code block here runs against the real Kalshi API. Copy it, plug in your credentials, and you'll have a verified station list in under 5 minutes.


Why You Can't Guess at Settlement Specs

Prediction markets are precise. The contract says what it says, and settlement happens exactly according to the listed specification. If your forecast is for the wrong station, you are not trading the market. You are trading your misunderstanding of the market.

The intuitive airport is often wrong because Kalshi uses stations with longer, more complete historical records, or stations that are managed directly by ASOS (Automated Surface Observing Systems) with higher data reliability. Midway has been an ASOS station longer than O'Hare in the records that matter for settlement. That's not obvious from the outside.

You can try to look it up manually on the Kalshi website. You'll get it right most of the time. But "most of the time" in a trading context means you have an untested assumption running in production. That's how bugs hide for months.

The API gives you the exact specification the exchange is using. Use it.


Authentication First

Kalshi uses RSA-PSS authentication. You generate a key pair, register the public key with your Kalshi account, and sign each API request with the private key. There's no API key string to paste in.

I won't reproduce the full auth setup here because Kalshi's docs cover it and it changes occasionally. But here's the signing utility I use in both bots:

import base64
import time
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend


def load_private_key(key_path: str):
    with open(key_path, "rb") as f:
        return serialization.load_pem_private_key(
            f.read(), password=None, backend=default_backend()
        )


def build_auth_headers(method: str, path: str, key_id: str, private_key) -> dict:
    """
    Returns signed headers for a Kalshi REST request.
    method: 'GET', 'POST', etc.
    path: the URL path only, e.g. '/trade-api/v2/events'
    """
    timestamp_ms = str(int(time.time() * 1000))
    message = timestamp_ms + method.upper() + path

    signature = private_key.sign(
        message.encode("utf-8"),
        padding.PSS(
            mgf=padding.MGF1(hashes.SHA256()),
            salt_length=padding.PSS.DIGEST_LENGTH,
        ),
        hashes.SHA256(),
    )

    return {
        "KALSHI-ACCESS-KEY": key_id,
        "KALSHI-ACCESS-TIMESTAMP": timestamp_ms,
        "KALSHI-ACCESS-SIGNATURE": base64.b64encode(signature).decode("utf-8"),
        "Content-Type": "application/json",
    }

Set two environment variables before running anything:

export KALSHI_KEY_ID="your-key-id-from-kalshi-dashboard"
export KALSHI_PRIVATE_KEY_PATH="/path/to/your/private_key.pem"

The Two Endpoints You Need

/trade-api/v2/events

This is where you get the list of weather events. An "event" in Kalshi's data model is the parent container. For weather markets, each city/date combination is typically one event, with multiple markets under it (above 85F, above 90F, above 95F, etc.).

The event title and subtitle usually contain the city name. The settlement details live one level down in the markets.

import os
import requests

BASE_URL = "https://trading-api.kalshi.com"


def get_weather_events(private_key, key_id: str, limit: int = 200) -> list:
    """
    Returns all active weather-related events from Kalshi.
    Paginates automatically until no cursor is returned.
    """
    path = "/trade-api/v2/events"
    params = {
        "limit": limit,
        "status": "open",
        "series_ticker": "KXHIGH",  # High temperature series
    }

    all_events = []
    cursor = None

    while True:
        if cursor:
            params["cursor"] = cursor

        headers = build_auth_headers("GET", path, key_id, private_key)
        response = requests.get(
            BASE_URL + path, headers=headers, params=params, timeout=10
        )
        response.raise_for_status()
        data = response.json()

        events = data.get("events", [])
        all_events.extend(events)

        cursor = data.get("cursor")
        if not cursor or not events:
            break

    return all_events

A note on series_ticker: Kalshi organizes weather temperature markets under KXHIGH for daily high temperature. If you're trading low temperature markets, use KXLOW. If you leave this parameter off, you'll get everything including non-weather events and you'll have to filter client-side. I prefer the server-side filter.

/trade-api/v2/markets

The market-level endpoint is where the settlement specification actually lives. Each market has a market_type field and, more importantly, the rules_primary field which contains the full settlement specification as a human-readable string.

That string is where the station ticker appears.

def get_markets_for_event(
    private_key, key_id: str, event_ticker: str
) -> list:
    """
    Returns all markets under a specific event ticker.
    """
    path = "/trade-api/v2/markets"
    params = {
        "event_ticker": event_ticker,
        "status": "open",
    }

    headers = build_auth_headers("GET", path, key_id, private_key)
    response = requests.get(
        BASE_URL + path, headers=headers, params=params, timeout=10
    )
    response.raise_for_status()
    data = response.json()

    return data.get("markets", [])

Pulling the Station Identifier

The settlement station is embedded in the market ticker itself and in the rules text. Market tickers for weather follow a pattern like:

KXHIGH-26AUG26-KMDW-T85

Breaking that down:

  • KXHIGH = high temperature series
  • 26AUG26 = settlement date
  • KMDW = the ICAO station code (Midway)
  • T85 = the temperature threshold (85F)

The station code is the third segment. You can parse it directly from the ticker string without even reading the rules text. But I always cross-reference against the rules text anyway because the ticker format has changed before.

import re


def extract_station_from_ticker(market_ticker: str) -> str | None:
    """
    Extracts the ICAO station code from a Kalshi weather market ticker.
    Returns None if the ticker doesn't match the expected pattern.

    Examples:
        KXHIGH-26AUG26-KMDW-T85  -> KMDW
        KXHIGH-26AUG26-KHOU-T90  -> KHOU
    """
    parts = market_ticker.split("-")
    if len(parts) >= 3:
        candidate = parts[2]
        # ICAO codes for US stations start with K and are 4 characters
        if re.match(r"^K[A-Z]{3}$", candidate):
            return candidate
    return None


def extract_station_from_rules(rules_text: str) -> str | None:
    """
    Fallback: scan the rules text for a 4-letter ICAO code starting with K.
    Less reliable than ticker parsing but useful for cross-checking.
    """
    matches = re.findall(r"\bK[A-Z]{3}\b", rules_text or "")
    # Return the first match; rules text typically mentions the station once
    return matches[0] if matches else None

The Full Pipeline: Build Your Station Map

This is the complete script that produces a verified station map for all open Kalshi weather markets. Run it once to build the map, then pin the output in your test suite so any future change to settlement specs breaks a test loudly instead of silently corrupting your trades.

import json
import os
from collections import defaultdict


def build_station_map(private_key, key_id: str) -> dict:
    """
    Queries Kalshi API and returns a dict mapping ICAO station codes
    to the list of event tickers that settle on that station.

    Output format:
    {
        "KMDW": ["KXHIGH-26AUG26-KMDW", ...],
        "KHOU": ["KXHIGH-26AUG26-KHOU", ...],
        ...
    }
    """
    print("Fetching weather events from Kalshi...")
    events = get_weather_events(private_key, key_id)
    print(f"Found {len(events)} events.")

    station_map = defaultdict(list)
    inconsistencies = []

    for event in events:
        event_ticker = event.get("event_ticker", "")
        markets = get_markets_for_event(private_key, key_id, event_ticker)

        for market in markets:
            market_ticker = market.get("ticker", "")
            rules_text = market.get("rules_primary", "")

            ticker_station = extract_station_from_ticker(market_ticker)
            rules_station = extract_station_from_rules(rules_text)

            # Cross-check: ticker and rules text should agree
            if ticker_station and rules_station and ticker_station != rules_station:
                inconsistencies.append(
                    {
                        "market": market_ticker,
                        "ticker_station": ticker_station,
                        "rules_station": rules_station,
                    }
                )
                print(f"  WARNING: Station mismatch on {market_ticker}")
                print(f"    Ticker says: {ticker_station}")
                print(f"    Rules say:  {rules_station}")

            station = ticker_station or rules_station
            if station and event_ticker not in station_map[station]:
                station_map[station].append(event_ticker)

    return dict(station_map), inconsistencies


def main():
    key_id = os.environ["KALSHI_KEY_ID"]
    key_path = os.environ["KALSHI_PRIVATE_KEY_PATH"]
    private_key = load_private_key(key_path)

    station_map, inconsistencies = build_station_map(private_key, key_id)

    print("\n--- Station Map ---")
    for station, events in sorted(station_map.items()):
        print(f"{station}: {len(events)} active markets")

    if inconsistencies:
        print(f"\n{len(inconsistencies)} inconsistencies found. Review before trading.")
    else:
        print("\nNo inconsistencies. Ticker and rules text agree on all stations.")

    # Write to file so you can pin it in tests
    output_path = "kalshi_station_map.json"
    with open(output_path, "w") as f:
        json.dump(station_map, f, indent=2)
    print(f"\nStation map written to {output_path}")


if __name__ == "__main__":
    main()

Running this against the live API takes about 30 seconds depending on how many open markets there are. On an average day I see 291 weather markets across roughly 15-20 stations.


Pin It in Your Tests

Building the map once isn't enough. Markets change. Kalshi occasionally adds new cities or changes station assignments. The station map you pull today might be wrong in 6 months.

The right pattern is:

  1. Run the builder script periodically (I run it weekly as a cron job).
  2. Diff the output against the previous version. Any change triggers a manual review.
  3. Pin the known correct stations in your test suite so any misalignment fails loudly.
# In your test file
import json
import pytest

KNOWN_STATION_ASSIGNMENTS = {
    "KMDW": "Chicago",      # Midway, NOT O'Hare
    "KHOU": "Houston",      # Hobby, NOT Bush
    "KLAX": "Los Angeles",
    "KJFK": "New York",
    "KORD": None,           # O'Hare is NOT a Kalshi settlement station
    "KIAH": None,           # Bush is NOT a Kalshi settlement station
}


def test_wrong_airports_not_in_station_map():
    """
    Confirm that the commonly-assumed-but-wrong airports are not
    in our active station map.
    """
    with open("kalshi_station_map.json") as f:
        station_map = json.load(f)

    for station, city in KNOWN_STATION_ASSIGNMENTS.items():
        if city is None:
            assert station not in station_map, (
                f"{station} should NOT be a settlement station "
                f"but was found in the map."
            )
        else:
            assert station in station_map, (
                f"{station} ({city}) should be a settlement station "
                f"but was not found in the map."
            )

This test has saved me three times. Once when I was tempted to hardcode O'Hare because it was the first result in a web search. Once when I found an old config file from before the audit that still had the wrong stations. Once when a colleague suggested using the "main" airport for each city as a sensible default.

There is no sensible default. There is only what the exchange actually uses.


The Rate Limit Situation

Kalshi's API returns 429 if you paginate too aggressively. The station map builder above doesn't have rate limiting built in because it's a one-shot script, but if you're running this inside a bot loop, add a sleep between paginated calls:

import time

# Between paginated calls inside any loop:
time.sleep(0.3)

0.3 seconds is enough. I haven't seen a 429 since adding it.


What the Response Actually Looks Like

In case you want to inspect the raw structure before writing your own parser:

def inspect_market(private_key, key_id: str, market_ticker: str):
    """Prints the full market response for one ticker. Useful for debugging."""
    path = f"/trade-api/v2/markets/{market_ticker}"
    headers = build_auth_headers("GET", path, key_id, private_key)
    response = requests.get(BASE_URL + path, headers=headers, timeout=10)
    response.raise_for_status()
    print(json.dumps(response.json(), indent=2))

Run that on one of your target markets and look at the rules_primary field. The settlement station, the settlement time, and the data source are all described there in plain English. Reading it once per market type is worth your time. You'll find things the ticker alone doesn't tell you.


What This Won't Do

This script tells you the settlement station. It doesn't tell you whether the market is mispriced. It doesn't tell you whether your forecast source covers that station. It doesn't validate that your forecast is using the same temperature definition (daily high in Local Standard Time, which is its own bug story).

Those are separate problems. This one step, getting the station right, is just the minimum viable foundation. The rest of the accuracy work sits on top.

The Weather Bot is rebuilt and undergoing validation in paper-trading mode. This metadata pipeline is one of the pieces that makes the rebuilt version trustworthy where the old one was not. Knowing exactly which physical sensor your contract settles on is not optional. It's the first thing you get right.


The full source code for both bots, including the metadata validation pipeline, ships in the $75 package at predictandprofit.io. If you just want to run the station mapper standalone, everything you need is in this post.