DEV Community

Cover image for Build an Odds Movement Alert Bot in Python (Telegram & Discord)
orbistats
orbistats

Posted on

Build an Odds Movement Alert Bot in Python (Telegram & Discord)

If you have ever watched a price move on a betting market and thought, "I wish I had known five minutes earlier," this tutorial is for you.

Odds don't change randomly. A sharp drop on the home side can mean injury news, lineup leaks, or heavy money. Traders, analysts, and fantasy players all care about that signal. Almost none of them want to stare at a dashboard all day.

So in this guide we'll build a Python bot that watches odds, detects meaningful movement, and pushes an alert to Telegram and Discord the moment it happens.

By the end you'll have:

A polling loop that fetches odds from a REST API
A movement detector with configurable thresholds
Telegram and Discord notifications
Duplicate-alert protection and sane rate-limit handling
A clear upgrade path to WebSockets for real-time use

Let's build it.

Why Odds Movement Matters

Odds are a market price. Like any price, the change is often more informative than the level.

Football: a 1X2 home price falling from 2.10 to 1.85 within an hour often signals confirmed lineups or sharp money.
Tennis: a sudden drift on a favourite can mean an injury or a fitness doubt before it's public.
Basketball and American Football: spread and total movements reflect late injury reports.
Cricket: toss results and pitch conditions swing prices quickly.

The same logic applies in every sport. A bot that watches these moves for you is simple to build, and it's a good project for learning API polling, state tracking, and notification design.

What You'll Need
Python 3.10+
A free API key from Orbistats
A Telegram account (for a bot token) and/or a Discord server (for a webhook)
About 30 minutes
Choosing a data source

An alert bot is only as good as its data. The painful part of odds data is that every bookmaker has its own format, so you end up writing and maintaining a different parser for each. A normalized odds feed removes that work: one schema, one parser.

That's why I'm using the Orbistats Odds API. It returns pre-match and live odds (1X2, moneyline, spreads, totals, and more) in one consistent structure. It also covers 13 sports: football, basketball, American football, cricket, tennis, baseball, esports, combat sports, volleyball, handball, ice hockey, golf, and horse racing. So the bot we build works across all of them with almost no changes.

The free tier is enough for this tutorial, and I'll show how to stay inside its limits.

Step 1: Get Your API Key
Create an account on the sign-up page.
Copy your API key from the dashboard.
Skim the documentation and the quickstart.

Authentication is a standard bearer token:

http
Authorization: Bearer YOUR_API_KEY

The base URL is:

text
https://api.orbistats.com/v1/

If you'd like to experiment before writing any code, the sandbox lets you fire requests and inspect the JSON directly.

Step 2: Project Setup
bash
mkdir odds-alert-bot && cd odds-alert-bot
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install requests python-dotenv

Create a .env file:

env
ORBISTATS_API_KEY=your_key_here

TELEGRAM_BOT_TOKEN=123456:ABC-your-token
TELEGRAM_CHAT_ID=your_chat_id

DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...

SPORT=football
MARKET=1X2
MOVE_THRESHOLD_PCT=5
POLL_SECONDS=600

Getting the Telegram values:

Message @botfather, send /newbot, and copy the token.
Send any message to your new bot.
Open https://api.telegram.org/bot/getUpdates and read chat.id from the response.

Getting the Discord webhook: Server Settings โ†’ Integrations โ†’ Webhooks โ†’ New Webhook โ†’ Copy URL.

Step 3: Fetch Odds

Create bot.py. We'll start with configuration and the API client.

python
import os
import time
import logging
import requests
from dotenv import load_dotenv

load_dotenv()

API_KEY = os.environ["ORBISTATS_API_KEY"]
BASE_URL = "https://api.orbistats.com/v1"

SPORT = os.getenv("SPORT", "football")
MARKET = os.getenv("MARKET", "1X2")
THRESHOLD = float(os.getenv("MOVE_THRESHOLD_PCT", "5"))
POLL_SECONDS = int(os.getenv("POLL_SECONDS", "600"))

TG_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
TG_CHAT = os.getenv("TELEGRAM_CHAT_ID")
DISCORD_URL = os.getenv("DISCORD_WEBHOOK_URL")

logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
log = logging.getLogger("odds-bot")

session = requests.Session()
session.headers.update({"Authorization": f"Bearer {API_KEY}"})

def fetch_odds(sport: str, market: str) -> list[dict]:
"""Fetch current odds for one sport/market.

NOTE: confirm the exact path and query params in the API Reference.
"""
url = f"{BASE_URL}/{sport}/odds"
resp = session.get(url, params={"market": market}, timeout=15)
resp.raise_for_status()
payload = resp.json()
# Some APIs wrap results in {"data": [...]}; handle both shapes.
return payload.get("data", payload) if isinstance(payload, dict) else payload
Enter fullscreen mode Exit fullscreen mode

The path and parameters above follow the pattern used in the docs examples (/v1/football/...). Check the API reference for the exact odds route and field names, and adjust the one function above if they differ.

Step 4: Normalize the Data

Even with a normalized API, I like to add one more layer in my own code: a function that converts whatever the API returns into a small internal shape. If the response format ever changes, you only edit this one function.

python
def normalize(item: dict) -> dict | None:
"""Turn one API item into {key, label, prices}.

Expected input resembles:
{
  "match_id": "match_50231",
  "home": {"name": "Manchester City"},
  "away": {"name": "Arsenal"},
  "market": "1X2",
  "odds": {"home": 1.91, "draw": 3.40, "away": 4.20}
}
"""
try:
    match_id = item["match_id"]
    home = item["home"]["name"] if isinstance(item["home"], dict) else item["home"]
    away = item["away"]["name"] if isinstance(item["away"], dict) else item["away"]
    prices = {k: float(v) for k, v in item["odds"].items()}
except (KeyError, TypeError, ValueError):
    return None

return {
    "key": f"{match_id}:{item.get('market', MARKET)}",
    "label": f"{home} vs {away}",
    "prices": prices,
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Detect Odds Movement

This is the heart of the bot. We store the last seen price for every outcome and compare each new reading against it.

We use percentage change rather than absolute change. A move from 1.20 to 1.30 is very different from 8.00 to 8.10, and percentages handle both fairly.

python
last_seen: dict[str, dict[str, float]] = {}

def pct_change(old: float, new: float) -> float:
return (new - old) / old * 100

def detect_moves(match: dict) -> list[dict]:
"""Compare current prices to the previous snapshot."""
key, prices = match["key"], match["prices"]
previous = last_seen.get(key)
moves = []

if previous:
    for outcome, new_price in prices.items():
        old_price = previous.get(outcome)
        if old_price is None or old_price == 0:
            continue
        change = pct_change(old_price, new_price)
        if abs(change) >= THRESHOLD:
            moves.append({
                "outcome": outcome,
                "old": old_price,
                "new": new_price,
                "pct": change,
            })

# Always update the snapshot so the next poll compares to now.
last_seen[key] = prices
return moves
Enter fullscreen mode Exit fullscreen mode

Notice that the first poll produces no alerts. It only records a baseline. That's intentional: you can't call something a "movement" without a before and an after.

Reading the direction
Odds shortening (price falls): the market considers the outcome more likely.
Odds drifting (price rises): the market considers it less likely.

We'll show this with an arrow in the message so it reads at a glance.

Step 6: Send Telegram Alerts
python
def format_alert(match: dict, move: dict) -> str:
arrow = "๐Ÿ“‰ shortened" if move["pct"] < 0 else "๐Ÿ“ˆ drifted"
return (
f"โšก Odds movement: {match['label']}\n"
f"Outcome: {move['outcome'].upper()}\n"
f"{move['old']:.2f} โ†’ {move['new']:.2f} "
f"({move['pct']:+.1f}%) {arrow}"
)

def send_telegram(text: str) -> None:
if not (TG_TOKEN and TG_CHAT):
return
url = f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage"
r = requests.post(url, json={"chat_id": TG_CHAT, "text": text}, timeout=10)
if not r.ok:
log.warning("Telegram failed: %s %s", r.status_code, r.text[:200])
Step 7: Send Discord Alerts

Discord webhooks are even simpler. There's no bot account and no OAuth, just a POST.

python
def send_discord(text: str) -> None:
if not DISCORD_URL:
return
r = requests.post(DISCORD_URL, json={"content": text}, timeout=10)
if not r.ok:
log.warning("Discord failed: %s %s", r.status_code, r.text[:200])

def notify(text: str) -> None:
send_telegram(text)
send_discord(text)

Want richer messages? Discord supports embeds, with colors, fields, and timestamps. A green embed for shortening and a red one for drifting looks great in a trading channel.

Step 8: The Main Loop (With Rate-Limit Awareness)

Here's the part many tutorials skip. Polling costs requests. On the free tier you get a limited number per day (150 at the time of writing, per the docs, so always double-check your plan on the pricing page).

Do the math before choosing an interval:

text
86,400 seconds/day รท 600 seconds = 144 requests/day

A 10-minute interval for a single sport fits inside 150 requests per day. If you poll five sports every minute, you'll burn through the limit in minutes.

python
def run_once() -> int:
alerts = 0
for raw in fetch_odds(SPORT, MARKET):
match = normalize(raw)
if not match:
continue
for move in detect_moves(match):
notify(format_alert(match, move))
alerts += 1
return alerts

def main() -> None:
log.info("Starting odds bot: sport=%s market=%s threshold=%s%%",
SPORT, MARKET, THRESHOLD)
backoff = 1
while True:
try:
sent = run_once()
log.info("Poll complete, alerts sent: %d", sent)
backoff = 1
time.sleep(POLL_SECONDS)
except requests.HTTPError as e:
status = e.response.status_code if e.response is not None else "?"
if status == 429:
wait = min(backoff * 60, 900)
log.warning("Rate limited. Sleeping %ss", wait)
time.sleep(wait)
backoff *= 2
else:
log.error("HTTP error: %s", e)
time.sleep(30)
except requests.RequestException as e:
log.error("Network error: %s", e)
time.sleep(30)

if name == "main":
main()

Run it:

bash
python bot.py

On the first poll you'll see a baseline. After the next one, any outcome that moved beyond your threshold fires an alert in both channels.

Step 9: Avoid Alert Spam (Cooldowns)

A price can wobble around your threshold and fire repeatedly. Add a cooldown so each outcome alerts at most once per window:

python
COOLDOWN_SECONDS = 1800
last_alert_at: dict[str, float] = {}

def should_alert(match_key: str, outcome: str) -> bool:
k = f"{match_key}:{outcome}"
now = time.time()
if now - last_alert_at.get(k, 0) < COOLDOWN_SECONDS:
return False
last_alert_at[k] = now
return True

Then in run_once:

python
for move in detect_moves(match):
if should_alert(match["key"], move["outcome"]):
notify(format_alert(match, move))
alerts += 1
Step 10: Make It Work for All 13 Sports

Because the data is normalized, supporting more sports is mostly configuration. Loop over a list:

python
SPORTS = ["football", "basketball", "tennis", "cricket"]

Then call fetch_odds(sport, MARKET) for each. Keep the request math in mind. Four sports at 10 minutes means 576 calls a day, which exceeds the free tier but fits comfortably on a paid plan.

Each sport has its own coverage details and quirks, so browse the sport pages for what's available:

Football and Basketball for the highest-volume markets
Cricket and Tennis for fast-moving live prices
Esports and Horse Racing if you want niche markets with less competition

One tip: different sports suit different thresholds. Tennis moves quickly, so use a higher threshold (8 to 10%). Football 1X2 is steadier, so 4 to 5% is a good start. Store a threshold per sport in a dict.

Going Real-Time: From Polling to WebSockets

Polling has one fundamental weakness: you only see the market every N minutes. A move that happens and reverses between two polls is invisible.

For true real-time alerts, switch from request/response to a persistent connection. The WebSocket API pushes updates to you as they happen, so you stop polling and stop worrying about request quotas.

The change to our bot is small. Everything after the data arrives (normalize, detect_moves, notify) stays identical. Only the source changes:

python

Conceptual sketch, check the WebSocket docs for the real URL and message format

import json, websocket

def on_message(ws, message):
data = json.loads(message)
match = normalize(data)
if match:
for move in detect_moves(match):
if should_alert(match["key"], move["outcome"]):
notify(format_alert(match, move))

ws = websocket.WebSocketApp(
"wss://",
header={"Authorization": f"Bearer {API_KEY}"},
on_message=on_message,
)
ws.run_forever(reconnect=5)

If you'd rather not hold a connection open yourself, webhooks are the third option. The provider calls your URL when something changes, so you can host a tiny Flask or FastAPI endpoint and let the events come to you.

Which should you pick?

Method Best for Trade-off
REST polling Learning, low-volume, free tier Delayed, uses quota
WebSocket Live odds, trading-style alerts Needs reconnect logic
Webhooks Server-side event handling Needs a public endpoint
Add Historical Context (Optional but Powerful)

Here's a trick that makes alerts far more useful: attach context. Is a 6% move big or normal for this market?

Historical data lets you answer that. If you store past movement per market, you can alert only on moves that exceed the typical volatility, instead of using one fixed number. The Historical Sports Data API provides multi-season archives, including closing odds, which are ideal for backtesting your thresholds before you trust them with real decisions.

If you want to go deeper, the Sports Statistics API lets you combine movement with form and team stats, so an alert reads "Home price shortened 6% and they are unbeaten in five" instead of just a number.

Deploying the Bot

A bot on your laptop stops when your laptop sleeps. A few easy options:

A small VPS (any $5/month box) with systemd keeping the script alive
Docker on a home server or Raspberry Pi
A free-tier cloud VM for light usage

A minimal systemd unit:

ini
[Unit]
Description=Odds Movement Alert Bot
After=network-online.target

[Service]
WorkingDirectory=/opt/odds-alert-bot
ExecStart=/opt/odds-alert-bot/.venv/bin/python bot.py
Restart=always
RestartSec=10
EnvironmentFile=/opt/odds-alert-bot/.env

[Install]
WantedBy=multi-user.target

Then:

bash
sudo systemctl enable --now odds-bot
journalctl -u odds-bot -f
Production Checklist

Before you rely on this, run through the list:

Persist state. Right now last_seen lives in memory, so a restart means a fresh baseline. Use SQLite or Redis if you care about continuity.
Never commit .env. Add it to .gitignore.
Handle API changes. Check the changelog and the status page when something looks off.
Log everything. When an alert doesn't fire, logs tell you why.
Respect quotas. Track your own request count and log it daily.
Treat alerts as information, not advice. Odds movement is a signal. It isn't a guarantee of anything.
Ideas to Extend This Project

Once the basics work, there's a lot of room to grow:

Slash commands (/watch Arsenal) so users choose what to track
Multi-bookmaker comparison, alerting when one price diverges from the rest
Charts, rendering a mini odds-history image with matplotlib and attaching it to the alert
Per-user thresholds stored in a database
Steam-move detection, flagging when several outcomes move together
A web dashboard built on top of the same detector

If you want ready-made code for each endpoint, the examples page and the SDKs are worth a look, and the guides cover related topics.

Full Project Structure
text
odds-alert-bot/
โ”œโ”€โ”€ .env
โ”œโ”€โ”€ .gitignore
โ”œโ”€โ”€ bot.py
โ””โ”€โ”€ requirements.txt

requirements.txt:

text
requests
python-dotenv
websocket-client # only if you add the WebSocket version
Wrapping Up

We built a complete alert pipeline in under 150 lines:

Fetch odds from a normalized API
Normalize them into your own small shape
Detect percentage movement against the last snapshot
Filter with thresholds and cooldowns
Notify through Telegram and Discord

The architecture is deliberately modular. Swap polling for WebSockets, add more sports, or bolt on statistics, and the core logic barely changes. That's the real advantage of building on a clean, normalized data layer: your effort goes into your product, not into parsing.

If you build something with this, I'd love to hear about it in the comments. And if you hit a snag, drop your error message below and I'll help debug.

Happy building! ๐Ÿš€

Disclaimer: This tutorial is for educational and informational purposes. Odds data is not financial or betting advice. Please follow the laws and regulations in your region.

Top comments (0)