How to Build a Sports Betting Bot in Python (Alerts, Not Auto-Betting)
Build a Python bot that pulls odds, flags prices above the no-vig fair line and sharp line moves, and posts alerts to Discord. Polling, scheduling, webhooks and the WebSocket stream, with credit costs, and why the bot should never place bets itself.
This tutorial shows how to build a sports betting bot that watches the odds and tells you when something deserves a closer look: a sportsbook price that beats the no-vig fair line from a sharp book, or a line that moves sharply. It is written in Python, runs on a schedule, and posts alerts to a Discord channel. Later sections replace polling with webhooks and a WebSocket stream.
The bot finds and reports. It does not place bets, and the next section explains why that line matters.
What this bot does, and what it doesn't
The bot does four things: it pulls prices from several books, computes a fair price from a sharp reference, flags prices that are better than fair or that moved by more than a threshold, and sends a message.
It does not log in to sportsbooks or place wagers. Bots that place bets automatically break the terms of most sportsbooks, which prohibit automated or scripted access to accounts, and books can void bets, limit or close accounts that use them. Keep the human in the loop: the bot surfaces a price, a person checks it at the book and decides.
Two more honest caveats. Prices move constantly, so an alert is an observation at a moment in time, and it can be stale by the time you read it. And books limit players who consistently take good prices. Nothing here is a guarantee of profit or advice on what to bet. PreciseAPI is a data provider only.
How to build a sports betting bot: the moving parts
Whatever language you use, how to build a sports betting bot comes down to the same four parts: fetch, detect, notify and schedule. The fetch step can be done three ways, depending on plan and how fast you need to react:
| Delivery | How it works | Plans |
|---|---|---|
| REST polling | Call /v1/odds on a timer, 2 credits per call | Edge, Sharp, Syndicate |
| Webhooks | PreciseAPI posts a signed line.moved event when a price moves past your threshold, 1 credit per alert | Every paid plan |
| WebSocket | One connection receives a snapshot, then every change | Syndicate |
We'll build the polling version first, because it works on every paid plan and the detection code carries over unchanged.
Step 1: Pull odds from a betting bot API
A betting bot API needs three things from the data: many books in one request, a sharp book to use as the reference, and ids that let you match the same bet across books. One /v1/odds call covers a whole league, and several leagues if you comma-separate them, for 2 credits. Install requests, set PRECISEAPI_KEY in the environment, and start a file called bot.py; the Python blocks in steps 1 to 5 go into it, in order.
import os
import time
from datetime import datetime, timezone
import requests
API = "https://api.preciseapi.com/v1"
SESSION = requests.Session()
SESSION.headers["Authorization"] = f"Bearer {os.environ['PRECISEAPI_KEY']}"
SHARP = "pinnacle"
BOOKS = ["pinnacle", "draftkings", "fanduel", "betmgm", "caesars", "fanatics"]
LEAGUES = "nba,nhl"
def get_odds():
params = {"league": LEAGUES, "markets": "moneyline,spread,total", "books": ",".join(BOOKS)}
for attempt in range(4):
res = SESSION.get(f"{API}/odds", params=params, timeout=15)
if res.status_code == 429 or res.status_code >= 500:
time.sleep(int(res.headers.get("Retry-After", 2 ** attempt)))
continue
res.raise_for_status() # other 4xx errors mean the request is wrong: fix it, don't retry
return res.json()
raise RuntimeError("PreciseAPI unavailable after retries")
Rate-limited and failed requests are never charged, and a 429 carries Retry-After, so the retry loop costs nothing extra.
Step 2: Find prices above the no-vig fair line
There is no /v1/ev endpoint in the REST API: expected value is computed from the prices, which is what this step does. Take the sharp book's market, remove its margin to get fair probabilities, then compare every other book's price with them. The expected value of a price per unit staked is fair probability × decimal odds − 1.
Because the same outcome at the same number shares a selection id across every source, matching a DraftKings price to Pinnacle's fair probability is a dictionary lookup.
def to_decimal(american):
return 1 + american / 100 if american > 0 else 1 + 100 / -american
def age_seconds(ts):
return (datetime.now(timezone.utc) - datetime.fromisoformat(ts.replace("Z", "+00:00"))).total_seconds()
def fair_probabilities(event, max_age=120):
"""Selection id -> no-vig probability, from the sharp book's markets."""
fair = {}
for book in event["bookmakers"]:
if book["id"] != SHARP:
continue
for m in book["markets"]:
if m["suspended"] or age_seconds(m["updated_at"]) > max_age or len(m["outcomes"]) < 2:
continue
implied = {o["id"]: 1 / to_decimal(o["price"]) for o in m["outcomes"]}
total = sum(implied.values())
fair.update({sel: p / total for sel, p in implied.items()})
return fair
def find_edges(event, min_edge=0.02, max_age=120):
fair = fair_probabilities(event, max_age)
for book in event["bookmakers"]:
if book["id"] == SHARP:
continue
for m in book["markets"]:
if m["suspended"] or age_seconds(m["updated_at"]) > max_age:
continue
for o in m["outcomes"]:
p = fair.get(o["id"])
if p is None:
continue # the sharp book doesn't price this exact line
ev = p * to_decimal(o["price"]) - 1
if ev >= min_edge:
yield {"book": book["id"], "market": m["key"], "outcome": o["name"], "point": o.get("point"),
"price": o["price"], "fair": 1 / p, "ev": ev}
This uses the multiplicative method to remove the margin, the simplest of several; our guide on how to devig odds compares the others. The age filter matters as much as the math: a stale sharp price produces confident, wrong alerts. The positive EV API page describes the same approach, and the EV calculator is handy for checking a single alert by hand.
Step 3: Detect line moves
A move is a change in price between two reads. Measure it in cents, continuously across even money, so -110 to -130 is 20 cents and -105 to +105 is 10:
def cents(american):
"""Position on a continuous scale across even money: -110 -> -10, +105 -> 5."""
return american - 100 if american >= 100 else american + 100
last_seen = {} # (book, selection id) -> last price
def find_moves(event, min_cents=20):
for book in event["bookmakers"]:
for m in book["markets"]:
for o in m["outcomes"]:
key = (book["id"], o["id"])
before = last_seen.get(key)
last_seen[key] = o["price"]
if before is not None and abs(cents(o["price"]) - cents(before)) >= min_cents:
yield {"book": book["id"], "market": m["key"], "outcome": o["name"], "point": o.get("point"),
"from": before, "to": o["price"]}
When a book changes a spread or total number, the outcomes get new selection ids (the id includes the point), so this function treats them as new rather than as a move. To alert on number changes too, also track each market's line keyed by book, event, market key and period. The line movement API page covers /v1/history if you want the full path of a move rather than two snapshots.
Step 4: Send automated betting alerts
Discord is the easiest destination: create a webhook in the channel's settings and post JSON to it. The cooldown stops the same alert repeating every cycle:
DISCORD_URL = os.environ["DISCORD_WEBHOOK_URL"]
COOLDOWN = 15 * 60
sent = {} # alert key -> time last sent
def notify(event, kind, a):
key = (kind, event["id"], a["book"], a["market"], a["outcome"], a.get("point"))
if time.time() - sent.get(key, 0) < COOLDOWN:
return
sent[key] = time.time()
game = f'{event["away"]["abbr"]} @ {event["home"]["abbr"]}'
num = "" if a.get("point") is None else f' {a["point"]:+g}' if a["market"] == "spread" else f' {a["point"]:g}'
if kind == "ev":
text = f'Positive EV {game}: {a["outcome"]}{num} {a["price"]:+d} at {a["book"]} (fair {a["fair"]:.2f}, EV {a["ev"]:.1%})'
else:
text = f'Move {game}: {a["outcome"]}{num} at {a["book"]}, {a["from"]:+d} -> {a["to"]:+d}'
requests.post(DISCORD_URL, json={"content": text, "allowed_mentions": {"parse": []}}, timeout=10)
Automated betting alerts are only useful if people trust them, so include the book, the number, the price and a time in every message, and never phrase an alert as a pick. Telegram, Slack and email work the same way with a different POST. Our Discord odds bot tutorial builds a fuller relay with embeds, batching and Discord rate-limit handling.
Step 5: Schedule the betting bot in Python
The simplest scheduler is a loop that runs a cycle, then sleeps for the rest of the interval:
def run(interval=60):
while True:
started = time.time()
try:
body = get_odds()
for event in body["data"]:
for a in find_edges(event):
notify(event, "ev", a)
for a in find_moves(event):
notify(event, "move", a)
except Exception as exc: # log and carry on; the next cycle tries again
print("cycle failed:", exc)
time.sleep(max(0, interval - (time.time() - started)))
if __name__ == "__main__":
run()
Run it as a long-lived process under systemd, Docker with a restart policy, or any host that keeps a worker alive. A cron job also works, but then last_seen and sent must be saved to a file or SQLite between runs. Running a betting bot in Python this way costs 2 credits per cycle: once a minute is 86,400 credits a month, which fits Edge's 150,000; every 30 seconds is 172,800, which needs Sharp's unlimited credits. Edge and Sharp serve pre-game odds, so games drop out of the responses once they start.
Going real-time: webhooks and the WebSocket
Webhooks
If line moves are all you need, let PreciseAPI do the detection. Create a line-move alert rule in the dashboard, narrowed to leagues, books and markets, with a threshold in cents, and point it at a webhook endpoint. Rules cover full-game moneyline, spread and total markets; a market fires at most once every 10 minutes per rule, and each delivery costs 1 credit. A small Flask receiver verifies the signature and forwards the message:
import hashlib
import hmac
import json
import os
import threading
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["PRECISEAPI_WEBHOOK_SECRET"].encode()
def verify(raw, header, tolerance=300):
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
if not parts.get("t", "").isdigit() or abs(time.time() - int(parts["t"])) > tolerance:
return False
expected = hmac.new(SECRET, parts["t"].encode() + b"." + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
@app.post("/webhooks/preciseapi")
def hook():
raw = request.get_data() # the raw bytes, not re-serialized JSON
if not verify(raw, request.headers.get("PreciseAPI-Signature", "")):
abort(400)
evt = json.loads(raw)
d = evt["data"]
if evt["type"] == "line.moved" and d.get("move"):
text = f'{d["book"]["name"]}: {d["move"]["outcome"]} {d["move"]["from"]} -> {d["move"]["to"]}'
threading.Thread(target=post_to_discord, args=(text,), daemon=True).start()
return "", 200 # acknowledge quickly; deliveries must get a 2xx within 5 seconds
post_to_discord is the requests.post call from step 4. Deliveries can arrive more than once, so de-duplicate on the top-level id. The official Python package also ships a signature verifier; see the webhooks docs.
WebSocket
On Syndicate, the stream replaces polling entirely and includes in-play prices. You receive a snapshot in the same shape as /v1/odds, then deltas of changed markets every 250 ms. Keep a fair-price table from the sharp book's updates and check every other book's change against it. This version reuses to_decimal, SHARP and BOOKS from bot.py:
import asyncio
import json
import websockets
fair = {} # selection id -> no-vig probability, kept current from the sharp book
def on_market(event_id, book, m):
if book == SHARP:
implied = {o["id"]: 1 / to_decimal(o["price"]) for o in m["outcomes"]}
total = sum(implied.values())
fair.update({sel: p / total for sel, p in implied.items()})
return
for o in m["outcomes"]:
p = fair.get(o["id"])
if p and p * to_decimal(o["price"]) - 1 >= 0.02:
print("positive EV", event_id, book, m["key"], o["name"], o["price"]) # wire this to notify()
async def stream():
headers = {"Authorization": f"Bearer {os.environ['PRECISEAPI_KEY']}"}
sub = {"type": "subscribe", "leagues": ["nba", "nhl"], "books": BOOKS, "markets": ["moneyline", "spread", "total"]}
async with websockets.connect("wss://api.preciseapi.com/v1/stream", additional_headers=headers) as ws:
await ws.send(json.dumps(sub))
async for raw in ws:
msg = json.loads(raw)
if msg["type"] == "snapshot":
for event in msg["data"]:
for b in event["bookmakers"]:
for m in b["markets"]:
on_market(event["id"], b["id"], m)
elif msg["type"] == "delta":
for change in msg["changes"]:
on_market(change["event_id"], change["book"], change["market"])
asyncio.run(stream())
A production client also watches the seq numbers, sends resync when one is skipped, handles large snapshots that continue in snapshot_part frames, and reconnects with backoff. The preciseapi[stream] client in the SDK docs does all of that for you, and the streaming docs describe the protocol. The stream costs 1 credit per connection-minute on a plan with unlimited credits.
Skip the math: the MCP tools
If you'd rather ask than code, the odds API MCP server exposes the same analysis as tools for Claude, ChatGPT and other AI clients on any paid plan: find_outliers ranks prices that beat the no-vig market (consensus or a sharp book such as Pinnacle), find_arbitrage finds sides whose combined implied probability is under 100%, and get_line_history shows how a game's lines moved. Each call costs the same credits as the REST endpoint it reads.
Where to take the bot next
Once you know how to build a sports betting bot for one league and one kind of alert, most extensions are configuration rather than new architecture:
- More markets. Add
team_totalor period markets withperiods=1h,1q; the selection-id matching works unchanged. Player props come from/v1/propsat 3 credits per request, 25 events at a time. - Arbitrage. For each selection group, take the best price per side across books and flag markets whose implied probabilities add up to less than 1. The arbitrage API page walks through the check.
- Closing line value. Log every alert with its price, then compare it with the closing price from
/v1/historyafter the game starts. That tells you whether the alerts were early, which is a better test of the logic than short-term results. - Per-user rules. Store thresholds, books and leagues per subscriber, and route each alert to the right channel.
What it costs
| Setup | Credits | Plan |
|---|---|---|
| Poll one call a minute | 86,400 a month | Edge ($49.99, 150,000 credits) |
| Poll every 30 seconds | 172,800 a month | Sharp ($199.99, unlimited) |
| Line-move webhooks | 1 per delivered alert | Every paid plan |
| WebSocket with in-play prices | 1 per connection-minute | Syndicate ($249.99, unlimited) |
The odds API pricing page has the full comparison, including webhook endpoint and alert rule limits per plan.
Frequently asked questions
Can a sports betting bot place bets automatically?
It can be built to, but automated betting breaks the terms of most sportsbooks, and books can void bets or close accounts that use bots. This tutorial builds an alerting bot: it reports prices and moves, and a person decides at the book. PreciseAPI provides data only and can't place bets.
Does PreciseAPI have an EV or arbitrage endpoint?
Not in the REST API. You compute expected value and arbitrage from /v1/odds or /v1/best-lines data, as in step 2, or use the MCP tools find_outliers and find_arbitrage, which do the calculation for you.
How much does it cost to run a betting bot?
Polling /v1/odds once a minute uses 86,400 credits a month, within the Edge plan's 150,000. Faster polling needs Sharp's unlimited credits, and in-play prices over the WebSocket need Syndicate.
Does a positive EV alert mean the bet will win?
No. Expected value is a long-run average that depends on your fair price being right, the price can move before you act, and books limit players who keep taking good prices. Treat alerts as information to verify, not as bets to place.
Should my bot poll, use webhooks or use the WebSocket?
Polling is the simplest and works on every paid plan. Webhooks suit line-move alerts without holding a connection. The WebSocket on Syndicate is the right choice for in-play prices or anything that must react within seconds.