The short answer

Yes, Kalshi has a public API, and Python needs only two libraries, requests and cryptography. Market data needed no keys in my runs. Account calls need three signed headers that prove a request is yours. Send your first order to the demo exchange, Kalshi’s practice exchange with fake money. Real money comes later, with a new host and new keys.

In July I dry-ran my strategy against the demo exchange’s live order books for 48 seconds, making choices but sending no orders. It produced zero quotes. The code was fine, but the market had no crowd in it, and that taught me what a demo can prove.

Five scripts from first request to first order

Selected

01_public_markets.py

GET /markets, then one market's order book, no keys

Host: production, read only. Does not reach the matching engine.

02_auth.py

Signed GET /portfolio/balance

Host: demo. Does not reach the matching engine.

03_find_market.py

series ticker to event ticker to market ticker, prices shown in cents

Host: demo. Does not reach the matching engine.

04_first_order.py

POST /portfolio/events/orders, then DELETE the same order

Host: demo. Reaches the matching engine.

05_positions.py

Signed GET /portfolio/positions

Host: demo. Does not reach the matching engine.

The highlighted step is the only one that reaches the matching engine.

Only one of the five scripts reaches the matching engine, the part of Kalshi that pairs orders, and it runs on the demo exchange.

Source: Kalshi API docs (checked 10/10/2026) and my own lab · 10/10/2026

I pasted each code block below from a script that ran on 10/10/2026, with its real output under it. All five ran on the demo exchange.

Does Kalshi have an API?

Yes. Kalshi’s help center says so in its first line. The developer docs cover a REST API, a WebSocket feed and official SDKs (software development kits).

As of 10/10/2026 the docs list two production hosts for real-money trading. The recommended one is https://external-api.kalshi.com/trade-api/v2. The shared host https://api.elections.kalshi.com/trade-api/v2 is still supported, and I used it for my July probes. My notes from then record trading-api.kalshi.com as the old host, so older tutorials may point you somewhere stale.

I keep the base URL in one constant at the top of each script, because hosts have moved.

The first script needs no account and no key. It lists open markets on the demo exchange, then prints one order book.

"""01_public_markets.py: read Kalshi market data with no account, no key and no signing.

Runs against the Kalshi DEMO exchange (practice exchange, fake money). Read only.
"""
import sys
from datetime import datetime
from decimal import Decimal
from urllib.parse import urlsplit
from zoneinfo import ZoneInfo

import requests

BASE = "https://external-api.demo.kalshi.co/trade-api/v2"
DEMO_HOSTS = ("external-api.demo.kalshi.co", "demo-api.kalshi.co")

def run_time():
    now = datetime.now(ZoneInfo("America/Los_Angeles"))
    return f"{now:%m/%d/%Y} {now.hour % 12 or 12}:{now:%M} {now:%p} PT"

def has_two_sided_quote(market):
    # The list shows an empty side as "0.0000" (bid) or "1.0000" (ask), not null.
    bid = Decimal(market["yes_bid_dollars"] or "0")
    ask = Decimal(market["yes_ask_dollars"] or "1")
    return bid > 0 and ask < 1

def main():
    host = urlsplit(BASE).hostname
    if host not in DEMO_HOSTS:
        sys.exit(f"Refusing to run: {host} is not a Kalshi demo host.")

    print("Run time:", run_time())
    print("Host:", host, "(demo)")

    # Step 1: five open markets. No key, no signature, no auth headers.
    # mve_filter=exclude skips combo (multi-leg) markets, which have no real book on demo.
    params = {"status": "open", "limit": 5, "mve_filter": "exclude"}
    response = requests.get(f"{BASE}/markets", params=params, timeout=20)
    print("GET /markets -> HTTP", response.status_code)
    for m in response.json()["markets"]:
        print(f"{m['ticker']:<44} {m['event_ticker']:<40} "
              f"yes_bid={m['yes_bid_dollars']} yes_ask={m['yes_ask_dollars']} volume_fp={m['volume_fp']}")

    # Step 2: find the first market that has a real bid AND a real ask. Page up to 5 times.
    chosen, cursor = None, ""
    for _ in range(5):
        params = {"status": "open", "limit": 1000, "mve_filter": "exclude", "cursor": cursor}
        page = requests.get(f"{BASE}/markets", params=params, timeout=20).json()
        chosen = next((m for m in page["markets"] if has_two_sided_quote(m)), None)
        cursor = page.get("cursor")
        if chosen or not cursor:
            break
    if chosen is None:
        # No two-sided market in 5 pages: take the first market and say so.
        chosen = page["markets"][0]
        print("No two-sided market in 5 pages; using the first market instead.")
    ticker = chosen["ticker"]
    print("Order book for", ticker)

    book = requests.get(f"{BASE}/markets/{ticker}/orderbook", params={"depth": 5}, timeout=20)
    print("GET /markets/{ticker}/orderbook -> HTTP", book.status_code)
    book = book.json()["orderbook_fp"]

    # Each level is [price_in_dollars, count_fp]. The API lists worst price first,
    # so sort to put the best (highest) bid on top.
    best = {}
    for side in ("yes_dollars", "no_dollars"):
        levels = sorted(book[side], key=lambda level: Decimal(level[0]), reverse=True)
        print(f"{side} (best first):")
        for price, count in levels[:5]:
            print(f"  price={price} count={count}")
        if not levels:
            print("  (no bids)")
        best[side] = Decimal(levels[0][0]) if levels else None

    # The book holds bids only. A NO bid at 0.47 is the same trade as a YES ask at 0.53.
    if best["no_dollars"] is None:
        print("Implied YES ask: none (no NO bids)")
    else:
        print("Implied YES ask:", Decimal("1") - best["no_dollars"], "(1 minus best NO bid)")

if __name__ == "__main__":
    main()

Output:

Run time: 10/10/2026 10:01 AM PT
Host: external-api.demo.kalshi.co (demo)
GET /markets -> HTTP 200
KXDEMOFUTHOURLY-26OCT10H18-UP                KXDEMOFUTHOURLY-26OCT10H18               yes_bid=0.0100 yes_ask=1.0000 volume_fp=235.00
KXITFWMATCH-26OCT10SANMEA-SAN                KXITFWMATCH-26OCT10SANMEA                yes_bid=0.0100 yes_ask=1.0000 volume_fp=0.00
KXITFWMATCH-26OCT10SANMEA-MEA                KXITFWMATCH-26OCT10SANMEA                yes_bid=0.0100 yes_ask=1.0000 volume_fp=0.00
KXNHLSAVE-26OCT10MINFLA-FLAJMARKSTROM25-24   KXNHLSAVE-26OCT10MINFLA                  yes_bid=0.0100 yes_ask=1.0000 volume_fp=0.00
KXLALIGAFIRSTGOAL-26OCT10BARGET-NOGOAL       KXLALIGAFIRSTGOAL-26OCT10BARGET          yes_bid=0.0100 yes_ask=1.0000 volume_fp=0.00
Order book for KXQUICKSETTLE-10OCT26H1310-3
GET /markets/{ticker}/orderbook -> HTTP 200
yes_dollars (best first):
  price=0.3000 count=100.00
no_dollars (best first):
  price=0.3000 count=100.00
Implied YES ask: 0.7000 (1 minus best NO bid)

Two details from that run matter for your own code. An empty side shows as a bid of "0.0000" or an ask of "1.0000", not as a blank. So the script counts a market as two-sided only when the bid is above 0 and the ask is below 1.

The docs say order book levels run from best to worst. The response I got listed them from lowest price to highest, so the script sorts them itself.

Can you use the Kalshi API from the US?

Yes. That is the reason I build on it. Kalshi’s help center says the Commodity Futures Trading Commission (CFTC), the US regulator for futures, oversees it as a Designated Contract Market. That is an exchange the CFTC has approved.

My first prediction-market bot was a Polymarket bot. But as of 10/09/2026, international Polymarket, the main exchange at polymarket.com, lists the United States as close-only on its geoblock page. That rule covers the website and the API, so a US user can close a trade they already have but cannot open a new one. A server in another country does not change where you are, so I do not route around it.

I live in the Bay Area. So when I planned my next exchange, Kalshi became my US path. I cover how the two compare for bot builders in a separate post.

Outside the US, check your country before you build. Kalshi’s help article on trading from abroad, updated 03/20/2026, says you can trade from many countries. Its Member Agreement sets the limits. Another Kalshi help page still says Kalshi is only for US residents, so read Kalshi’s eligibility page for your country first.

How do I get a Kalshi API key?

Create one in your Kalshi account under Profile Settings, in the API Keys section. The key is a key pair. You keep the private key secret, and Kalshi keeps the public key.

As of 10/10/2026 the dashboard selects Ed25519 by default, and the docs call it recommended. Kalshi still offers RSA for programs that only speak RSA-PSS.

You can also make the pair on your own machine with OpenSSL and paste only the public key into Kalshi. I did that for my demo key, because then the private key never crosses a browser or a network.

Save the private key and the Key ID right away. Kalshi does not keep your private key. Lose it and it is gone.

Demo keys are separate. Sign up for a demo account at demo.kalshi.co. Kalshi says demo and production do not share logins or keys, and its demo help article says made-up details are fine at signup. I keep my demo private key in its own file with permissions set to 600, so only my account can read it.

If you use the official SDK, check its version. The docs say Ed25519 keys need SDK version 3.31.0 or later, while RSA keys work with every version.

How does Kalshi API authentication work in Python?

You sign a short message with your private key and send the result in three headers. Every account request needs KALSHI-ACCESS-KEY, KALSHI-ACCESS-TIMESTAMP and KALSHI-ACCESS-SIGNATURE.

The message is the timestamp in milliseconds, then the method, then the path. Nothing else.

How one Kalshi request gets signed

Message

1760000000000GET/trade-api/v2/portfolio/balance

→ sign it (RSA-PSS with SHA-256 or Ed25519)

Signature

<base64 signature>

Placeholder, not a real signature. A real one is a long base64 string.

Headers

KALSHI-ACCESS-KEY
<your key ID>
KALSHI-ACCESS-TIMESTAMP
1760000000000
KALSHI-ACCESS-SIGNATURE
<base64 signature>

Step 1 of 5

Take a timestamp in milliseconds

The current Unix time in milliseconds, written as text, like 1760000000000.

Header it fillsKALSHI-ACCESS-TIMESTAMP

Step 2 of 5

Build the message

Join the timestamp, the method (such as GET) and the path. The path starts at /trade-api/v2. Drop the query string, the part after the question mark. Example message 1760000000000GET/trade-api/v2/portfolio/balance

Step 3 of 5

Sign it

An RSA key signs with RSA-PSS and SHA-256. Use MGF1 with SHA-256, and set the salt length equal to the digest length. An Ed25519 key signs the message as it is. Kalshi knows which check to run from the public key you gave it.

Step 4 of 5

Base64 encode the signature

Base64 turns the raw signature bytes into plain text that fits in a header.

Header it fillsKALSHI-ACCESS-SIGNATURE

Step 5 of 5

Send three headers

Put the Key ID, the timestamp and the signature on the request. Get any piece wrong and Kalshi refuses it.

Header it fillsKALSHI-ACCESS-KEY

A Kalshi signature covers the timestamp, the method and the path, and it travels in three headers.

Source: Kalshi API Keys docs · 10/10/2026

The message is where I look first when a request fails. Start the path at /trade-api/v2. Strip the query string before you sign. Get any piece wrong and Kalshi refuses the request, because the signature it computes will not match yours.

For RSA keys the docs specify RSA-PSS with SHA-256, MGF1 with SHA-256, and a salt as long as the digest. I copy those settings exactly as written. Ed25519 keys are simpler because they sign the message bytes directly. A dashboard key is Ed25519 by default, so script 02 checks the key type and signs either way.

My demo key is RSA, and it signs through one small Signer class. Two of my bots reuse that same class, so I only had to get signing right once. Script 02 copies its signing logic and adds an Ed25519 branch. Only the RSA branch ran against Kalshi, and a code comment says so.

"""02_auth.py: sign a Kalshi request and call an account endpoint on the DEMO exchange.

Needs two environment variables:
  KALSHI_KEY_ID    the Key ID of your DEMO API key
  KALSHI_KEY_PATH  path to your DEMO private key (a .pem file)
"""
import base64
import os
import sys
import time
from datetime import datetime
from urllib.parse import urlsplit
from zoneinfo import ZoneInfo

import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ed25519, padding

BASE = "https://external-api.demo.kalshi.co/trade-api/v2"
DEMO_HOSTS = ("external-api.demo.kalshi.co", "demo-api.kalshi.co")
PREFIX = "/trade-api/v2"  # the signed path starts here, not at the host

def run_time():
    now = datetime.now(ZoneInfo("America/Los_Angeles"))
    return f"{now:%m/%d/%Y} {now.hour % 12 or 12}:{now:%M} {now:%p} PT"

class Signer:
    def __init__(self, key_id, key_path):
        self.key_id = key_id
        with open(os.path.expanduser(key_path), "rb") as f:
            self.key = serialization.load_pem_private_key(f.read(), password=None)

    def headers(self, method, path):
        timestamp = str(int(time.time() * 1000))  # milliseconds
        message = (timestamp + method.upper() + path).encode("utf-8")
        if isinstance(self.key, ed25519.Ed25519PrivateKey):
            # Ed25519 follows docs.kalshi.com/getting_started/api_keys.
            # I did not run this branch against an Ed25519 key on Kalshi; my demo key is RSA.
            signature = self.key.sign(message)
        else:
            signature = self.key.sign(
                message,
                padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.DIGEST_LENGTH),
                hashes.SHA256(),
            )
        return {
            "KALSHI-ACCESS-KEY": self.key_id,
            "KALSHI-ACCESS-TIMESTAMP": timestamp,
            "KALSHI-ACCESS-SIGNATURE": base64.b64encode(signature).decode("ascii"),
        }

def main():
    host = urlsplit(BASE).hostname
    if host not in DEMO_HOSTS:
        sys.exit(f"Refusing to run: {host} is not a Kalshi demo host.")

    print("Run time:", run_time())
    print("Host:", host, "(demo)")
    signer = Signer(os.environ["KALSHI_KEY_ID"], os.environ["KALSHI_KEY_PATH"])

    # A correct request. Sign the path WITHOUT any query string.
    path = "/portfolio/balance"
    response = requests.get(BASE + path, headers=signer.headers("GET", PREFIX + path), timeout=20)
    print("GET /portfolio/balance -> HTTP", response.status_code)
    print("Response keys:", sorted(response.json()))  # names only, no amounts

    # Deliberate failure: the query string is left in the signed path.
    headers = signer.headers("GET", PREFIX + "/portfolio/orders?limit=1")
    response = requests.get(BASE + "/portfolio/orders", params={"limit": 1}, headers=headers, timeout=20)
    print("DELIBERATE FAILURE, GET /portfolio/orders?limit=1 signed with the query string -> HTTP", response.status_code)
    print("Error code:", response.json().get("error", {}).get("code"))

if __name__ == "__main__":
    main()

Output:

Run time: 10/10/2026 10:01 AM PT
Host: external-api.demo.kalshi.co (demo)
GET /portfolio/balance -> HTTP 200
Response keys: ['balance', 'balance_breakdown', 'balance_dollars', 'portfolio_value', 'updated_ts']
DELIBERATE FAILURE, GET /portfolio/orders?limit=1 signed with the query string -> HTTP 401
Error code: authentication_error

The second request fails on purpose. I left ?limit=1 in the signed path, and Kalshi answered 401 with authentication_error.

How do I find a Kalshi market and read its prices?

You walk down three levels of tickers to reach a price. A series is the recurring question, an event is one instance of it, and a market is one tradable contract inside the event. A contract pays $1 if you are right and $0 if you are wrong.

For example, as of 10/10/2026 KXHIGHNY is the series for the highest temperature in New York City. KXBTC15M is a 15-minute series on whether Bitcoin goes up or down.

Prices come back as dollar strings, such as "0.5600", with up to six decimal places. Contract counts are strings too, such as "10.00", and the fp in field names means fixed point. My code turns each string into a number and converts prices to cents, because 56 is easier for me to scan than 0.5600.

Many older tutorials show prices as whole cents from 1 to 99. Those match the legacy order endpoint, so check which price format your guide uses.

The order book only lists bids, which are offers to buy at a set price. Kalshi keeps one list for YES and one for NO. A YES bid at 7 cents is the same trade as an offer to sell NO at 93 cents. So Kalshi does not list that trade twice.

Two lessons from my July probes shaped this script. First, when I called the market list in July, it came back with empty bid and ask prices. So it showed me no depth, meaning how much is on offer at each price. Script 03 tries the list first and reads a market’s order book when both list prices are empty.

Second, settlement rules live inside each market. Settlement is how Kalshi decides a market and pays it out. As of 10/10/2026 the KXHIGHNY rules settle on one station’s reading, labeled CLINYC, as reported by The Weather Company. The station matters more than the city.

"""03_find_market.py: walk series -> event -> market on the DEMO exchange and pick one market for script 04.

No keys needed: these are public GET requests. Read only.
"""
import sys
from datetime import datetime
from decimal import Decimal
from urllib.parse import urlsplit
from zoneinfo import ZoneInfo

import requests

BASE = "https://external-api.demo.kalshi.co/trade-api/v2"
DEMO_HOSTS = ("external-api.demo.kalshi.co", "demo-api.kalshi.co")
SERIES = "KXHIGHNY"  # highest temperature in New York City

def run_time():
    now = datetime.now(ZoneInfo("America/Los_Angeles"))
    return f"{now:%m/%d/%Y} {now.hour % 12 or 12}:{now:%M} {now:%p} PT"

def get(path, **params):
    response = requests.get(BASE + path, params=params, timeout=20)
    response.raise_for_status()
    return response.json()

def bid_cents(price):
    # The list shows "0.0000" when nobody is bidding. Convert dollars to whole cents.
    cents = int(round(float(price or 0) * 100))
    return cents if cents > 0 else "none"

def ask_cents(price):
    # The list shows "1.0000" when nobody is selling.
    cents = int(round(float(price or 1) * 100))
    return cents if cents < 100 else "none"

def cents_from_book(ticker):
    # Fallback: read the order book. Best YES bid = highest YES price.
    # YES ask = 1 minus the highest NO price (the book holds bids only).
    book = get(f"/markets/{ticker}/orderbook", depth=5)["orderbook_fp"]
    yes = [Decimal(price) for price, _ in book["yes_dollars"]]
    no = [Decimal(price) for price, _ in book["no_dollars"]]
    bid = int(max(yes) * 100) if yes else "none"
    ask = int((1 - max(no)) * 100) if no else "none"
    return bid, ask

def show_series(ticker):
    info = get(f"/series/{ticker}")["series"]
    print("Series:", info["ticker"], "-", info["title"])

def main():
    host = urlsplit(BASE).hostname
    if host not in DEMO_HOSTS:
        sys.exit(f"Refusing to run: {host} is not a Kalshi demo host.")

    print("Run time:", run_time())
    print("Host:", host, "(demo)")

    series = SERIES
    show_series(series)
    events = get("/events", series_ticker=series, status="open", limit=3)["events"]
    if not events:
        # Demo has no open KXHIGHNY events (checked 10/10/2026), so fall back to the series
        # of the first open event on demo.
        series = get("/events", status="open", limit=1)["events"][0]["series_ticker"]
        print(f"No open {SERIES} events on demo. Falling back to {series}.")
        show_series(series)
        events = get("/events", series_ticker=series, status="open", limit=3)["events"]

    print("Events:")
    for event in events:
        print("  ", event["event_ticker"])

    event = events[0]["event_ticker"]
    print("Markets in", event)
    chosen = None
    for m in get("/markets", event_ticker=event, status="open")["markets"]:
        bid, ask = bid_cents(m["yes_bid_dollars"]), ask_cents(m["yes_ask_dollars"])
        if bid == "none" and ask == "none":
            bid, ask = cents_from_book(m["ticker"])
        print(f"  {m['ticker']:<40} yes_bid={bid} yes_ask={ask} (cents)")
        # A 1-cent post-only bid in script 04 cannot cross an ask of 3 cents or more, or no ask.
        if chosen is None and (ask == "none" or ask >= 3):
            chosen = m["ticker"]

    if chosen is None:
        sys.exit("No market in this event is safe for a 1-cent bid. Run again later.")
    print("CHOSEN=" + chosen)

if __name__ == "__main__":
    main()

Output:

Run time: 10/10/2026 10:01 AM PT
Host: external-api.demo.kalshi.co (demo)
Series: KXHIGHNY - Highest temperature in NYC
No open KXHIGHNY events on demo. Falling back to KXELONMARS.
Series: KXELONMARS - Elon Mars
Events:
   KXELONMARS-99
Markets in KXELONMARS-99
  KXELONMARS-99                            yes_bid=89 yes_ask=none (cents)
CHOSEN=KXELONMARS-99

The demo exchange had no open KXHIGHNY events when I ran it. So the script fell back to the first open event it found. It picked a market with no ask, where a 1-cent bid cannot trade.

Is there a Kalshi demo API?

Yes. Kalshi runs a full demo exchange with its own WebSocket and its own REST host, https://external-api.demo.kalshi.co/trade-api/v2. The docs warn that demo prices may differ from real markets, and I measured how much because my strategy depended on it.

On 07/21/2026 the set of demo markets my strategy watches showed 628 markets as open. Only 148 of them had a two-sided book, with offers on both sides.

Near settlement, books were empty or wide, with a gap between the best buy and sell prices that no real crowd would leave. One 15-minute market showed a 1-cent bid against a 49-cent ask, which is an offer to sell. Many of the “open” markets were ghosts, old markets from May that demo never settled. My 48-second dry run on demo produced zero quotes.

So I split what demo told me into two piles.

The first pile was plumbing, the code that talks to the exchange. It passed. Signing worked. So did the balance call. An order placed and cancelled cleanly.

The second pile was edge, a real advantage that should pay over time. That was a different matter, because my strategy bets against the habits of real traders. Demo has no crowd.

A green run on demo, where every check passes, feels like readiness, but it only proves your code can talk to the exchange.

That demo plumbing run is the first of two checks I run before any bot trades real money. The second is a free six-question checklist. Enter your email below and the six questions land in your inbox.

How do I place my first order with the Kalshi API?

Send it to the demo exchange first. My bots have a live flag, a switch that points them at real money. My demo trader refuses any host that is not a demo host. My command line also blocks the live flag unless I override it on purpose.

Use POST /trade-api/v2/portfolio/events/orders, which the docs call Create Order V2. As of 10/10/2026 the docs still say the legacy /portfolio/orders path “will be deprecated no earlier than May 6, 2026,” and that date has passed. I found no newer date in Kalshi’s changelog. So I treat the old path as one Kalshi can retire at any time, and I write new code against V2.

My order starts with the market ticker and a side of bid or ask. A bid buys YES and an ask sells YES. To buy NO, send an ask at 1 minus your NO price, because V2 quotes every order from the YES side. A NO buy at 30 cents is an ask at "0.7000".

The count and the price go as strings, with price in dollars. Kalshi also requires time_in_force, which says how long the order stays live. It requires self_trade_prevention_type too, which says what happens if the order would trade against one of your own. I also send a fresh UUID as client_order_id, because the docs recommend it for catching duplicates.

My first demo order in July used V2 with a count of "1.00", and placing it and cancelling it both returned success. The script below does the same at a 1-cent price with post_only set. That makes it a maker order, which waits in the order book until someone trades with it. At 1 cent it should never fill.

One contract is the right size for a plumbing test, because a bigger order only adds risk. When sizing becomes a real question, the Kelly criterion calculator shows the math before you pick a number.

"""04_first_order.py: place ONE resting 1-cent order on the DEMO exchange, then cancel it.

DEMO ONLY. The first lines of main() stop the script on any host that is not a demo host.
Usage: python3 04_first_order.py [MARKET_TICKER]   (default: the CHOSEN= line in 03_output.txt)
Needs KALSHI_KEY_ID and KALSHI_KEY_PATH, the same as script 02.
"""
import os
import sys
import uuid
from datetime import datetime
from decimal import Decimal
from importlib import import_module
from urllib.parse import urlsplit
from zoneinfo import ZoneInfo

import requests

Signer = import_module("02_auth").Signer  # reuse the Signer from script 02

BASE = "https://external-api.demo.kalshi.co/trade-api/v2"
DEMO_HOSTS = ("external-api.demo.kalshi.co", "demo-api.kalshi.co")
PREFIX = "/trade-api/v2"

def run_time():
    now = datetime.now(ZoneInfo("America/Los_Angeles"))
    return f"{now:%m/%d/%Y} {now.hour % 12 or 12}:{now:%M} {now:%p} PT"

def chosen_ticker():
    if len(sys.argv) > 1:
        return sys.argv[1]
    here = os.path.dirname(os.path.abspath(__file__))
    with open(os.path.join(here, "03_output.txt")) as f:
        lines = [line.strip() for line in f if line.startswith("CHOSEN=")]
    return lines[-1].split("=", 1)[1]

def place_order(signer, ticker):
    path = "/portfolio/events/orders"
    body = {
        "ticker": ticker,
        "side": "bid",                    # bid = buy YES
        "count": "1.00",                  # a string, two decimals
        "price": "0.0100",                # dollars, as a string: 1 cent
        "time_in_force": "good_till_canceled",
        "self_trade_prevention_type": "taker_at_cross",
        "post_only": True,                # rest in the book; never take a price
        "client_order_id": str(uuid.uuid4()),
    }
    headers = signer.headers("POST", PREFIX + path)
    return requests.post(BASE + path, json=body, headers=headers, timeout=20)

def cancel_order(signer, ticker, order_id):
    path = f"/portfolio/events/orders/{order_id}"
    headers = signer.headers("DELETE", PREFIX + path)  # sign the path with no query string
    # market_ticker tells Kalshi which shard holds the order; without it the cancel goes to shard 0.
    return requests.delete(BASE + path, params={"market_ticker": ticker}, headers=headers, timeout=20)

def main():
    host = urlsplit(BASE).hostname
    if host not in DEMO_HOSTS:
        sys.exit(f"Refusing to run: {host} is not a Kalshi demo host.")

    print("Run time:", run_time())
    print("Host:", host, "(demo)")
    signer = Signer(os.environ["KALSHI_KEY_ID"], os.environ["KALSHI_KEY_PATH"])
    ticker = chosen_ticker()
    print("Market:", ticker)

    created = place_order(signer, ticker)
    print("POST /portfolio/events/orders -> HTTP", created.status_code)
    if created.status_code != 201:
        print("Error:", created.json())
        sys.exit(1)
    order = created.json()
    print("order_id:", order["order_id"][:8])
    print("fill_count:", order["fill_count"])
    print("remaining_count:", order["remaining_count"])
    print("ts_ms:", order["ts_ms"])

    cancelled = cancel_order(signer, ticker, order["order_id"])
    print("DELETE /portfolio/events/orders/{order_id} -> HTTP", cancelled.status_code)
    result = cancelled.json()
    print("order_id:", result["order_id"][:8])
    print("reduced_by:", result["reduced_by"])
    print("ts_ms:", result["ts_ms"])

    if Decimal(order["fill_count"]) > 0:
        print("WARNING: the order filled. A post-only 1-cent bid should rest.")

if __name__ == "__main__":
    main()

Output:

Run time: 10/10/2026 10:01 AM PT
Host: external-api.demo.kalshi.co (demo)
Market: KXELONMARS-99
POST /portfolio/events/orders -> HTTP 201
order_id: 01a126c3
fill_count: 0.00
remaining_count: 1.00
ts_ms: 1791651713402
DELETE /portfolio/events/orders/{order_id} -> HTTP 200
order_id: 01a126c3
reduced_by: 1.00
ts_ms: 1791651713554

The order rested with a fill_count of 0. The cancel returned 200 and reduced the order by its one contract.

Going from demo to real money changes more than one line. The host becomes the production host, https://external-api.kalshi.com/trade-api/v2. Demo keys do not work there, so you need keys from a real Kalshi account. That account asks you to verify your identity at signup.

Every order now spends real money. Script 04 refuses any host that is not a demo host, so going live means you change that check on purpose. Start with one contract, the same size as the demo test.

How do I check my positions?

Call GET /trade-api/v2/portfolio/positions with the same signed headers. A position is what you hold in one market. Its position_fp field is positive for YES contracts and negative for NO.

Results come back in pages, and an empty cursor means you reached the end. I run this right after the order script, because that script should leave the book exactly as it found it. This check confirms it left nothing behind.

"""05_positions.py: list positions and resting orders on the DEMO exchange.

DEMO ONLY. Needs KALSHI_KEY_ID and KALSHI_KEY_PATH, the same as script 02.
Usage: python3 05_positions.py [MARKET_TICKER]   (default: the CHOSEN= line in 03_output.txt)
"""
import os
import sys
from datetime import datetime
from importlib import import_module
from urllib.parse import urlsplit
from zoneinfo import ZoneInfo

import requests

Signer = import_module("02_auth").Signer             # signing, from script 02
first_order = import_module("04_first_order")        # the cancel call, from script 04

BASE = "https://external-api.demo.kalshi.co/trade-api/v2"
DEMO_HOSTS = ("external-api.demo.kalshi.co", "demo-api.kalshi.co")
PREFIX = "/trade-api/v2"

def run_time():
    now = datetime.now(ZoneInfo("America/Los_Angeles"))
    return f"{now:%m/%d/%Y} {now.hour % 12 or 12}:{now:%M} {now:%p} PT"

def signed_get(signer, path, **params):
    headers = signer.headers("GET", PREFIX + path)  # sign the path with no query string
    return requests.get(BASE + path, params=params, headers=headers, timeout=20)

def main():
    host = urlsplit(BASE).hostname
    if host not in DEMO_HOSTS:
        sys.exit(f"Refusing to run: {host} is not a Kalshi demo host.")

    print("Run time:", run_time())
    print("Host:", host, "(demo)")
    signer = Signer(os.environ["KALSHI_KEY_ID"], os.environ["KALSHI_KEY_PATH"])
    ticker = first_order.chosen_ticker()

    response = signed_get(signer, "/portfolio/positions", settlement_status="unsettled", limit=20)
    print("GET /portfolio/positions -> HTTP", response.status_code)
    print("Response keys:", sorted(response.json()))
    rows = response.json()["market_positions"]
    print("Market positions:", len(rows))
    for row in rows:
        print(f"  {row['ticker']}  position_fp={row['position_fp']}")
    # Names only. The dollar amounts stay out of this post.
    print("Dollar fields:", sorted({key for row in rows for key in row if key.endswith("_dollars")}))

    response = signed_get(signer, "/portfolio/orders", status="resting", ticker=ticker)
    print("GET /portfolio/orders (resting,", ticker + ") -> HTTP", response.status_code)
    orders = response.json()["orders"]
    print("Resting orders:", len(orders))

    for order in orders:  # should be empty after script 04; clean up if it is not
        cancelled = first_order.cancel_order(signer, ticker, order["order_id"])
        print("Cancelled leftover", order["order_id"][:8], "-> HTTP", cancelled.status_code)

if __name__ == "__main__":
    main()

Output:

Run time: 10/10/2026 10:02 AM PT
Host: external-api.demo.kalshi.co (demo)
GET /portfolio/positions -> HTTP 200
Response keys: ['cursor', 'event_positions', 'market_positions']
Market positions: 0
Dollar fields: []
GET /portfolio/orders (resting, KXELONMARS-99) -> HTTP 200
Resting orders: 0

What are the Kalshi API rate limits?

Kalshi caps request speed with token budgets per second, and these tokens are request credits that have nothing to do with AI tokens. Reads and writes draw from separate buckets.

Each account sits in a tier. As of 10/10/2026 the Basic tier gets 200 read tokens per second. It gets 100 write tokens per second. Most requests cost 10 tokens, so that works out to about 20 reads and 10 writes per second.

Basic comes with signup. Advanced raises both budgets to 300, and you get it by calling the upgrade endpoint. You earn the tiers above that from trading volume, or Kalshi assigns them.

Go over a limit and you get a 429 with no Retry-After header. So the docs suggest exponential backoff, where you wait a little longer after each failed try.

One detail changes how I think about bots. Cancels draw from the write bucket, the same as new orders. The V2 cancel page lists a single cancel at 2 tokens, against 10 for most requests.

So a loop that cancels and replaces a quote spends about 12 write tokens per update. I think that is where the limit bites first, long before reads do, because a quoting loop writes every time the price moves.

Where should a Kalshi bot run?

Anywhere that stays on, as long as you test the network path first. On 06/04/2026 the Windows server for my futures bot got a 403 on every call to a third-party data service. My Mac got a 200 with the same key. I hashed both copies of the key and they matched, so the service was blocking the server’s datacenter address.

My lesson is simple. If your laptop works and your server gets a 403 with the same key, test where the request comes from before you rotate keys.

That futures server is a QuantVPS Windows machine in Chicago, a VPS (virtual private server) that stays on all day. If you want the same setup, here is my QuantVPS link. It is an affiliate link, so I earn a commission if you buy through it, at no extra cost to you. I compare other hosts in VPS providers compared.

Nothing in this post is financial advice. It shows plumbing, and plumbing says nothing about whether a strategy makes money.

Sources

  1. Kalshi Help Center, “Does Kalshi have an API?“, https://help.kalshi.com/faq/does-kalshi-have-an-api, checked 10/10/2026
  2. Kalshi Help Center, “How is Kalshi regulated?“, https://help.kalshi.com/en/articles/13823765-how-is-kalshi-regulated, checked 10/10/2026
  3. Polymarket docs, geographic restrictions, https://docs.polymarket.com/developers/CLOB/geoblock, checked 10/10/2026
  4. Kalshi docs, API Keys, https://docs.kalshi.com/getting_started/api_keys, checked 10/10/2026
  5. Kalshi docs, Demo Environment, https://docs.kalshi.com/getting_started/demo_env, checked 10/10/2026
  6. Kalshi Help Center, “Creating and using a demo account”, https://help.kalshi.com/en/articles/13823775-creating-and-using-a-demo-account, checked 10/10/2026
  7. Kalshi docs, SDKs overview, https://docs.kalshi.com/sdks/overview, checked 10/10/2026
  8. Kalshi docs, Get Markets, https://docs.kalshi.com/api-reference/market/get-markets, checked 10/10/2026
  9. Kalshi docs, Get Market Orderbook, https://docs.kalshi.com/api-reference/market/get-market-orderbook, checked 10/10/2026
  10. Kalshi docs, Create Order V2, https://docs.kalshi.com/api-reference/orders/create-order-v2, checked 10/10/2026
  11. Kalshi docs, Create Order (legacy), https://docs.kalshi.com/api-reference/orders/create-order, checked 10/10/2026
  12. Kalshi docs, Quick Start create order, https://docs.kalshi.com/getting_started/quick_start_create_order, checked 10/10/2026
  13. Kalshi docs, Order direction, https://docs.kalshi.com/getting_started/order_direction, checked 10/10/2026
  14. Kalshi docs, Get Positions, https://docs.kalshi.com/api-reference/portfolio/get-positions, checked 10/10/2026
  15. Kalshi docs, Rate Limits, https://docs.kalshi.com/getting_started/rate_limits, checked 10/10/2026
  16. Kalshi docs, Cancel Order (V2), https://docs.kalshi.com/api-reference/orders/cancel-order-v2, checked 10/10/2026
  17. Kalshi docs, API Environments and Endpoints, https://docs.kalshi.com/getting_started/api_environments, checked 10/10/2026
  18. Kalshi docs, Changelog, https://docs.kalshi.com/changelog, checked 10/10/2026
  19. Kalshi Help Center, “Can I trade on Kalshi from outside the United States?“, https://help.kalshi.com/en/articles/14026044-international-access-eligibility, checked 10/10/2026
  20. Kalshi Help Center, “Is Kalshi US only?“, https://help.kalshi.com/account/signing-up/is-kalshi-us-only, checked 10/10/2026
  21. Kalshi public API, KXHIGHNY series and market rules, https://api.elections.kalshi.com/trade-api/v2/markets?series_ticker=KXHIGHNY&status=open, checked 10/10/2026

Keep reading