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:
GET /v1/edge— returns pre-computed EV edges across books and sports. This is your scanner signal.GET /v1/odds— returns raw odds by event, useful for formatting the full line context in your alert embed.
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.