NEW: Live arbitrage across 10+ prediction markets.Arbitrage →
← Index
APIAug 7, 202625 min read

How to Build a Kalshi Trading Bot (2026)

How to Build a Kalshi Trading Bot (2026)

The Short Answer

A Kalshi trading bot is a Python script that reads prices from Kalshi's REST API, decides something, and eventually places orders. Market data is the easy half: a plain requests.get against https://external-api.kalshi.com/trade-api/v2/markets returns live markets with no account, no API key, and no headers. Trading is the hard half, needing an account, an RSA key pair, and signed headers. Build the read path first, rehearse trading in Kalshi's demo environment, then think about funding it.

This is a build guide for a Kalshi trading bot, written for someone who can run a Python script but has never touched a prediction market API. No prior knowledge of order books, tickers, or websockets is assumed. By the end you will have a script that lists live markets, reads a price correctly, survives rate limits in a polling loop, and signs an authenticated request the way Kalshi's own SDK does.

One warning first. Kalshi rewrote how prices come across the wire in March 2026, and the old integer fields were not deprecated, they were deleted. That single change breaks essentially every Kalshi tutorial written before this year, including some that still rank well. Every endpoint and field name below was checked against the live API, most recently on 9 August 2026.

Key Takeaways

  • Market data needs no account. Your first working script is about a dozen lines of requests and returns real prices immediately.
  • Every price is a decimal string like "0.1600", not the integer 16. Wrap it in float(). Any tutorial that divides a price by 100 is running on fields Kalshi removed on 12 March 2026.
  • Placing orders needs an RSA-PSS signature over timestamp + METHOD + path with the timestamp in milliseconds. Rehearse it against the demo environment, which has its own URL, its own credentials, and mock money.

What a Kalshi Trading Bot Actually Does

Kalshi is an exchange for yes or no questions. Each question is a market with a ticker, a unique id string like KXHIGHNY-26AUG07-T96 that you pass to every endpoint. Related markets group into a series, a recurring family of questions (KXHIGHNY is the daily high temperature in New York City). You buy contracts that pay one dollar if the answer is yes and nothing if it is no, so a price of 0.16 means the market prices that outcome at roughly a 16 percent chance. Our beginner's guide to prediction market APIs covers these concepts without code.

A bot is then a loop: read the state, apply a rule, act or do nothing, wait, repeat. The API part is easy and this post gets you through it in an afternoon. The rule in the middle is where the money is won or lost, and nobody can hand you that. Treat the code below as plumbing, not strategy.

You need Python and the requests library for everything up to the authentication section. Any modern version works, which is a real advantage. At the time of writing Kalshi's two official Python SDKs, kalshi_python_sync and kalshi_python_async, are both at 3.27.0 and both declare requires_python >=3.13. On 3.11 or 3.12 pip does not fail. It quietly backsolves to version 3.2.0 from December 2025, the one release tagged >=3.9, which predates the March 2026 field migration this post exists to warn you about. An error would have been kinder. Raw requests sidesteps that entirely and hides nothing.

Your First Fetch: List Live Markets

Start with the endpoint that needs the least from you. Listing markets requires no account, no API key, and no headers at all. Save this as bot.py and run it. Every block below appends to that same file and reuses variables and imports from the blocks above it, so run it top to bottom rather than pasting pieces in isolation.

import requests

HOST = "https://external-api.kalshi.com"
BASE = HOST + "/trade-api/v2"

resp = requests.get(
    f"{BASE}/markets",
    params={"series_ticker": "KXHIGHNY", "status": "open", "limit": 5},
    timeout=10,
)
resp.raise_for_status()
markets = resp.json()["markets"]

for m in markets:
    print(m["ticker"], "|", m["title"])
  • import requests is the one third party dependency here (pip install requests). The standard library's urllib can do the same job, but you would hand-build the query string and parse the JSON yourself, which buries the parts you are trying to learn.
  • HOST = "https://external-api.kalshi.com" is the canonical production host as of 2026. It gets its own variable because the signing section later needs the bare host and the path separately, and because changing this one line is what moves every request in this post to the demo environment.
  • BASE = HOST + "/trade-api/v2" bolts on the version prefix that every endpoint below hangs off, and that prefix matters again later because it is part of the string you sign. Two host names you will meet in older tutorials: api.elections.kalshi.com still answers and is still supported, so code using it is not broken, while trading-api.kalshi.com is dead in a way that misleads you. It still resolves, so you get no DNS error to point at; it just answers every request with a 401 whose body tells you the API has moved to api.elections.kalshi.com. If you inherit a script stuck on that host, the fix is the hostname, not your credentials.
  • requests.get(f"{BASE}/markets", ...) hits the market list. Notice what is missing: no headers, no key, no login. This endpoint is public and returns HTTP 200 to a completely anonymous caller.
  • params={"series_ticker": "KXHIGHNY", ...} filters to one series. Drop it and you get whatever the exchange hands back first out of thousands of markets, which is useless for learning. KXHIGHNY is a good practice series because it is in Kalshi's own docs, it always has open markets, and it settles daily.
  • "status": "open" restricts results to markets still trading. Without it you get closed and settled markets whose prices never move, and you will spend an hour wondering why your bot's numbers never change.
  • "limit": 5 caps the page size. Larger responses come back with a cursor value you pass on the next call to page through the rest.
  • timeout=10 is not optional in a bot. requests waits forever by default, so a single stalled socket will hang your loop silently and permanently instead of erroring out so you can retry.
  • resp.raise_for_status() converts a 4xx or 5xx into an exception. Skip it and an error response still parses as JSON, then your next line dies on a missing key and you debug the wrong thing.
  • resp.json()["markets"] reaches into the envelope. The response is {"cursor": "...", "markets": [...]}, so the list is never at the top level. Every Kalshi endpoint wraps its payload like this.
  • m["ticker"] is the string you will feed to every other endpoint in this post. m["title"] is the human readable question, which is worth printing so you can confirm you are looking at the market you think you are.

Reading a Price Without Getting the Units Wrong

Now fetch one market on its own and read its price. This is where most people's first Kalshi bot quietly produces nonsense, so read the line notes carefully.

ticker = markets[0]["ticker"]

resp = requests.get(f"{BASE}/markets/{ticker}", timeout=10)
resp.raise_for_status()
m = resp.json()["market"]

yes_bid = float(m["yes_bid_dollars"])
yes_ask = float(m["yes_ask_dollars"])

print(m["title"])
print(f"  bid {yes_bid:.2f}  ask {yes_ask:.2f}  spread {yes_ask - yes_bid:.2f}")
print("  last", repr(m["last_price_dollars"]), " volume", repr(m["volume_fp"]))
  • ticker = markets[0]["ticker"] just reuses the first result from the previous block so this runs immediately. A real bot picks its market deliberately, usually by matching on the title or the strike.
  • f"{BASE}/markets/{ticker}" is the single market endpoint. Also public, also no headers. It returns one envelope, {"market": {...}}, hence resp.json()["market"] rather than ["markets"] on the previous call.
  • float(m["yes_bid_dollars"]) is the line that matters most in this entire post. Kalshi sends prices as decimal strings: 16 cents arrives as the literal text "0.1600". Without float(), any arithmetic either raises a TypeError or silently concatenates strings.
  • yes_bid is the best price anyone is currently willing to pay you for a yes contract, and yes_ask is the cheapest yes contract you can buy right now. Buying at the ask and selling at the bid is called taking; posting your own price and waiting is called making.
  • print(m["title"]) and the formatted line under it put the three numbers side by side, and the :.2f is there so you get 0.16 rather than 0.16000000000000003 once you start doing arithmetic on these.
  • yes_ask - yes_bid is the spread, and it is the immediate cost of changing your mind. On a thin market the spread is frequently wider than whatever edge a first bot thinks it has found, which is a more common reason for losing money than bad predictions.
  • repr(m["last_price_dollars"]) and repr(m["volume_fp"]) print those two fields with their quotes intact, so your terminal shows '0.0100' and you can see the type for yourself. Plain print() would hide the quotes and these would look exactly like the floats above, which is how the whole units problem stays invisible. volume_fp is contracts traded over the market's lifetime; the _fp suffix stands for fixed point.
  • If you copy code from elsewhere that reads m["yes_bid"], m["last_price"], or m["volume"], it will raise KeyError. Those fields were removed on 12 March 2026, not deprecated. There is no fallback.

Here is the translation table between what old tutorials use and what the API actually returns today. If you are debugging a script you found on GitHub, this is usually the whole fix.

What you want to readField to use in 2026Dead field in old tutorialsExample value
Best yes bidyes_bid_dollarsyes_bid"0.1600"
Best yes askyes_ask_dollarsyes_ask"0.0100"
Last traded pricelast_price_dollarslast_price"0.0100"
Lifetime volumevolume_fpvolume"1449.65"
Open interestopen_interest_fpopen_interestdecimal string
Order book objectorderbook_fporderbookholds yes_dollars and no_dollars
Minimum price stepprice_ranges[].steptick_size"0.0100" or "0.0010"

That last row is its own small trap. tick_size was removed on 7 May 2026 and the minimum price increment is now per market and can change, so read it from price_ranges rather than hardcoding one cent. Both example values above are live ones: a Bitcoin daily market pulled on 9 August 2026 stepped in whole cents, while another market pulled the same morning stepped in tenths of a cent and reported a price_level_structure of "deci_cent". Nothing breaks if you round to two decimals on a market like that, which is why it is easy to miss, but you have quietly thrown away nine of every ten price levels you could have posted at.

The Order Book Trap: Both Sides Are Bids

The order book is the live list of offers sitting on the exchange, grouped by price. An offer sits there because whoever placed it named their own price rather than accepting whatever was already available, and an offer like that is a limit order. It vanishes when someone takes the other side of it, which is called being filled. The book tells you how much size you could actually trade, which the single best price on its own, the top of the book, does not.

resp = requests.get(
    f"{BASE}/markets/{ticker}/orderbook",
    params={"depth": 3},
    timeout=10,
)
resp.raise_for_status()
book = resp.json()["orderbook_fp"]

print("yes bids:", book["yes_dollars"])
print("no  bids:", book["no_dollars"])

if book["no_dollars"]:
    best_no_bid = max(float(p) for p, _ in book["no_dollars"])
    print("implied best yes ask:", round(1 - best_no_bid, 2))
  • params={"depth": 3} asks for three price levels per side. It accepts 0 to 100, where 0 means every level. Start small: full books on liquid markets are long and hard to read while you are learning the shape.
  • resp.json()["orderbook_fp"] uses the current key name. It is orderbook_fp, not orderbook, and the sub-keys are yes_dollars and no_dollars, not yes and no. Same March 2026 migration as the price fields.
  • book["yes_dollars"] being an empty list is the trap that sends beginners looking for a bug that does not exist. Kalshi returns bids only, on both sides. There are no asks in the response at all.
  • The reason is that in a binary market the two are the same thing: a yes bid at 7 cents is identical to a no ask at 93 cents, with the same contract size. Sending both would be duplicate data, so Kalshi sends one.
  • if book["no_dollars"]: guards the next two lines, and it is not defensive padding. An empty side is ordinary here rather than exceptional, and max() over an empty list raises ValueError.
  • best_no_bid = max(float(p) for p, _ in book["no_dollars"]) pulls the highest no bid. Each entry is a two element list of strings, ["0.9700", "605.00"], meaning price then quantity, so the float() and the discarded second value are both doing real work.
  • 1 - best_no_bid is the conversion rule you will use constantly: yes_ask = 1 - best_no_bid and no_ask = 1 - best_yes_bid. On the market checked for this post, yes bids came back empty while the top no bid was 0.99, giving an implied yes ask of 0.01, which matched the market object's yes_ask_dollars of "0.0100" exactly.
  • One caveat on this endpoint, and it is the one to design around. Kalshi's API reference lists the order book as requiring the three signed headers, and it is one of only three market data paths whose OpenAPI spec still declares authentication; the multi-market /markets/orderbooks carries the same security block, and so does the forecast percentile history path. Every other market data endpoint in this post is explicitly marked public. In practice the order book still answered anonymously each time it was checked, most recently on 9 August 2026, which is why the snippet above runs as written with no headers. Treat that as undocumented generosity, not a guarantee: generated SDK clients may demand a key here regardless, and Kalshi can start enforcing its own spec without notice. Keep this call behind the same helper as your signed requests so that adding headers is one line rather than a refactor, and if it starts returning 401, check the market endpoints reference at docs.kalshi.com before assuming your code broke.

Turning It Into a Loop That Survives Rate Limits

A bot needs to check repeatedly. Asking on a timer is called polling, and it is fine for anything reacting on a scale of seconds rather than milliseconds. The one thing you must handle is the rate limit, the cap on how often the exchange will answer you.

import time

def poll_price(ticker, tries=3):
    for attempt in range(tries):
        r = requests.get(f"{BASE}/markets/{ticker}", timeout=10)
        if r.status_code == 429:
            time.sleep(1)
            continue
        r.raise_for_status()
        return float(r.json()["market"]["yes_ask_dollars"])
    raise RuntimeError(f"gave up on {ticker} after {tries} tries")

while True:
    print(time.strftime("%H:%M:%S"), poll_price(ticker))
    time.sleep(5)
  • import time covers both of the waits below: the one second pause after a 429 and the five second gap between polls.
  • def poll_price(ticker, tries=3) wraps the fetch so retry logic lives in one place. Once your bot has three or four endpoints, retry logic scattered inline is how you end up with one call path that silently lacks it.
  • for attempt in range(tries) bounds the retries at three. The attempt name is never used inside the loop; range is only there to supply a count, and a bot that retries without a bound is indistinguishable from a bot that is working.
  • if r.status_code == 429 catches the rate limit response, whose body is simply {"error": "too many requests"}. Kalshi meters with a token bucket rather than a plain requests-per-second count: each request costs tokens (10 by default) drawn from a per-second budget, with separate budgets for reads and writes.
  • At the time of writing the entry level Basic tier budget is 200 read tokens and 100 write tokens per second, roughly 20 reads and 10 writes at the default cost. Kalshi publishes seven tiers and says it can move you between them at its discretion, so check docs.kalshi.com/getting_started/rate_limits before you size a loop. Those costs are documented for authenticated requests, and Basic is the tier you get on completing signup; the anonymous loop here has no published budget of its own, so treat the figures above as a ceiling you stay well under. The 10 token default is not universal either, and you can check the exceptions without an account: GET /trade-api/v2/account/endpoint_costs is itself a public endpoint and returns the current default alongside every endpoint priced differently from it.
  • time.sleep(1) then continue is the floor, not the finished version. A 429 on Kalshi carries no penalty and no cooldown, and the bucket keeps refilling, so a short pause and a retry usually clears it. But Kalshi's own rate limit page tells you to apply exponential backoff on a 429, and doubling that sleep on each attempt is two lines, so add them before you leave anything running unattended.
  • r.raise_for_status() comes after the 429 check on purpose. Reverse the order and raise_for_status turns a completely recoverable rate limit into a crashed bot.
  • return float(r.json()["market"]["yes_ask_dollars"]) unwraps the envelope and converts in the same breath, and returning here is what ends the retry loop early on the first good response.
  • raise RuntimeError(...) fails loudly once retries are exhausted. The alternative, returning None, feeds a null into your trading logic and produces a decision based on no data, which is the worst of the available outcomes. Know what it costs you, though: the loop below catches nothing, so roughly three seconds of sustained throttling stops the bot. That is the right default while you are watching it run, and the thing to wrap in try / except RuntimeError once you are not.
  • while True: around print(time.strftime("%H:%M:%S"), poll_price(ticker)) is the whole bot in miniature: timestamp what you saw, then act on it. Note that this loop has no exit condition. The last section replaces it with one you can stop.
  • time.sleep(5) sets the polling interval. Do not set this to zero: you will burn your token budget, get nothing useful, and learn nothing.

To react within the same second, you want the websocket: a connection that stays open so the exchange pushes updates to you instead of you asking. Kalshi's is at wss://external-api-ws.kalshi.com/trade-api/ws/v2, with one catch that reshapes a beginner project. Unlike the REST market data endpoints, it needs authentication at the handshake, the ordinary HTTP request that opens the connection before it upgrades into a stream, and that holds even for channels carrying nothing but public data. Your three signed headers go on that opening request; there is no anonymous streaming at all. After that you send a JSON subscribe message naming a channel such as orderbook_delta and the ticker you have been using, the server answers subscribed, and updates start arriving. Kalshi pings every ten seconds, and Python's websockets library answers those for you. Our complete Kalshi API guide has the full streaming setup.

Signing a Request: The Shape of Placing an Order

Everything so far ran anonymously. From here you need a real Kalshi account and an API key from the dashboard, which gives you a key ID and a downloadable RSA private key file. Do this on a demo account first (see the next section). Kalshi never gives you a password or a token to send. You hold the private key file, Kalshi holds the matching public half, and for every request you compute a short signature over the timestamp, method, and path using your file, which Kalshi then checks against its half. Nothing secret ever crosses the wire. The file is the whole of your account security, though, so treat it like a password and keep it out of your repository. (If you are arriving from another exchange: no, there is no bearer token and no HMAC secret here.)

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

KEY_ID = os.environ["KALSHI_KEY_ID"]

with open(os.environ["KALSHI_KEY_PATH"], "rb") as fh:
    PRIVATE_KEY = serialization.load_pem_private_key(fh.read(), password=None)
  • import base64, os, time covers three standard library modules with nothing to install. os reads your environment variables in this block; base64 and time are used by the signing function in the next one, so they go in now rather than being added twice.
  • from cryptography.hazmat... pulls in the cryptography package (pip install cryptography). This is the same library Kalshi's own SDK uses internally, so you are not doing anything exotic by signing manually.
  • os.environ["KALSHI_KEY_ID"] reads the key ID from an environment variable instead of hardcoding it. Set it in your shell before running (export KALSHI_KEY_ID=... on macOS or Linux, $env:KALSHI_KEY_ID="..." in PowerShell). Using os.environ[...] rather than os.environ.get(...) is deliberate: you want a loud KeyError at startup, not a confusing 401 later.
  • os.environ["KALSHI_KEY_PATH"] holds the path to the .pem file rather than the key itself. Environment variables leak into logs and process listings more easily than files do, so pass the location and keep the secret on disk with tight permissions.
  • load_pem_private_key(fh.read(), password=None) parses the downloaded file into a key object. Pass password=b"..." instead if you chose to encrypt the file when you saved it.

Now the signature itself. Four details decide whether this works: milliseconds rather than seconds, the full path including the /trade-api/v2 prefix, no query string, and PSS padding with a SHA-256 salt length. The line notes flag each one as it appears.

def auth_headers(method, path):
    ts = str(int(time.time() * 1000))
    message = (ts + method.upper() + path).encode()
    signature = PRIVATE_KEY.sign(
        message,
        padding.PSS(mgf=padding.MGF1(hashes.SHA256()),
                    salt_length=padding.PSS.DIGEST_LENGTH),
        hashes.SHA256(),
    )
    return {
        "KALSHI-ACCESS-KEY": KEY_ID,
        "KALSHI-ACCESS-TIMESTAMP": ts,
        "KALSHI-ACCESS-SIGNATURE": base64.b64encode(signature).decode(),
    }
  • int(time.time() * 1000) produces milliseconds. Kalshi's docs call this out as the single most common authentication error, because time.time() gives you seconds and the resulting 401 does not tell you which of the three headers was wrong.
  • message = (ts + method.upper() + path).encode() builds the string you sign: timestamp, then the HTTP verb in capitals, then the path, concatenated with nothing in between. It looks like 1703123456789GET/trade-api/v2/portfolio/balance.
  • path must be the full path from the API root, including the /trade-api/v2 prefix, and excluding any query string. Signing /portfolio/balance alone fails, and so does signing /markets?limit=5. Strip everything from the ? onward before signing.
  • The request body is not signed. When you post an order, only the timestamp, method, and path go into the signature. This surprises people coming from other exchange APIs where the payload is part of the digest.
  • padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.DIGEST_LENGTH) plus hashes.SHA256() specifies RSA-PSS with an SHA-256 digest and a salt length equal to that digest. These are not defaults you can omit; a valid RSA signature with different padding is still a rejected signature.
  • base64.b64encode(signature).decode() converts the raw signature bytes to the text the header expects. Sending raw bytes produces a 401 with no useful explanation.
  • The three header names are exact and case sensitive as written: KALSHI-ACCESS-KEY, KALSHI-ACCESS-TIMESTAMP, KALSHI-ACCESS-SIGNATURE.

Test it on the cheapest authenticated endpoint there is, your account balance, before you go anywhere near an order.

path = "/trade-api/v2/portfolio/balance"

r = requests.get(HOST + path,
                 headers=auth_headers("GET", path), timeout=10)

print(r.status_code, r.text)
  • path = "/trade-api/v2/portfolio/balance" is declared once and used twice, for the URL and for the signature. Keeping them in one variable prevents the classic bug where you sign one path and request another.
  • HOST + path rebuilds the URL from the bare host and the full signed path, which is why the first block split HOST out of BASE. BASE already carries the /trade-api/v2 prefix that also has to sit inside the signed string, so reusing it here would sign one path and request another.
  • If you took the advice above and made a demo key, set HOST to https://external-api.demo.kalshi.co before you run this. A demo key against the production host returns a 401 that looks exactly like a broken signature, and people lose hours to it.
  • headers=auth_headers("GET", path) attaches the three signed headers. The same endpoint with no headers returns 401, which makes this a clean pass/fail test of your signing code.
  • print(r.status_code, r.text) shows the raw response. A 200 means your key, timestamp, and signature are all correct and you are cleared to move on. Anything else: confirm your key and your host come from the same environment first, and only then start on the signature.

Orders themselves go to Kalshi's V2 endpoints under /portfolio/events/orders, signed the same way. Two warnings. The legacy order mutation endpoints under /portfolio/orders, meaning create, cancel, amend, decrease and their batch forms, were announced on 18 June 2026 for deprecation sometime in the window of 18 to 25 June, and Kalshi's changelog states that once deprecated they answer Please switch to the V2 endpoints with a link to the V2 reference, so older tutorials' order code is dead on arrival. The read endpoints under that same prefix are alive and carry no deprecation flag: GET /portfolio/orders is still the documented way to list your resting orders, so do not go hunting for a replacement that does not exist. And take the request body fields from the current API reference, not from any blog post including this one, because a typo there becomes a real trade. If you would rather not hand-roll signing, the official sync SDK (pip install kalshi-python-sync) does it for you, with one gotcha: its published README shows from kalshi_python import ..., which does not exist in the installed wheel. The real import is from kalshi_python_sync import Configuration, KalshiClient, and the current release needs Python 3.13 or newer. Kalshi warns on its own SDK page that these packages can lag the API, so check field names against the reference either way. Our Kalshi Python tutorial covers the SDK path.

Before You Risk Any Money

Kalshi has something most prediction markets do not: a real demo environment with mock funds at demo.kalshi.co. Sign up there separately, generate a separate API key, and point your bot at https://external-api.demo.kalshi.co. Credentials are not shared in either direction, so a production key simply fails against demo and vice versa. Note the domain ends in .co, not .com, which costs people an afternoon more often than it should.

import os

HOST = "https://external-api.demo.kalshi.co"
BASE = HOST + "/trade-api/v2"

MAX_CONTRACTS_PER_MARKET = 20
MAX_OPEN_MARKETS = 3
KILL_FILE = "STOP"

def within_limits(open_markets, qty):
    return qty <= MAX_CONTRACTS_PER_MARKET and len(open_markets) < MAX_OPEN_MARKETS

def should_stop():
    return os.path.exists(KILL_FILE)
  • import os repeats the import from the signing section, which costs nothing if you ran everything in order and saves you a NameError if you skipped ahead to the safety code. This is the block that most needs to work on its own.
  • Reassigning HOST and BASE to the demo pair is genuinely the only change needed to move every earlier block, the balance test included, onto demo. That is exactly why the host was a separate variable in the first place. Keep the value in a config file or an environment variable so switching environments is never a code edit you might forget to reverse.
  • MAX_CONTRACTS_PER_MARKET and MAX_OPEN_MARKETS are hard caps. A cap that nothing ever reads is decoration, which is what the next two lines fix.
  • within_limits(open_markets, qty) is the check to run immediately before any order call, skipping the trade when it returns False. Pass it the size you are about to buy and your current positions, which you read from GET /trade-api/v2/portfolio/positions. A position cap is what turns a logic bug from a wiped account into an annoying but survivable loss, and every bot needs one from day one rather than after the first incident.
  • KILL_FILE = "STOP" plus should_stop() is the crudest possible kill switch, and it works. Creating an empty file named STOP halts the bot from any terminal, phone SSH session, or cron job, with no need to find and kill a process.

That kill switch is worth nothing until the loop consults it, so here is the polling loop from earlier with the one change that makes it stoppable.

while not should_stop():
    print(time.strftime("%H:%M:%S"), poll_price(ticker))
    time.sleep(5)

print("stopped")
  • while not should_stop() replaces the while True from the polling section, and that is the whole fix. The check runs once per pass, so the bot halts within one sleep interval of the file appearing.
  • print(time.strftime("%H:%M:%S"), poll_price(ticker)) is the same body as before. Whatever your bot actually does goes here, behind within_limits once there is an order call to guard.
  • time.sleep(5) now sets two things at once: how often you poll, and the worst case delay between creating STOP and the bot noticing. Checking only at startup would not be a kill switch at all.
  • print("stopped") after the loop tells you the bot exited on purpose rather than crashing. Delete the STOP file before the next run or it will exit immediately.

Two honest limits on demo. Kalshi states plainly that demo prices may not reflect real markets, so it validates your mechanics, not your strategy: it catches signature bugs, reconnect failures, and off-by-one sizing, and tells you nothing about whether your rule makes money. Fills against thin synthetic books are easier than real ones, so expect a worse live fill rate.

Three more things before real funds. If you plan to backtest, which means replaying past prices to see how your rule would have done, check GET /historical/cutoff first, and believe the endpoint over any number you read anywhere. Kalshi's docs describe a three month target window for live data, but the cutoff moves: on 9 August 2026 it was reporting a boundary of 9 June, which is two months, not three. Past that line the live endpoints return HTTP 200 with empty arrays rather than an error, so a backtest can silently train on nothing at all. The older records are not deleted, they have moved to the parallel /historical/ endpoints, so a full history means querying both sides of the cutoff and stitching the results together. Candlesticks, the summarised price bars you get instead of one row per trade, accept a period_interval of 1, 60, or 1440 minutes per bar and nothing else; their start_ts and end_ts are UTC epoch seconds, the count of seconds since 1 January 1970, and a local-time epoch quietly returns near-empty results instead of failing.

The last one is a note to file away rather than act on. Kalshi is partway through splitting trading across multiple matching engines, which its docs call exchange sharding. As of 9 August 2026 the rollout still only touches combos, and it has dates on it now: legacy combo collections move onto shard 1 by 17 August 2026, with the original collections supported until 24 August. exchange_index defaults to 0 on every REST endpoint, so a single market bot like the one in this post is not affected. It matters later because collateral, the cash locked up against your open positions, is held per shard, and Kalshi tells programmatic traders to preallocate collateral on a shard before placing orders there. Skip that and you can hold a healthy total balance while being rejected for insufficient collateral on the one engine you are actually trading. If you ever see that while your balance looks fine, it is infrastructure, not your arithmetic.

Finally, the boring part: your profit and loss is after fees. Run your expected edge through the real fee math in our Kalshi fees breakdown before concluding a strategy works, because a per-contract fee eats a one cent edge without noticing. None of this is financial advice, and a bot that trades badly just loses money faster than you would by hand. Start in demo, start small, cap everything.

Frequently Asked Questions

How do I build a Kalshi trading bot?

Build it in the order that keeps you unblocked. Start with public market data, because plain requests against https://external-api.kalshi.com/trade-api/v2/markets needs no account at all. Parse the prices as decimal strings like "0.1600" rather than integers, or the arithmetic is wrong from your very first line. Wrap the fetch in a polling loop that treats HTTP 429 as a pause and a retry instead of a crash. Only then create an API key, sign your requests with RSA-PSS, and rehearse order placement in the demo environment before you fund anything.

Does Kalshi allow trading bots?

Kalshi publishes a documented public API, ships official Python SDKs, and offers rate limit tiers that scale with your trading volume: Basic on signup, Advanced through a self-service Upgrade Account API Usage Level call, and Expert and above granted automatically from trailing 30 day volume. Programmatic trading is clearly intended rather than tolerated. Your account eligibility and the exchange's terms apply exactly as they do to manual trading, so read the current terms yourself.

How do I use the Kalshi API with Python?

For market data, use requests directly: the markets, single market, and candlestick endpoints are public GET calls. For trading, either sign requests yourself with the cryptography library or install the official sync SDK with pip install kalshi-python-sync. Note that the SDK's published README shows the wrong import: the real module is kalshi_python_sync, and the current release requires Python 3.13 or newer.

Can I test a Kalshi bot without real money?

Yes. Kalshi runs a demo environment at demo.kalshi.co with mock funds and the same API surface, reachable at https://external-api.demo.kalshi.co/trade-api/v2. You need a separate demo account and separate demo API keys, because credentials are not shared between environments. Kalshi notes that demo prices may not reflect real markets, so it validates mechanics rather than strategy.

What are the Kalshi API rate limits for a bot?

Kalshi uses a token bucket rather than a fixed requests-per-second cap. Each request costs tokens, 10 by default, drawn from separate read and write budgets that refill continuously. At the time of writing the entry level Basic tier allows 200 read and 100 write tokens per second, roughly 20 reads and 10 writes, but Kalshi publishes seven tiers and can move you between them, so check its rate limit docs before sizing a loop. Exceeding your budget returns HTTP 429 with no penalty or cooldown, and Kalshi still recommends exponential backoff on top of a plain retry.

Conclusion

The API half of this project is smaller than it looks: market data costs nothing, prices are one float() away from usable, the order book confuses you exactly once, and request signing is fifteen lines that return either 200 or 401 with no ambiguity. The hard parts are the ones no tutorial can hand you: a rule worth automating, and the discipline to cap it. Build the read path first, run it against demo longer than you want to, and when you go live, go live small. Our complete Kalshi API guide picks up where this leaves off.