Most betting bots are designed to predict an outcome. A sportsbook arbitrage bot has a narrower job: watch the same market across multiple US sportsbooks, identify when the best available prices create a combined implied probability below 100%, calculate a balanced stake split and send an alert before the prices move.
The formula is simple. The difficult part is making sure the bot compares the same event, market, period and line. Most false positives come from mismatched point spreads, stale prices, suspended selections or incomplete markets.
This tutorial builds a Python detection and Discord alerting bot. It does not log into sportsbook accounts or place wagers automatically.
What the Bot Needs to Do
A useful first version only needs to complete seven tasks:
- Discover the US sportsbooks available through the data feed.
- Load upcoming events for a selected league, such as the NBA.
- Request the same market from each covered sportsbook.
- Normalise outcome names, periods and line values.
- Find the best available price for each opposing outcome.
- Calculate the implied probability, stake split and expected return.
- Reject weak or stale opportunities, then send the remaining alerts to Discord.
Start with full-game NBA moneylines. They are two-way markets, so the comparison logic is easier to verify before adding spreads, totals and three-way markets.
Why US Sportsbooks Show Different Prices
Sportsbooks do not work from one official price. Each operator has its own models, trading decisions, customer exposure and update timing. After an injury report, lineup change or sharp market move, one sportsbook may adjust immediately while another holds its previous number for longer.
Most price differences are not large enough to create an arbitrage. A true two-way opportunity only exists when the reciprocal of the best price on each side adds up to less than 1.00. The window may last seconds, which makes automated monitoring far more practical than checking sportsbook pages manually.
Use One Normalised US Odds Feed
Maintaining separate integrations for DraftKings, FanDuel, BetMGM, BetRivers and every other operator quickly becomes a larger project than the bot itself. A normalised US sportsbook odds API returns covered sportsbook prices through one event, market and selection structure, removing the need to maintain a different parser for every book.
The basic workflow uses three endpoints: the bookmaker catalog, the event list and the odds snapshot for each event. The bot can then compare the returned moneyline rows by side and sportsbook.
The complete parameters, response fields, pagination rules and streaming behaviour are available in the Odds API documentation.
Set Up the Python Project
Create a virtual environment and install the two packages used in this example:
| python -m venv .venv pip install requests python-dotenv |
Store secrets in a local .env file rather than placing them directly in the source code:
| ODDS_API_KEY=replace_me DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/replace_me |
Create a Small API Client
The API key is sent in the X-API-Key header. A shared request function keeps authentication, timeouts and rate-limit handling in one place.
| import os from typing import Any import requests load_dotenv() BASE_URL = “https://api.odds-api.net/v1” def get_json(path: str, params: dict[str, Any] | None = None) -> dict: |
Fetch US Sportsbooks and Upcoming NBA Events
Query the bookmaker catalog with the US country filter, then keep the operators you want to monitor. The event request below loads upcoming NBA games only.
| preferred_books = { “draftkings”, “fanduel”, “betmgm”, “betrivers”, “bet365”, } catalog = get_json(“/bookmakers”, {“country_code”: “US”}) event_page = get_json( |
In production, use a bounded start-time window and follow next_cursor until it is empty. Keeping the window narrow reduces unnecessary requests and avoids processing games that are too far away to matter.
Load Comparable Moneyline Prices
For each event, request the moneyline market from the selected sportsbooks. Supplying an explicit bookmaker list makes the comparison set clear.
| def load_moneyline(event_id: str, books: list[str]) -> dict: return get_json( f”/events/{event_id}/odds/snapshot”, { “bookmakers”: “,”.join(books), “types”: “moneyline”, “market_keys”: “moneyline”, “price_fields”: “odds”, }, ) |
The snapshot includes the bookmaker, market key, period, side, selection name, decimal odds and availability state. It also includes freshness fields such as as_of_ts_ms and ttl_seconds, which should be checked before an alert is sent.
Find the Best Price on Each Side
The function below keeps only available full-game moneyline rows, then records the highest decimal price for the home and away sides.
| def best_moneyline_prices(items: list[dict]) -> dict | None: best: dict[str, dict] = {} for row in items: side = row.get(“side”) if side not in best or odds > best[side][“odds”]: return best if set(best) == {“home”, “away”} else None |
This works for two-way NBA moneylines because there is no draw outcome. The same function should not be reused unchanged for soccer three-way markets.
Calculate the Arbitrage and Stake Split
Suppose the best home price is 2.05 at DraftKings and the best away price is 2.10 at FanDuel. The implied probability test is:
| 1 / 2.05 = 48.78% |
| 1 / 2.10 = 47.62% |
| 48.78% + 47.62% = 96.40% |
Because the total is below 100%, the prices form a theoretical arbitrage. The stakes are weighted by each side’s implied probability so that either outcome produces approximately the same payout.
| def calculate_two_way_arb(best: dict, total_stake=1000.0): home_odds = best[“home”][“odds”] away_odds = best[“away”][“odds”] implied_total = 1 / home_odds + 1 / away_odds if implied_total >= 1.0: home_stake = total_stake * (1 / home_odds) / implied_total return { |
At those prices, a $1,000 total stake is split into approximately $506.02 on the home side and $493.98 on the away side. The theoretical payout is about $1,037.35, producing a $37.35 return before any execution problems or costs. Do not round the stakes until the final display step, and recalculate the two possible payouts after rounding.
Send Qualified Opportunities to Discord
A Discord webhook is enough for the first alerting layer. Include the event, sportsbooks, prices, proposed stakes and expected return so the alert can be checked quickly.
| def send_discord(event: dict, best: dict, arb: dict) -> None: webhook_url = os.environ[“DISCORD_WEBHOOK_URL”] message = ( response = requests.post( |
Run the Detection Loop
The initial loop can reject stale snapshots and ignore opportunities below a minimum expected margin. A production service would schedule the loop, add retries and store alert fingerprints for deduplication.
| import time
MIN_PROFIT_PCT = 1.0 def snapshot_is_fresh(snapshot: dict) -> bool: for event in events: best = best_moneyline_prices(snapshot.get(“items”, [])) arb = calculate_two_way_arb(best, total_stake=1000.0) |
Production Filters That Prevent False Positives
A mathematical result below 100% is not enough. The bot should reject an opportunity unless every comparison field matches.
- Match the exact event, market key and period.
- For spreads and totals, require the same line value on both sides.
- Reject unavailable, suspended or stale prices.
- Require every outcome needed to cover the market.
- Set a minimum margin high enough to survive price movement and stake rounding.
- Deduplicate alerts so the same opportunity is not posted repeatedly.
- Honor Retry-After headers and back off after rate-limit or server errors.
- Display the snapshot timestamp so users know how fresh the prices are.
When the snapshot request is not limited to explicit bookmakers, follow next_cursor until complete is true. Partial pages should never be treated as the full market.
What Changes for Spreads, Totals and Three-Way Markets
Spreads and totals require stricter grouping. A +3.5 selection can only be opposed by -3.5 on the other team. It must not be compared with -4.0. The same rule applies to totals: Over 219.5 and Under 219.5 form a pair, while Under 220.5 belongs to a different market state.
Three-way markets require all three outcomes. For a soccer moneyline, the bot must find the best home, draw and away prices, then add all three implied probabilities. Skipping the draw would create a false arbitrage because one possible result remains uncovered.
Use Streaming After the Snapshot Version Works
Polling snapshots is the easiest way to verify the logic. Once the results are stable, hot events can move to Server-Sent Events or WebSockets. Load a snapshot first, store its resume token, apply incoming deltas and reload the snapshot if the stream requests a resync.
Do not stream every distant event. Reserve real-time connections for games and markets where rapid changes matter, while using slower polling for the rest of the schedule.
Detection Is Not Automatic Execution
A valid alert is still exposed to execution risk. One price can move after the first wager is accepted, a sportsbook can limit the requested stake, a selection can be suspended, or the two books can apply different settlement rules.
The detection bot should therefore present each opportunity as a candidate to verify, not as a guaranteed completed return. Sportsbook access and sports betting laws also vary by US state, so the software should not assume that every operator or market is available to every user.
Final Thoughts
Start narrowly: one league, one two-way market, a small group of US sportsbooks, a minimum profit threshold and one Discord channel. This makes each alert easier to audit before the system expands.
Once NBA moneylines are reliable, the same architecture can add leagues, spreads, totals, line-movement alerts and real-time streams. Clean market matching, fresh data and disciplined filtering matter more than the formula. For a staged build, follow the arbitrage betting bot guide.