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:

DeliveryHow it worksPlans
REST pollingCall /v1/odds on a timer, 2 credits per callEdge, Sharp, Syndicate
WebhooksPreciseAPI posts a signed line.moved event when a price moves past your threshold, 1 credit per alertEvery paid plan
WebSocketOne connection receives a snapshot, then every changeSyndicate

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:

What it costs

SetupCreditsPlan
Poll one call a minute86,400 a monthEdge ($49.99, 150,000 credits)
Poll every 30 seconds172,800 a monthSharp ($199.99, unlimited)
Line-move webhooks1 per delivered alertEvery paid plan
WebSocket with in-play prices1 per connection-minuteSyndicate ($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.

Keep reading