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:
- Mobile push notifications — Discord's mobile app is aggressive about alerts, which matters when arbitrage windows close in minutes
- Rich embeds — We can format odds comparisons, EV calculations, and bet slip links in readable format
- 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.