BettingLab

Build a Discord Odds Alert Bot in Python

Marcus Hale
Marcus Hale

Build a Discord Odds Alert Bot in Python

If you're running a betting group, managing a model for a syndicate, or just tired of manually checking lines across books, a Discord odds alert bot in Python is the kind of tool that actually earns its keep. The setup I'm going to walk you through hits the MoneyLine API on a polling loop, filters for edges above a threshold you define, and fires a formatted embed into a Discord channel. No fluff. Let's ship it.

This isn't a weekend hackathon toy. The architecture below is the same pattern I'd use in production — env vars for secrets, async Discord client, clean separation between data-fetch and notification logic. You can extend it to run on a $5 DigitalOcean droplet or a Lambda function with an EventBridge trigger.


Why This Stack: MoneyLine API + Discord

Discord is where serious betting groups actually operate. It has webhooks, slash commands, roles for access control, and it's free. Combining it with the MoneyLine API gives you structured odds data with EV already calculated — you're not scraping, you're consuming a clean JSON feed.

The two endpoints doing the work here:

The flow: poll /v1/edge every N minutes → filter for edge_pct >= threshold → check if alert already sent (dedup) → post embed to Discord webhook.

For context on how this compares to building on other data providers, check out our API comparison page.


Project Structure and Setup

discord-odds-bot/
├── bot.py
├── alerter.py
├── dedup.py
├── .env
└── requirements.txt

requirements.txt

discord.py==2.3.2
httpx==0.27.0
python-dotenv==1.0.1
asyncio

.env

MONEYLINE_API_KEY=your_api_key_here
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_TOKEN
EDGE_THRESHOLD=5.0
POLL_INTERVAL_SECONDS=300
SPORT_FILTER=baseball_mlb

Get your API key from the MoneyLine API free tier — it includes 1,000 credits/month, which is enough to poll every 5 minutes through a full MLB game slate without burning your quota.


The Core Code

dedup.py — Avoid Spamming Duplicate Alerts

# dedup.py
# Simple in-memory dedup store. Swap for Redis if you scale horizontally.

from datetime import datetime, timedelta

class AlertDeduplicator:
    def __init__(self, ttl_minutes: int = 120):
        self._seen: dict[str, datetime] = {}
        self._ttl = timedelta(minutes=ttl_minutes)

    def is_new(self, alert_key: str) -> bool:
        now = datetime.utcnow()
        if alert_key in self._seen:
            if now - self._seen[alert_key] < self._ttl:
                return False  # already sent recently
        self._seen[alert_key] = now
        return True

    def purge_expired(self):
        now = datetime.utcnow()
        self._seen = {k: v for k, v in self._seen.items() if now - v < self._ttl}

alerter.py — Fetch Edges and Build Discord Embeds

# alerter.py

import httpx
import os
from dedup import AlertDeduplicator

API_BASE = "https://mlapi.bet"
API_KEY = os.environ["MONEYLINE_API_KEY"]
WEBHOOK_URL = os.environ["DISCORD_WEBHOOK_URL"]
EDGE_THRESHOLD = float(os.environ.get("EDGE_THRESHOLD", "5.0"))
SPORT_FILTER = os.environ.get("SPORT_FILTER", "baseball_mlb")

dedup = AlertDeduplicator(ttl_minutes=120)

HEADERS = {
    "Authorization": f"Bearer {API_KEY}",
    "Accept": "application/json",
}


def edge_color(edge_pct: float) -> int:
    """Return a Discord embed color based on edge size."""
    if edge_pct >= 15:
        return 0xFF4444   # red — screaming value
    elif edge_pct >= 10:
        return 0xFF8C00   # orange
    elif edge_pct >= 5:
        return 0x00C853   # green
    return 0x888888       # grey — shouldn't reach here


async def fetch_edges(client: httpx.AsyncClient) -> list[dict]:
    resp = await client.get(
        f"{API_BASE}/v1/edge",
        headers=HEADERS,
        params={
            "sport": SPORT_FILTER,
            "min_edge": EDGE_THRESHOLD,
            "limit": 25,
        },
        timeout=10.0,
    )
    resp.raise_for_status()
    data = resp.json()
    return data.get("edges", [])


async def fetch_event_odds(client: httpx.AsyncClient, event_id: str) -> dict:
    resp = await client.get(
        f"{API_BASE}/v1/odds",
        headers=HEADERS,
        params={"event_id": event_id, "markets": "h2h,spreads"},
        timeout=10.0,
    )
    resp.raise_for_status()
    return resp.json()


def build_embed(edge: dict, odds_context: dict) -> dict:
    home = odds_context.get("home_team", "Home")
    away = odds_context.get("away_team", "Away")
    book = edge.get("book", "unknown")
    market = edge.get("market_type", "h2h")
    side = edge.get("side", "")
    american_odds = edge.get("odds_american", "N/A")
    edge_pct = edge.get("edge_pct", 0.0)
    no_vig_prob = edge.get("no_vig_prob", None)
    commence = odds_context.get("commence_time", "TBD")

    prob_str = f"{no_vig_prob*100:.1f}%" if no_vig_prob else "N/A"

    return {
        "embeds": [
            {
                "title": f"🔔 Edge Alert: {away} @ {home}",
                "color": edge_color(edge_pct),
                "fields": [
                    {"name": "Book", "value": book, "inline": True},
                    {"name": "Market", "value": market.upper(), "inline": True},
                    {"name": "Side", "value": side, "inline": True},
                    {"name": "Odds", "value": str(american_odds), "inline": True},
                    {"name": "Edge", "value": f"{edge_pct:.2f}%", "inline": True},
                    {"name": "No-Vig Prob", "value": prob_str, "inline": True},
                    {"name": "Game Time (UTC)", "value": commence, "inline": False},
                ],
                "footer": {"text": "MoneyLine API • BettingLab"},
            }
        ]
    }


async def post_to_discord(payload: dict):
    async with httpx.AsyncClient() as client:
        resp = await client.post(WEBHOOK_URL, json=payload, timeout=8.0)
        resp.raise_for_status()


async def run_scan():
    async with httpx.AsyncClient() as client:
        edges = await fetch_edges(client)

        dedup.purge_expired()

        for edge in edges:
            event_id = edge.get("event_id")
            side = edge.get("side", "")
            book = edge.get("book", "")
            alert_key = f"{event_id}:{side}:{book}"

            if not dedup.is_new(alert_key):
                continue

            try:
                odds_ctx = await fetch_event_odds(client, event_id)
            except Exception as e:
                print(f"[warn] Could not fetch odds for {event_id}: {e}")
                odds_ctx = {}

            embed = build_embed(edge, odds_ctx)
            await post_to_discord(embed)
            print(f"[alert] Sent: {alert_key} | edge={edge.get('edge_pct'):.2f}%")

bot.py — The Polling Entry Point

# bot.py

import asyncio
import os
from dotenv import load_dotenv
from alerter import run_scan

load_dotenv()

POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL_SECONDS", 300))


async def main():
    print(f"[bot] Starting odds alert loop. Polling every {POLL_INTERVAL}s.")
    while True:
        try:
            await run_scan()
        except Exception as e:
            print(f"[error] Scan failed: {e}")
        await asyncio.sleep(POLL_INTERVAL)


if __name__ == "__main__":
    asyncio.run(main())

What the Output Looks Like

When an edge fires, your Discord channel gets an embed like this (rendered):

🔔 Edge Alert: Phillies @ Mets
Book: DraftKings    Market: H2H    Side: Mets ML
Odds: +138          Edge: 8.41%    No-Vig Prob: 45.2%
Game Time (UTC): 2026-07-14T23:10:00Z
─────────────────────────────────────
MoneyLine API • BettingLab

Color-coded: green for 5-10% edge, orange for 10-15%, red for anything over 15%. You'll notice these immediately in a busy channel. The dedup logic means if you're polling every 5 minutes and that Mets line holds, you're not getting 24 copies of the same alert over two hours.


Tuning and Extensions

Adjust the threshold by sport. MLB moneylines move less than NFL spreads pre-game. I run EDGE_THRESHOLD=5.0 for MLB and bump it to 7.0 for NBA player props to cut noise.

Add a /v1/players/trending layer. If you're tracking player prop edges, pull trending players and cross-reference the edge feed to add injury/status context to the embed. It's one extra API call per alert.

Role mentions. If your Discord server has a @sharp-alerts role, inject <@&ROLE_ID> into the embed content field for high-edge alerts (>12%). Discord won't ping on embeds alone — you need a separate content key in the webhook payload.

Rate limit awareness. The MoneyLine API returns standard rate-limit headers. Parse X-RateLimit-Remaining in your httpx responses and back off if you're close. At 1k credits/month on the free tier, a 5-minute poll interval over 30 days costs roughly 8,640 requests — you'll want to upgrade before running this across multiple sports simultaneously.

For more on getting the most out of the free tier and understanding credit consumption, see our full API docs.

If you're evaluating how the edge calculations here compare to building your own no-vig model, the EV betting explainer is worth the 10 minutes.


FAQ

What does the /v1/edge endpoint actually return?

It returns a list of edges across books and markets, with fields including event_id, side, book, odds_american, edge_pct, and no_vig_prob. The edge_pct is the percentage difference between the implied no-vig probability and the book's offered price — positive means you're getting better odds than the fair line.

How do I avoid getting rate-limited by Discord webhooks?

Discord allows 30 requests per 60 seconds per webhook. If your scan returns 25 alerts at once on a loaded slate, add a asyncio.sleep(2) between each post_to_discord call. In practice, dedup handles most of this since most edges persist across polls.

Can I run this on a free server or cloud function?

Yes. A Railway.app free tier instance or a single AWS Lambda with EventBridge cron works fine for a 5-minute poll interval. Lambda cold-start latency doesn't matter here since a few seconds of delay on a 5-minute cycle is noise.

What's the difference between using /v1/edge vs. building my own EV calc on top of /v1/odds?

/v1/edge does the no-vig math for you — it pins the market price by averaging sharp books and strips the juice before comparing to each book's line. If you want to use a custom market-of-truth (e.g., Pinnacle only), you'd pull /v1/odds, filter to Pinnacle, convert to implied probabilities, devig, then compare. More control, more work. For most use cases, /v1/edge is the right call.

How do I add filtering for specific books only?

The /v1/edge endpoint accepts a books query param (comma-separated). For example: books=draftkings,fanduel,betmgm. This is useful if your group only has accounts at certain books and you don't want alerts for lines you can't actually bet.

Build with the same data we use.

MoneyLine API powers BettingLab's edge calculations. Free tier, 1k credits/month.