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
requestsand returns real prices immediately. - Every price is a decimal string like
"0.1600", not the integer16. Wrap it infloat(). 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 + pathwith 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 requestsis the one third party dependency here (pip install requests). The standard library'surllibcan 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.comstill answers and is still supported, so code using it is not broken, whiletrading-api.kalshi.comis 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 toapi.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.KXHIGHNYis 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": 5caps the page size. Larger responses come back with acursorvalue you pass on the next call to page through the rest.timeout=10is not optional in a bot.requestswaits 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": {...}}, henceresp.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". Withoutfloat(), any arithmetic either raises aTypeErroror silently concatenates strings.yes_bidis the best price anyone is currently willing to pay you for a yes contract, andyes_askis 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:.2fis there so you get0.16rather than0.16000000000000003once you start doing arithmetic on these.yes_ask - yes_bidis 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"])andrepr(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. Plainprint()would hide the quotes and these would look exactly like the floats above, which is how the whole units problem stays invisible.volume_fpis contracts traded over the market's lifetime; the_fpsuffix stands for fixed point.- If you copy code from elsewhere that reads
m["yes_bid"],m["last_price"], orm["volume"], it will raiseKeyError. 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 read | Field to use in 2026 | Dead field in old tutorials | Example value |
|---|---|---|---|
| Best yes bid | yes_bid_dollars | yes_bid | "0.1600" |
| Best yes ask | yes_ask_dollars | yes_ask | "0.0100" |
| Last traded price | last_price_dollars | last_price | "0.0100" |
| Lifetime volume | volume_fp | volume | "1449.65" |
| Open interest | open_interest_fp | open_interest | decimal string |
| Order book object | orderbook_fp | orderbook | holds yes_dollars and no_dollars |
| Minimum price step | price_ranges[].step | tick_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 isorderbook_fp, notorderbook, and the sub-keys areyes_dollarsandno_dollars, notyesandno. 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, andmax()over an empty list raisesValueError.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 thefloat()and the discarded second value are both doing real work.1 - best_no_bidis the conversion rule you will use constantly:yes_ask = 1 - best_no_bidandno_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'syes_ask_dollarsof"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/orderbookscarries 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 atdocs.kalshi.combefore 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 timecovers 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. Theattemptname is never used inside the loop;rangeis 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 == 429catches 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_limitsbefore 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_costsis itself a public endpoint and returns the current default alongside every endpoint priced differently from it. time.sleep(1)thencontinueis 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 andraise_for_statusturns 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, returningNone, 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 intry/except RuntimeErroronce you are not.while True:aroundprint(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, timecovers three standard library modules with nothing to install.osreads your environment variables in this block;base64andtimeare used by the signing function in the next one, so they go in now rather than being added twice.from cryptography.hazmat...pulls in thecryptographypackage (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). Usingos.environ[...]rather thanos.environ.get(...)is deliberate: you want a loudKeyErrorat startup, not a confusing 401 later.os.environ["KALSHI_KEY_PATH"]holds the path to the.pemfile 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. Passpassword=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, becausetime.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 like1703123456789GET/trade-api/v2/portfolio/balance.pathmust be the full path from the API root, including the/trade-api/v2prefix, and excluding any query string. Signing/portfolio/balancealone 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)plushashes.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 + pathrebuilds the URL from the bare host and the full signed path, which is why the first block splitHOSTout ofBASE.BASEalready carries the/trade-api/v2prefix 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
HOSTtohttps://external-api.demo.kalshi.cobefore 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 returns401, which makes this a clean pass/fail test of your signing code.print(r.status_code, r.text)shows the raw response. A200means 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 osrepeats the import from the signing section, which costs nothing if you ran everything in order and saves you aNameErrorif you skipped ahead to the safety code. This is the block that most needs to work on its own.- Reassigning
HOSTandBASEto 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_MARKETandMAX_OPEN_MARKETSare 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 returnsFalse. Pass it the size you are about to buy and your current positions, which you read fromGET /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"plusshould_stop()is the crudest possible kill switch, and it works. Creating an empty file namedSTOPhalts 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 thewhile Truefrom 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, behindwithin_limitsonce 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 creatingSTOPand 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 theSTOPfile 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.