
How to Build a Kalshi Sports Betting Bot
Build your own automated trading bot for Kalshi MLB and NFL game lines on Supabase, step by step. It includes the exact risk limits our bot runs with.
(affiliate link)
How it works
Scan
Read your model picks and find the matching open Kalshi market for each one.
In Supabase: an Edge Function
Queue
Store every qualifying bet as a row with a status field, so nothing is lost between passes.
In Supabase: a table
Execute
Place the order, and retry on a network or rate-limit failure instead of dropping the bet.
In Supabase: an Edge Function
Grade
Settle each bet from the Kalshi market result, not from your own score feed.
In Supabase: an Edge Function
Supabase Cron runs the functions on a timer, several times a day.
The bot reads your model picks, finds the matching open Kalshi market, and places orders that pass your risk limits. It runs on a schedule several times a day so late-opening markets get caught. Every bet is queued, executed with retries, then graded from the Kalshi settlement. This guide shows the four stages, the setup, and the exact limits our bot runs with. It does not hand out our keys or our API.
Before you start
What you need
- A Kalshi account, verified and funded.
- A free Supabase account at supabase.com. Supabase gives you a database, a place to run your code, and a timer, all in one place.
- Picks from a model. Use your own, or copy ours by hand.
- About 2 hours for the first setup. You do not need your own server.
Words you will see
- API key: A key ID and a private key file from Kalshi. Your bot uses them to prove it is you.
- Edge Function: A small program that Supabase runs for you when it gets a request. Your bot is made of these.
- Secret: A private value, like your Kalshi key, that Supabase stores and gives only to your functions.
- Table: A list in your Supabase database, like a spreadsheet. The bot keeps its settings and its bets in tables.
- Cron job: A timer that runs a function at set times each day.
- Paper mode: The bot does everything except place real orders. Use it until the results look right.
Setup steps
- 1Open a Kalshi account , verify it and fund it.Sign up for Kalshi with our link(affiliate link)
- 2Make a free Supabase account at supabase.com. Click New project. Enter a name and a strong database password, and pick the region nearest to you. Wait about two minutes for the project to start.
- 3In Kalshi, open your account settings and find API Keys. Click Create key. Kalshi gives you the private key file one time only, so save it in a safe folder right away. Copy the key ID too.
- 4Test on the Kalshi demo site first. The demo site has its own account and its own API keys. Use real money only after the bot works on demo.
- 5Save your Kalshi keys in Supabase as secrets (Supabase step 2 below). Never put a key in website code, in a table, or in git.
- 6Build the tables, the functions and the timer in Supabase. The next section shows each click and gives you each file.
- 7Get picks from your model. Each pick needs the game date, the Kalshi market, the side (yes or no) and your model probability.
- 8Put each pick in the queue table. The bot checks the live Kalshi price, computes the edge (your probability minus the Kalshi ask price), and places the bet only if it passes your limits.
- 9Grade each bet from the settled Kalshi market result, not from your own score feed.
Check Kalshi's API docs at docs.kalshi.com for the current base URLs and endpoints.
Build it on Supabase, step by step
Supabase runs the whole bot on its free plan. Tables hold the settings and the bets. Edge Functions run the code. Cron runs the functions on a timer. Do each step in order.
- 1
Create the tables
The bot needs two tables. bot_config holds your settings in one row. bet_queue holds one row for each bet. Change your limits in bot_config, not in the code.
Supabase dashboard > SQL Editor > New query. Paste the code. Click Run.
tables.sql-- One row of settings. Change the values here, not in the code. create table public.bot_config ( id int primary key default 1, live_enabled boolean not null default false, -- false = paper mode, no real orders kill_switch boolean not null default false, -- true = stop all orders now unit_cents int not null default 10000, -- 100 dollars per bet daily_cap_cents int not null default 60000, -- 600 dollars per game date min_edge numeric not null default 0.02, -- 2 percent vs the Kalshi ask max_attempts int not null default 6 -- retries for a failed order ); insert into public.bot_config (id) values (1); -- One row for each bet the bot wants to make. create table public.bet_queue ( id bigint generated always as identity primary key, game_date date not null, market_ticker text not null unique, -- one bet per market side text not null check (side in ('yes', 'no')), model_prob numeric not null, -- your model probability, 0 to 1 status text not null default 'queued', -- queued, placed, error attempts int not null default 0, price_cents int, contracts int, stake_cents int, order_id text, error_msg text, created_at timestamptz not null default now() ); -- Lock both tables. Only your Edge Functions can read or write them. alter table public.bot_config enable row level security; alter table public.bet_queue enable row level security;You should see: Success. No rows returned. Both tables show in Table Editor.
- 2
Save your Kalshi keys as secrets
Supabase functions need the key in PKCS#8 format. The file starts with BEGIN PRIVATE KEY. If your Kalshi file starts with BEGIN RSA PRIVATE KEY, convert it one time with the command below. On Windows, run it in Git Bash. On a Mac, run it in Terminal.
convert-key.shopenssl pkcs8 -topk8 -nocrypt -in kalshi-key.pem -out kalshi-key-pkcs8.pemName Value KALSHI_KEY_ID The key ID from Kalshi. KALSHI_PRIVATE_KEY The full text of kalshi-key-pkcs8.pem, with the BEGIN and END lines. KALSHI_HOST https://api.elections.kalshi.com for real money. Use the demo host from docs.kalshi.com while you test. CRON_SECRET A long random password that you make up. The timer sends it, and the functions check it. Supabase dashboard > Edge Functions > Secrets > Add new secret.
Supabase gives every function SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY automatically. Do not add them.
- 3
Make a test function and check your connection
This function signs one request and reads your Kalshi balance. It places no orders. If it works, your keys and your signing are correct.
Edge Functions > Deploy a new function > Via Editor. Name it bot-test. Delete the sample code, paste this code, and click Deploy. Then open the function Details and turn off JWT verification. The CRON_SECRET check protects the function instead.
bot-test/index.ts// bot-test: checks that your Kalshi keys work. It places no orders. const HOST = Deno.env.get("KALSHI_HOST") ?? "https://api.elections.kalshi.com"; // Turn the PEM text into bytes for WebCrypto. function pemToDer(pem: string): ArrayBuffer { const b64 = pem.replace(/-----[^-]+-----/g, "").replace(/[^A-Za-z0-9+/=]/g, ""); return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)).buffer; } // Kalshi signs: timestamp + METHOD + path. Sign the path WITHOUT the query string. async function kalshiHeaders(method: string, path: string) { const key = await crypto.subtle.importKey( "pkcs8", pemToDer(Deno.env.get("KALSHI_PRIVATE_KEY")!), { name: "RSA-PSS", hash: "SHA-256" }, false, ["sign"]); const ts = Date.now().toString(); const sig = await crypto.subtle.sign( { name: "RSA-PSS", saltLength: 32 }, key, new TextEncoder().encode(ts + method + path)); return { "KALSHI-ACCESS-KEY": Deno.env.get("KALSHI_KEY_ID")!, "KALSHI-ACCESS-TIMESTAMP": ts, "KALSHI-ACCESS-SIGNATURE": btoa(String.fromCharCode(...new Uint8Array(sig))), "Content-Type": "application/json", }; } Deno.serve(async (req) => { if (req.headers.get("x-cron-secret") !== Deno.env.get("CRON_SECRET")) { return new Response("unauthorized", { status: 401 }); } const path = "/trade-api/v2/portfolio/balance"; const r = await fetch(HOST + path, { headers: await kalshiHeaders("GET", path) }); return new Response(await r.text(), { status: r.status, headers: { "Content-Type": "application/json" } }); });Run the test from your computer. Put in your project ref (it is in the dashboard address, and in Project Settings > General) and your CRON_SECRET. On Windows use curl.exe. On a Mac use curl.
test.txtcurl.exe -X POST "https://YOUR-PROJECT-REF.supabase.co/functions/v1/bot-test" -H "x-cron-secret: YOUR-CRON-SECRET"You should see: A JSON answer with your balance. The word unauthorized means the CRON_SECRET does not match. A 401 from Kalshi means the key ID or the key text is wrong. A DataError means the key is not PKCS#8, so do step 2 again.
- 4
Make the execute function
This function reads today's queued bets, checks the live Kalshi price, and places each bet that still passes your limits. It starts in paper mode, so it places nothing real until you turn live_enabled on.
Deploy a new function > Via Editor. Name it bot-execute. Paste the code. Copy the pemToDer and kalshiHeaders functions from bot-test into the marked line. Click Deploy, then turn off JWT verification the same way as in step 3.
bot-execute/index.ts// bot-execute: places queued bets that still pass your limits. // Paper mode until bot_config.live_enabled is true. import { createClient } from "jsr:@supabase/supabase-js@2"; const HOST = Deno.env.get("KALSHI_HOST") ?? "https://api.elections.kalshi.com"; // PASTE pemToDer AND kalshiHeaders FROM bot-test HERE. Deno.serve(async (req) => { if (req.headers.get("x-cron-secret") !== Deno.env.get("CRON_SECRET")) { return new Response("unauthorized", { status: 401 }); } const db = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!); const { data: cfg } = await db.from("bot_config").select("*").eq("id", 1).single(); if (!cfg || cfg.kill_switch) return Response.json({ ok: false, reason: "kill switch on" }); // Kalshi tickers use Eastern time, so use Eastern time for game_date too. const today = new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }); const { data: rows } = await db.from("bet_queue").select("*") .eq("game_date", today).in("status", ["queued", "error"]).lt("attempts", cfg.max_attempts); const { data: placed } = await db.from("bet_queue").select("stake_cents") .eq("game_date", today).eq("status", "placed"); let spent = (placed ?? []).reduce((sum: number, r: any) => sum + (r.stake_cents ?? 0), 0); const results: any[] = []; for (const row of rows ?? []) { const res = await fetch(HOST + "/trade-api/v2/markets/" + row.market_ticker); const market = (await res.json()).market ?? {}; const askDollars = row.side === "yes" ? market.yes_ask_dollars : market.no_ask_dollars; const ask = Math.round(parseFloat(askDollars ?? "0") * 100); // price in cents if (ask <= 0 || ask >= 100) { results.push({ ticker: row.market_ticker, skip: "no price" }); continue; } const edge = Number(row.model_prob) - ask / 100; if (edge < Number(cfg.min_edge)) { results.push({ ticker: row.market_ticker, skip: "edge gone", edge }); continue; } const contracts = Math.min(Math.floor(cfg.unit_cents / ask), Math.floor((cfg.daily_cap_cents - spent) / ask)); if (contracts < 1) { results.push({ ticker: row.market_ticker, skip: "daily cap reached" }); continue; } if (!cfg.live_enabled) { results.push({ ticker: row.market_ticker, paper: true, contracts, ask }); continue; } // This order type quotes from the YES side: "bid" buys YES, "ask" buys NO. const path = "/trade-api/v2/portfolio/events/orders"; const body = { ticker: row.market_ticker, client_order_id: crypto.randomUUID(), side: row.side === "yes" ? "bid" : "ask", count: String(contracts), price: ((row.side === "yes" ? ask : 100 - ask) / 100).toFixed(4), time_in_force: "good_till_canceled", self_trade_prevention_type: "taker_at_cross", }; const r = await fetch(HOST + path, { method: "POST", headers: await kalshiHeaders("POST", path), body: JSON.stringify(body) }); const text = await r.text(); if (!r.ok) { await db.from("bet_queue").update({ status: "error", attempts: row.attempts + 1, error_msg: (r.status + ": " + text).slice(0, 500) }).eq("id", row.id); results.push({ ticker: row.market_ticker, error: r.status }); continue; } const order = JSON.parse(text); await db.from("bet_queue").update({ status: "placed", attempts: row.attempts + 1, order_id: order.order_id, price_cents: ask, contracts, stake_cents: contracts * ask, error_msg: null, }).eq("id", row.id); spent += contracts * ask; results.push({ ticker: row.market_ticker, placed: contracts, ask }); } return Response.json({ ok: true, live: cfg.live_enabled, results }); });This is a starter. Before you go live, add a check that skips a game that starts in less than 10 minutes, and cap the contracts at the size available at the ask. See Our example constraints below.
- 5
Add a test bet and run in paper mode
Your scan function, or you by hand, adds rows to bet_queue. Add one row for a real game today. Get the market ticker from the markets endpoint at docs.kalshi.com. Then run bot-execute with the same curl command as step 3, with bot-execute in place of bot-test.
SQL Editor > New query.
test-bet.sqlinsert into public.bet_queue (game_date, market_ticker, side, model_prob) values ('2026-09-27', 'PASTE-THE-KALSHI-MARKET-TICKER', 'yes', 0.56);You should see: A result with paper: true and a contract count. Nothing is placed on Kalshi.
When the paper results look right for a few days, turn on real orders:
go-live.sqlupdate public.bot_config set live_enabled = true where id = 1; -- To stop everything at once: -- update public.bot_config set kill_switch = true where id = 1; - 6
Put the bot on a timer
Cron calls bot-execute at set times, so late markets get caught. The times are in UTC. 14:30 UTC is 9:30 AM Central time in the summer.
Database > Extensions. Turn on pg_cron and pg_net. Then SQL Editor > New query. Put in your project ref and your CRON_SECRET, then click Run.
schedule.sql-- Store the project URL and your cron secret in Vault (encrypted). Do this one time. select vault.create_secret('https://YOUR-PROJECT-REF.supabase.co', 'project_url'); select vault.create_secret('YOUR-CRON-SECRET', 'cron_secret'); -- Run bot-execute 4 times a day (UTC times). select cron.schedule('bot-execute', '30 14,17,20,23 * * *', $$ select net.http_post( url := (select decrypted_secret from vault.decrypted_secrets where name = 'project_url') || '/functions/v1/bot-execute', headers := jsonb_build_object( 'Content-Type', 'application/json', 'x-cron-secret', (select decrypted_secret from vault.decrypted_secrets where name = 'cron_secret')), body := '{}'::jsonb, timeout_milliseconds := 60000); $$); - 7
Check that it worked
A cron run that says succeeded only means Supabase sent the request. It does not mean a bet was placed. Check the function answer and the table.
Edge Functions > bot-execute > Logs, and Table Editor > bet_queue.
check.sql-- The answers from your functions, newest first. select status_code, content, created from net._http_response order by created desc limit 10; -- Bets that failed, with the reason. select market_ticker, attempts, error_msg from public.bet_queue where status = 'error'; - 8
Optional: use your own computer instead of the dashboard
If you like to work in files, the Supabase CLI does the same steps.
cli.txtnpx supabase login npx supabase link --project-ref YOUR-PROJECT-REF npx supabase functions new bot-execute npx supabase functions deploy bot-execute --no-verify-jwt npx supabase secrets set --env-file .env
Copy our Monte Carlo picks
No model of your own yet? You can follow the picks our Monte Carlo sim publishes and place them on Kalshi by hand.
- 1
Create a free account
Create a free BankrollSZN account. The sim is free with an account.
- 2
- 3
Read the recommended bets
Read the model's recommended bets for the day. Each card shows the side, the line and the edge.
- 4
Find the same line on Kalshi
On Kalshi, find the same game and the exact same line. If Kalshi has no market at that line, skip the pick. Do not move it to a nearby line.
- 5
Re-check the Kalshi price
Check the Kalshi price before you buy. Lines move, so an edge that was there when we published can be gone.
- 6
Use your own unit size
Use your own unit size and a daily cap. Our limits are in the section below.
- This is not financial advice. You trade at your own risk.
- 2026 is our first year modeling these markets. The record is short, and past results do not promise future results.
- Every pick is graded and public. See the performance history for each sport before you follow anything: MLB pick history and NFL pick history (open the History tab).
- Only bet what you can afford to lose. 21+. 1-800-GAMBLER.
Our example constraints
$100
Unit
Flat per bet
$600
Daily cap
Per game date, all sports
2%
Edge floor
Vs Kalshi ask, re-checked
10 min
Start buffer
No bets before start
6
Retries
Placement attempts max
4 days
Lookahead
Queue ahead, place on edge
Model gate first
Only takes picks our model marks live. Paper-mode picks are skipped.
Fill cap
Contracts capped at size available at the ask. A thin book gives a small fill, not an error.
No line shopping
If Kalshi has no strike at the exact line the model published, skip the pick. Never move it to the nearest strike.
One bet per market
Skip a market that is already queued.
Switches
A live on/off flag, an auto-execute flag, and a kill switch that stops all placement at once.
Markets
MLB game totals and run line, NFL game totals and moneyline. MLB moneyline and NFL spread are turned off in our setup.
These are our settings, not advice. Size your own unit to your own bankroll.
Gotchas we hit
Kalshi runs markets on more than one shard. Cash on one shard cannot back an order on another. The Kalshi website moves the money for you, the API does not. Turn on Kalshi's automatic balance allocation across the shards you trade. A "user not found" or "insufficient balance" error on one sport only usually means this.
Pass the exchange index when you cancel, or the cancel lands on the wrong shard.
Including the query string in the signed path gives a signature error. Sign the path only.
Match games on the team pair, not only the date, or a late game rolls into the next day.
For example JAX vs JAC, LA vs LAR. Map them or you drop teams with no error.
Match on the numeric strike, not on a half-point ladder like a sportsbook.
Grade ties as 50, not as a loss, or your record drifts.
If the bot only picks up pending rows, one failure means a missed bet for the day. Re-scan errored rows each pass.
Check what it wrote, not just the run status.
Supabase WebCrypto reads only PKCS#8 keys (BEGIN PRIVATE KEY). A key that starts with BEGIN RSA PRIVATE KEY fails with a DataError. Convert it one time with openssl.
A free project can pause after a period with no activity. Check that your project is active before game days, or use a paid plan.
FAQ
Kalshi is a CFTC-regulated exchange and offers an API for automated trading. Check availability in your state.
21+. Bet within your means. If you or someone you know has a gambling problem, call 1-800-GAMBLER. This guide is educational and is not financial advice.
How to build a Kalshi sports betting bot
A step by step guide to building your own Kalshi trading bot for MLB and NFL game lines on Supabase. Covers the four stages, the tables, the Edge Functions, the Cron timer, the exact risk limits our bot runs with, and the gotchas we hit in production.
It also shows how to copy our free Monte Carlo sim picks by hand, with the caveat that 2026 is our first modeling season.
This guide is educational. It does not give access to our API, our keys, or our bot. Use your own model and your own Kalshi account.
Frequently asked questions
Is a Kalshi bot legal?
Kalshi is a CFTC-regulated exchange and offers an API for automated trading. Check availability in your state.
Do I need to know how to code?
A little. You paste the code on this page, change your limits in one table, and read the logs. You do not need to run a server.
Why Supabase?
The free plan has a database, Edge Functions and Cron in one place. The bot needs all three, and nothing else.
Can I use BankrollSZN picks?
Yes. Our Monte Carlo picks for MLB and NFL are free with an account. See the Copy our Monte Carlo picks section above. This guide does not give access to our API or our bot. Not financial advice.
How much money do I need?
Enough that your unit is a small part of your bankroll. Our example is $100 units with a $600 daily cap.
Does the bot win?
Not guaranteed. Track every bet and grade it from settlement.