BettingLab

Build a Discord Bet Alert Bot in Python

Marcus Hale
Marcus Hale

Build a Discord Bet Alert Bot in Python

If you've ever missed a +EV window because you were away from your dashboard, you already know the problem. Good edges don't wait. A book misprices a line for 4–8 minutes, sharp money hammers it, and the line moves. If you're not already watching, you're not getting the bet down.

The fix isn't to stare at a screen all day. It's to build a Discord bet alert bot in Python that watches the MoneyLine API for you and fires a notification the moment an edge crosses your threshold. This is the exact kind of thing that used to take a Bloomberg Terminal and a team of developers. Now it takes an afternoon and about 150 lines of Python.

This tutorial walks you through the whole thing: polling /v1/edge for EV opportunities, filtering by your criteria, and posting clean, actionable alerts to a Discord channel via webhook. No fluff, no hand-waving — just working code you can deploy today.


What You're Actually Building

Here's the architecture in plain English:

  1. A Python process runs on a loop (every 60 seconds or so).
  2. Each tick, it calls /v1/edge on the MoneyLine API to fetch current positive-EV opportunities.
  3. It filters results by minimum EV threshold, sport, and bet type.
  4. Any edge that passes the filter and hasn't been alerted in the last N minutes gets posted to Discord via webhook.
  5. A simple deduplication cache prevents you from getting the same alert 10 times.

That's it. No database required. No frontend. You can run this on a $4/month VPS, a Raspberry Pi, or a spare laptop. The complexity is in the filtering logic, not the infrastructure.


Prerequisites

Install the dependencies:

pip install httpx python-dotenv

We're using httpx instead of requests because it handles timeouts more cleanly and supports async if you want to extend this later. python-dotenv keeps your secrets out of source code.

Create a .env file:

MONEYLINE_API_KEY=your_key_here
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/YOUR/WEBHOOK

The Full Bot Code

# ev_alert_bot.py
import os
import time
import json
import httpx
from datetime import datetime, timezone
from dotenv import load_dotenv

load_dotenv()

MONEYLINE_API_KEY = os.getenv("MONEYLINE_API_KEY")
DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL")

API_BASE = "https://mlapi.bet"
POLL_INTERVAL_SECONDS = 60

# --- Filter configuration ---
MIN_EV_PERCENT = 5.0          # Only alert on edges >= 5% EV
SPORTS_FILTER = None           # e.g. ["MLB", "NBA"] or None for all
BET_TYPES_FILTER = None        # e.g. ["moneyline", "spread"] or None for all
ALERT_COOLDOWN_SECONDS = 300   # Don't re-alert same edge within 5 minutes

# --- Deduplication cache: {edge_id: last_alerted_timestamp} ---
alerted_edges: dict[str, float] = {}


def fetch_edges() -> list[dict]:
    """Call /v1/edge and return a list of edge objects."""
    headers = {"Authorization": f"Bearer {MONEYLINE_API_KEY}"}
    try:
        resp = httpx.get(
            f"{API_BASE}/v1/edge",
            headers=headers,
            timeout=10.0,
        )
        resp.raise_for_status()
        data = resp.json()
        return data.get("edges", [])
    except httpx.HTTPStatusError as e:
        print(f"[{now()}] API error {e.response.status_code}: {e.response.text}")
        return []
    except Exception as e:
        print(f"[{now()}] Unexpected error fetching edges: {e}")
        return []


def passes_filter(edge: dict) -> bool:
    """Return True if this edge meets our alert criteria."""
    ev = edge.get("ev_percent", 0.0)
    if ev < MIN_EV_PERCENT:
        return False

    if SPORTS_FILTER:
        sport = edge.get("sport", "").upper()
        if sport not in [s.upper() for s in SPORTS_FILTER]:
            return False

    if BET_TYPES_FILTER:
        bet_type = edge.get("bet_type", "").lower()
        if bet_type not in [b.lower() for b in BET_TYPES_FILTER]:
            return False

    return True


def is_cooldown_active(edge_id: str) -> bool:
    """Return True if we alerted this edge recently."""
    last_alerted = alerted_edges.get(edge_id)
    if last_alerted is None:
        return False
    return (time.time() - last_alerted) < ALERT_COOLDOWN_SECONDS


def build_discord_message(edge: dict) -> dict:
    """Build a Discord embed payload from an edge object."""
    ev = edge.get("ev_percent", 0.0)
    event = edge.get("event_name", "Unknown Event")
    sport = edge.get("sport", "")
    book = edge.get("book", "Unknown Book")
    market = edge.get("market", "")
    bet_type = edge.get("bet_type", "")
    odds = edge.get("odds", "")
    fair_odds = edge.get("fair_odds", "")
    commence = edge.get("commence_time", "")

    # Color: green gradient based on EV magnitude
    if ev >= 15:
        color = 0x00FF00  # bright green
    elif ev >= 10:
        color = 0x7FFF00  # chartreuse
    else:
        color = 0xFFD700  # gold

    embed = {
        "title": f"🎯 +EV Alert: {ev:.1f}% Edge",
        "description": f"**{event}**\n{sport} · {bet_type} · {market}",
        "color": color,
        "fields": [
            {"name": "Book", "value": book, "inline": True},
            {"name": "Odds", "value": str(odds), "inline": True},
            {"name": "Fair Odds", "value": str(fair_odds), "inline": True},
            {"name": "Game Time", "value": commence or "TBD", "inline": False},
        ],
        "footer": {"text": "MoneyLine API • BettingLab"},
        "timestamp": datetime.now(timezone.utc).isoformat(),
    }

    return {"embeds": [embed]}


def post_to_discord(payload: dict) -> bool:
    """POST an embed to the configured Discord webhook."""
    try:
        resp = httpx.post(
            DISCORD_WEBHOOK_URL,
            json=payload,
            timeout=5.0,
        )
        resp.raise_for_status()
        return True
    except Exception as e:
        print(f"[{now()}] Discord post failed: {e}")
        return False


def now() -> str:
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def run():
    print(f"[{now()}] EV alert bot started. Polling every {POLL_INTERVAL_SECONDS}s.")
    print(f"  Min EV: {MIN_EV_PERCENT}% | Sports: {SPORTS_FILTER or 'all'} | Cooldown: {ALERT_COOLDOWN_SECONDS}s")

    while True:
        edges = fetch_edges()
        print(f"[{now()}] Fetched {len(edges)} edges from API.")

        alerts_fired = 0
        for edge in edges:
            if not passes_filter(edge):
                continue

            edge_id = edge.get("id") or json.dumps(edge, sort_keys=True)
            if is_cooldown_active(edge_id):
                continue

            payload = build_discord_message(edge)
            success = post_to_discord(payload)

            if success:
                alerted_edges[edge_id] = time.time()
                alerts_fired += 1
                ev = edge.get("ev_percent", 0)
                event = edge.get("event_name", "?")
                print(f"[{now()}] ✓ Alerted: {ev:.1f}% EV on {event}")

        if alerts_fired == 0:
            print(f"[{now()}] No qualifying edges this tick.")

        # Prune old cache entries to avoid unbounded memory growth
        cutoff = time.time() - (ALERT_COOLDOWN_SECONDS * 10)
        alerted_edges_pruned = {k: v for k, v in alerted_edges.items() if v > cutoff}
        alerted_edges.clear()
        alerted_edges.update(alerted_edges_pruned)

        time.sleep(POLL_INTERVAL_SECONDS)


if __name__ == "__main__":
    run()

What the Output Looks Like

When an edge fires, you get a Discord embed that looks like this in your channel:

🎯 +EV Alert: 8.4% Edge
Chicago Cubs vs Arizona Diamondbacks
MLB · moneyline · Full Game

Book         Odds     Fair Odds
DraftKings   +145     +133

Game Time: 2026-07-10T01:40:00Z
MoneyLine API • BettingLab

Color-coded by EV size. Clean. Actionable. No noise.


Tuning Your Filter Configuration

The four constants at the top of the script are where you do your real work:

MIN_EV_PERCENT

Start at 5.0%. Below that, after juice and variance, most edges aren't worth the cognitive overhead. If you're a more aggressive bettor or you're playing a volume game, drop it to 3.0%. If you want only fat pitches, push it to 10%.

SPORTS_FILTER

During MLB season you probably want ["MLB"]. If you're covering the full slate — NFL, NBA, NHL, NCAAB — leave it None. Just know that a broad filter during heavy betting days can produce a lot of alerts.

BET_TYPES_FILTER

["moneyline", "spread"] is a reasonable starting point. Props and totals can generate edges too, but they also generate more noise. Add them once you trust the signal.

ALERT_COOLDOWN_SECONDS

300 seconds (5 minutes) is sensible for most use cases. If a book is slow to move and the edge persists, you don't want 10 identical alerts. If you want to track line movement and see an edge grow, drop this to 60 and add a note in the embed showing whether EV increased since last alert.


Deploying This in Production

For a $0–$4/month setup:

Option 1: Railway or Render (free tier) Push to a GitHub repo, connect to Railway, set your env vars in the dashboard. Done. Railway keeps it running 24/7 on their free tier for small workers.

Option 2: Raspberry Pi Run it under systemd or just in a screen session. Add a crontab @reboot entry to start on boot. Solid for home labs.

Option 3: GitHub Actions scheduled workflow If you don't need sub-60-second polling, run this as a scheduled GitHub Action every 5 minutes. No server needed, and the free tier easily covers it. Just be aware you lose the in-memory deduplication cache between runs — you'd need to write alert history to a file or a KV store.

For the KV store option on Actions, write alerted_edges as a JSON file and commit it back to the repo, or use a free Redis instance on Upstash. Either works.


Extending the Bot

Once this is running, the obvious next steps:

The EV betting guide on BettingLab explains what positive expected value means if you're newer to the concept and want to sanity-check the numbers the API returns. And if you want to compare what data you're getting versus other API providers, the MoneyLine vs. The Odds API comparison breaks down coverage and update frequency in detail.


FAQ

How many API credits does polling every 60 seconds use? Each call to /v1/edge costs 1 credit on the free tier. Polling once per minute = 60 credits/hour = ~1,440 credits/day. The free tier gives you 1,000 credits/month, so you'll want to stay at 2–5 minute intervals on free, or upgrade to a paid plan if you want tighter polling. Even at 5-minute intervals, you won't miss most edges — books rarely fix a misprice in under 4 minutes.

What does the id field look like on edge objects? The API returns a stable id string per edge that combines the event, book, market, and outcome. If no id is present (shouldn't happen, but defensive code is good), the script falls back to hashing the edge dict. Either way, deduplication is reliable.

Can I run multiple bots watching different sports in the same Discord server? Yes. Create separate webhook URLs per channel, then run separate bot instances with different SPORTS_FILTER configs. Alternatively, modify the script to route by sport in a single process — it's a small refactor.

Is this considered automated betting? Will books flag me? This bot only alerts you — it doesn't place bets. You're doing the clicking. That said, if you're using alerts to bet fast on mispriced lines, some books will notice and limit your account. That's a sportsbook relationship management problem, not a technical one. The bot itself is completely within normal API usage.

What if the bot starts spamming Discord with alerts? Increase MIN_EV_PERCENT and ALERT_COOLDOWN_SECONDS. If you're seeing 50+ alerts per hour, your thresholds are too loose. Most serious bettors want 5–15 high-quality alerts per day, not a firehose. Start conservative and loosen from there.


The full code above is production-ready for personal use. Clone it, configure it, and you'll have a working edge alert system in under an hour. The MoneyLine API free tier is enough to validate whether this is worth running for your workflow before you spend a dime.

Build with the same data we use.

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