BettingLab

Build Discord Betting Alerts Python Bot Tutorial

Marcus Hale
Marcus Hale

I've built dozens of betting bots over the years, but the one that actually moves the needle? A Discord alert system that pings my crew when real edges show up. No more manually refreshing odds boards or missing 15-minute arbitrage windows.

Today we're building a Discord betting alerts Python bot that monitors the MoneyLine API for EV plays and arbitrage opportunities, then fires alerts straight to your Discord server. Real code, real alerts, real profit potential.

This isn't another "hello world" tutorial. We're building something you'll actually run in production — a bot that watches 40+ sportsbooks simultaneously and only bothers you when there's money on the table.

Why Discord for Betting Alerts

Discord beats Slack, Telegram, or email for betting alerts for three reasons:

  1. Mobile push notifications — Discord's mobile app is aggressive about alerts, which matters when arbitrage windows close in minutes
  2. Rich embeds — We can format odds comparisons, EV calculations, and bet slip links in readable format
  3. Channel organization — Separate channels for EV plays, arbitrage, line movements, whatever your strategy needs

Most importantly, Discord webhooks are dead simple to implement. No OAuth, no rate limit headaches, just POST some JSON and watch alerts flow.

Setting Up the Bot Infrastructure

First, the foundation. This bot polls the MoneyLine API every 60 seconds, processes opportunities, and fires Discord webhooks when criteria hit.

import requests
import asyncio
import discord
from discord.ext import commands, tasks
from datetime import datetime, timedelta
import json
from typing import List, Dict, Optional

class BettingAlertBot:
    def __init__(self, discord_token: str, webhook_url: str, ml_api_key: str):
        self.webhook_url = webhook_url
        self.ml_api_key = ml_api_key
        self.base_url = "https://mlapi.bet/v1"
        self.headers = {
            "Authorization": f"Bearer {ml_api_key}",
            "Content-Type": "application/json"
        }
        
        # Alert thresholds
        self.min_ev_threshold = 3.0  # 3% minimum EV
        self.min_arb_threshold = 1.5  # 1.5% minimum arbitrage
        self.tracked_events = set()  # Prevent duplicate alerts
        
    async def fetch_opportunities(self) -> Dict:
        """Fetch EV and arbitrage opportunities from MoneyLine API"""
        try:
            response = requests.get(
                f"{self.base_url}/edge",
                headers=self.headers,
                params={
                    "sport": "all",
                    "min_ev": self.min_ev_threshold,
                    "market_types": "moneyline,spread,total",
                    "limit": 50
                }
            )
            response.raise_for_status()
            return response.json()
        except Exception as e:
            print(f"API fetch failed: {e}")
            return {"edges": [], "arbitrages": []}

    async def send_discord_alert(self, embed_data: Dict):
        """Send formatted alert to Discord webhook"""
        payload = {
            "embeds": [embed_data],
            "username": "BettingBot"
        }
        
        requests.post(self.webhook_url, json=payload)

    def format_ev_embed(self, edge: Dict) -> Dict:
        """Format EV play as Discord embed"""
        game = f"{edge['away_team']} @ {edge['home_team']}"
        market = f"{edge['market_type']} {edge['line']}" if edge.get('line') else edge['market_type']
        
        return {
            "title": f"🔥 {edge['ev_percent']:.2f}% EV Play",
            "description": f"**{game}**\n{market}",
            "color": 0x00ff00,  # Green
            "fields": [
                {"name": "Sportsbook", "value": edge['sportsbook'], "inline": True},
                {"name": "Odds", "value": f"{edge['odds']:+d}", "inline": True},
                {"name": "Fair Odds", "value": f"{edge['fair_odds']:+d}", "inline": True},
                {"name": "EV%", "value": f"{edge['ev_percent']:.2f}%", "inline": True},
                {"name": "Kelly%", "value": f"{edge['kelly_percent']:.2f}%", "inline": True},
                {"name": "Sport", "value": edge['sport'], "inline": True}
            ],
            "footer": {"text": f"Event ID: {edge['event_id']}"},
            "timestamp": datetime.utcnow().isoformat()
        }

    def format_arb_embed(self, arb: Dict) -> Dict:
        """Format arbitrage as Discord embed"""
        game = f"{arb['away_team']} @ {arb['home_team']}"
        
        return {
            "title": f"💰 {arb['profit_percent']:.2f}% Arbitrage",
            "description": f"**{game}**\n{arb['market_type']}",
            "color": 0xffd700,  # Gold
            "fields": [
                {"name": "Side A", "value": f"{arb['side_a']['sportsbook']}\n{arb['side_a']['selection']} {arb['side_a']['odds']:+d}", "inline": True},
                {"name": "Side B", "value": f"{arb['side_b']['sportsbook']}\n{arb['side_b']['selection']} {arb['side_b']['odds']:+d}", "inline": True},
                {"name": "Profit", "value": f"{arb['profit_percent']:.2f}%", "inline": True},
                {"name": "Stake A", "value": f"${arb['stake_a']:.2f}", "inline": True},
                {"name": "Stake B", "value": f"${arb['stake_b']:.2f}", "inline": True},
                {"name": "Total Stake", "value": f"${arb['total_stake']:.2f}", "inline": True}
            ],
            "footer": {"text": f"Window: {arb['time_remaining']}min"},
            "timestamp": datetime.utcnow().isoformat()
        }

    @tasks.loop(seconds=60)
    async def monitor_opportunities(self):
        """Main monitoring loop"""
        data = await self.fetch_opportunities()
        
        # Process EV plays
        for edge in data.get("edges", []):
            event_key = f"ev_{edge['event_id']}_{edge['market_type']}_{edge['sportsbook']}"
            if event_key not in self.tracked_events:
                embed = self.format_ev_embed(edge)
                await self.send_discord_alert(embed)
                self.tracked_events.add(event_key)
                
        # Process arbitrages
        for arb in data.get("arbitrages", []):
            event_key = f"arb_{arb['event_id']}_{arb['market_type']}"
            if event_key not in self.tracked_events:
                embed = self.format_arb_embed(arb)
                await self.send_discord_alert(embed)
                self.tracked_events.add(event_key)
        
        # Clean old tracked events (prevent memory bloat)
        if len(self.tracked_events) > 1000:
            self.tracked_events.clear()

# Initialize and run
async def main():
    bot = BettingAlertBot(
        discord_token="your_discord_token",
        webhook_url="your_webhook_url", 
        ml_api_key="your_moneyline_api_key"
    )
    
    bot.monitor_opportunities.start()
    
    # Keep running
    while True:
        await asyncio.sleep(1)

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

Advanced Alert Filtering

Raw alerts are noise. The money is in the filtering. Here's how I tune the bot to only ping when opportunities are actually worth acting on:

Market-Specific Thresholds

Different markets need different EV thresholds. NBA player props can sustain 8% edges, but NFL spreads rarely see 3% without getting hammered.

def get_market_threshold(self, sport: str, market_type: str) -> float:
    """Dynamic EV thresholds by market"""
    thresholds = {
        "nfl": {"moneyline": 2.5, "spread": 2.0, "total": 2.5},
        "nba": {"moneyline": 3.0, "spread": 2.5, "total": 3.0},
        "mlb": {"moneyline": 4.0, "spread": 3.5, "total": 4.0},
        "nhl": {"moneyline": 3.5, "spread": 3.0, "total": 3.5}
    }
    
    return thresholds.get(sport, {}).get(market_type, self.min_ev_threshold)

Sportsbook Reliability Scoring

Not all edges are created equal. A 5% edge on a sharp book like Pinnacle means something different than 5% on a recreational book that might void winners.

def get_sportsbook_reliability(self, sportsbook: str) -> float:
    """Reliability multiplier for different books"""
    reliability = {
        "pinnacle": 1.0,      # Gold standard
        "bet365": 0.9,        # Reliable, limits winners
        "fanduel": 0.8,       # Recreational, decent limits
        "draftkings": 0.8,    # Same
        "betmgm": 0.7,        # Slower to adjust
        "caesars": 0.6,       # Recreational, quick limits
        "betrivers": 0.5      # Small market share
    }
    
    return reliability.get(sportsbook.lower(), 0.5)

def filter_edge_quality(self, edge: Dict) -> bool:
    """Only alert on high-quality edges"""
    market_threshold = self.get_market_threshold(edge['sport'], edge['market_type'])
    reliability = self.get_sportsbook_reliability(edge['sportsbook'])
    
    adjusted_ev = edge['ev_percent'] * reliability
    
    return adjusted_ev >= market_threshold

Real-Time Line Movement Alerts

Static EV scanning is table stakes. The alpha is in line movement — catching steam before the market corrects. Here's how to layer in movement detection:

async def track_line_movements(self):
    """Monitor line movements for steam detection"""
    try:
        response = requests.get(
            f"{self.base_url}/odds",
            headers=self.headers,
            params={
                "sport": "nfl,nba,mlb,nhl",
                "market_types": "moneyline,spread,total",
                "changed_since": "5m"  # Lines that moved in last 5 minutes
            }
        )
        
        movements = response.json().get("movements", [])
        
        for move in movements:
            if self.is_significant_movement(move):
                embed = self.format_movement_embed(move)
                await self.send_discord_alert(embed)
                
    except Exception as e:
        print(f"Movement tracking failed: {e}")

def is_significant_movement(self, movement: Dict) -> bool:
    """Filter for significant line movements"""
    # Moneyline movements of 10+ cents
    if movement['market_type'] == 'moneyline':
        return abs(movement['odds_change']) >= 10
        
    # Spread movements of 0.5+ points  
    elif movement['market_type'] == 'spread':
        return abs(movement['line_change']) >= 0.5
        
    # Total movements of 0.5+ points
    elif movement['market_type'] == 'total':
        return abs(movement['line_change']) >= 0.5
        
    return False

This catches reverse line movement (RLM) patterns that often signal sharp action. When 70% of public bets are on the favorite but the line moves toward the underdog, that's usually smart money.

Deployment and Monitoring

Running this locally is fine for testing, but production deployment needs reliability. I run mine on a $5/month VPS with a few tweaks for uptime:

Error Handling and Reconnection

async def robust_monitoring_loop(self):
    """Monitoring with exponential backoff on failures"""
    backoff_seconds = 1
    max_backoff = 300  # 5 minutes
    
    while True:
        try:
            await self.monitor_opportunities()
            backoff_seconds = 1  # Reset on success
            await asyncio.sleep(60)
            
        except Exception as e:
            print(f"Monitoring error: {e}")
            await asyncio.sleep(backoff_seconds)
            backoff_seconds = min(backoff_seconds * 2, max_backoff)

Health Check Endpoint

from flask import Flask
import threading

app = Flask(__name__)

@app.route('/health')
def health_check():
    return {"status": "running", "last_check": self.last_check_time}

# Run Flask in background thread
def run_health_server():
    app.run(host='0.0.0.0', port=8080)

threading.Thread(target=run_health_server, daemon=True).start()

This lets you monitor bot uptime and integrate with services like UptimeRobot for downtime alerts.

Performance Optimization

At scale, API efficiency matters. Here's how to squeeze more performance from fewer credits:

Batch Processing

Instead of individual API calls per market, batch requests by sport:

async def fetch_sport_opportunities(self, sport: str) -> List[Dict]:
    """Fetch all opportunities for a sport in one call"""
    response = requests.get(
        f"{self.base_url}/edge",
        headers=self.headers,
        params={
            "sport": sport,
            "min_ev": 1.0,  # Lower threshold, filter in code
            "market_types": "moneyline,spread,total,player_props",
            "limit": 100
        }
    )
    
    return response.json().get("edges", [])

Intelligent Polling

Not all sports need the same polling frequency. NFL has 16 games per week, NBA has 82 per team. Adjust accordingly:

def get_poll_interval(self, sport: str) -> int:
    """Sport-specific polling intervals"""
    intervals = {
        "nfl": 300,      # 5 minutes (low volume)
        "nba": 120,      # 2 minutes (high volume)
        "mlb": 180,      # 3 minutes (medium volume)
        "nhl": 180,      # 3 minutes (medium volume)
    }
    
    return intervals.get(sport, 240)

Advanced Features Worth Building

Once the basic bot works, here are features that separate amateurs from pros:

Bankroll-Aware Bet Sizing

Instead of fixed Kelly percentages, calculate optimal bet sizes based on current bankroll:

def calculate_bet_size(self, edge: Dict, bankroll: float) -> float:
    """Kelly criterion with bankroll management"""
    ev_decimal = edge['ev_percent'] / 100
    american_odds = edge['odds']
    
    # Convert American odds to decimal
    if american_odds > 0:
        decimal_odds = (american_odds / 100) + 1
    else:
        decimal_odds = (100 / abs(american_odds)) + 1
    
    # Kelly fraction
    win_prob = 1 / edge['fair_odds_decimal']
    kelly_fraction = (decimal_odds * win_prob - 1) / (decimal_odds - 1)
    
    # Conservative scaling (25% of full Kelly)
    bet_size = bankroll * kelly_fraction * 0.25
    
    return min(bet_size, bankroll * 0.05)  # Never bet more than 5%

Multi-Book Arbitrage Chains

Some arbitrages require 3+ books to close. Track these complex opportunities:

def find_three_way_arbitrages(self, odds_data: List[Dict]) -> List[Dict]:
    """Find arbitrage opportunities across 3+ books"""
    # Group by event and market
    events = {}
    for odd in odds_data:
        key = f"{odd['event_id']}_{odd['market_type']}"
        if key not in events:
            events[key] = []
        events[key].append(odd)
    
    arbitrages = []
    
    for event_odds in events.values():
        if len(event_odds) >= 3:
            # Check all possible combinations
            arb = self.calculate_multi_book_arbitrage(event_odds)
            if arb and arb['profit_percent'] > self.min_arb_threshold:
                arbitrages.append(arb)
                
    return arbitrages

Want to extend this further? Check out our arbitrage detection strategies for more sophisticated opportunity scanning.

Frequently Asked Questions

How many API credits does the bot consume daily?

With default settings (60-second polling, all major sports), expect 1,440 API calls per day. That's well within the free tier's 1,000 monthly credits if you optimize polling intervals by sport priority.

Can I run multiple bot instances for different strategies?

Yes, but use separate API keys and Discord webhooks. Running multiple instances on the same key risks hitting rate limits during high-volume periods like March Madness.

What's the typical alert volume during peak sports seasons?

During peak seasons (NFL + NBA + NHL overlapping), expect 15-25 alerts per day with 3%+ EV threshold. Lower the threshold to 2% and you'll see 40-60 daily alerts, but signal-to-noise ratio drops significantly.

How quickly do I need to act on arbitrage alerts?

Arbitrage windows average 8-15 minutes before books adjust. EV plays typically last 30-60 minutes. The bot timestamps all alerts, so prioritize fresh arbitrages over older EV plays.

Can the bot detect steam moves from sharp bettors?

The line movement module catches reverse line movement and rapid odds changes, which often indicate sharp action. However, true steam detection requires more sophisticated pattern recognition beyond this tutorial's scope.

The foundation is here — real monitoring, real alerts, real opportunities. Fork this code, tune the thresholds to your risk tolerance, and start catching edges that most bettors miss entirely.

Build with the same data we use.

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