Skip to main content

Polling intervals

Odds update every 5–30 seconds depending on the bookmaker and sport. There’s no benefit to polling faster than your use case requires — you’ll burn rate limit quota and get the same data.
Use caseRecommended intervalEndpoint
Pre-match value betting30–60 sGET /api/value-bets
Pre-match odds comparison30–60 sGET /api/odds
Live odds monitoringUse WebSocket insteadWS /api/stream
Event list discovery60 sGET /api/events
Sports / bookmakersOnce at startupGET /api/sports, GET /api/bookmakers
Use the WebSocket for live in-play events — it’s push-based and eliminates unnecessary requests entirely.

Filter server-side, not client-side

Always pass filters as query parameters rather than fetching everything and filtering locally. The difference in response size is significant:
# Bad — returns ~13,000 odds rows across all sports/bookmakers
GET /api/odds

# Good — returns only the ~40 EPL moneyline rows you actually need
GET /api/odds?competition=EPL&market_type=moneyline
Use competition codes (EPL, NBA) over sport names (Football, Basketball) when you know the league — they’re more precise. One sport can have 60+ competition codes, so filtering by sport can still return hundreds of events.

Use slugs, not match names

Match names can contain accented characters and spaces. Always use the URL-encoded slug field from GET /api/events:
# Correct: use the slug directly (already URL-encoded)
slug = event["slug"]  # "Crystal%20Palace%20-%20West%20Ham%20United"
resp = requests.get(f"{BASE}/api/events/{slug}", headers=headers)

# Wrong: don't reconstruct slugs from match_name
slug = urllib.parse.quote(event["match_name"])  # breaks with unusual characters

Per-event drilldown pattern

For automated value detection when you need full market depth:
import requests, os

API_KEY = os.environ["ODDSTREAM_API_KEY"]
BASE = "https://api.oddsstream.io"

# 1. Discover events with good coverage
events = requests.get(
    f"{BASE}/api/events",
    params={"sport": "Football", "limit": 200},
    headers={"X-Api-Key": API_KEY}
).json()["data"]

for event in events:
    if event["bookmaker_count"] < 3:
        continue  # skip events with sparse coverage

    # 2. Get all markets for this event
    detail = requests.get(
        f"{BASE}/api/events/{event['slug']}",
        headers={"X-Api-Key": API_KEY}
    ).json()["data"]

    # 3. Compare against Pinnacle
    pin_ml = next(
        (p for p in detail["pinnacle"]
         if p["market_type"] == "moneyline" and p["period"] == 0),
        None
    )
    if not pin_ml:
        continue

    pin_prices = {s["selection"]: s["odds"] for s in pin_ml["selections"]}

    for mkt in detail["markets"]:
        if mkt["market_type"] != "moneyline" or mkt["period"] != 0:
            continue
        for sel in mkt["selections"]:
            if sel["selection"] in pin_prices:
                edge = (sel["odds"] / pin_prices[sel["selection"]] - 1) * 100
                if edge > 2:
                    print(
                        f"{event['match_name']} | {sel['selection']} "
                        f"@ {sel['odds']} (+{edge:.1f}% EV) via {mkt['bookmaker']}"
                    )
Use GET /api/value-bets instead if you just want pre-calculated EV opportunities — it applies all quality filters (TRJ, staleness, odds ratio) and is faster than building your own detection. The per-event drilldown pattern is useful when you want full control over the EV math or need markets that /api/value-bets doesn’t cover.

Understanding EV%

ev_pct in /api/value-bets measures your expected edge over fair value. In plain English:
A +5% EV bet means that if you placed this bet 1,000 times in identical conditions, you’d expect to profit around 5per5 per 100 wagered — long run.
The math:
implied_probability = 1 / decimal_odds

# Devig removes Pinnacle's ~1.8% margin to get the TRUE probability
fair_prob = devig(pinnacle_home_prob, pinnacle_draw_prob, pinnacle_away_prob)
fair_odds = 1 / fair_prob

EV% = (bookmaker_odds / fair_odds - 1) × 100
Example:
Pinnacle moneyline: PSG 1.80 | Draw 3.60 | Lyon 4.50
→ Pinnacle implied: 55.6% | 27.8% | 22.2% = 105.5% (their margin)
→ Devigged fair:   52.7% | 26.3% | 21.0% = 100.0%
→ Fair odds:        PSG 1.898 | Draw 3.802 | Lyon 4.762

Winamax offers PSG at 2.05:
→ EV% = (2.05 / 1.898 - 1) × 100 = +8.0%

Understanding payout_rate (TRJ)

payout_rate is Pinnacle’s Total Return on Juice — the sum of implied probabilities across all outcomes:
TRJ = 1/odds_home + 1/odds_draw + 1/odds_away

Example: Pinnacle 2.05 / 3.40 / 3.50
TRJ = 1/2.05 + 1/3.40 + 1/3.50 = 0.488 + 0.294 + 0.286 = 1.068
payout_rate = 1.068 → Pinnacle is taking 6.8% margin (very high, line is unreliable)
A sharp, reliable Pinnacle line has payout_rate between 1.00 and 1.03:
  • 1.018 → 1.8% margin — normal for football moneylines
  • 1.005 → 0.5% margin — very sharp (high-liquidity game)
  • > 1.05 → margin >5% — unreliable reference, filtered out by /api/value-bets

Data freshness

  • scraped_at fields are UTC ISO-8601 timestamps
  • /api/value-bets returns only bets detected in the last 30 minutes with odds scraped in the last 15 minutes
  • /api/odds uses a 10-minute default freshness window (stale_minutes=10)
Always check how old the data is before acting on it:
from datetime import datetime, timezone

def is_fresh(scraped_at: str, max_age_seconds: int = 60) -> bool:
    scraped = datetime.fromisoformat(scraped_at.replace("Z", "+00:00"))
    age = (datetime.now(timezone.utc) - scraped).total_seconds()
    return age <= max_age_seconds

for bet in resp.json()["data"]:
    if not is_fresh(bet["odds_updated_at"]):
        print(f"Warning: {bet['match_name']} odds are stale, verify before betting")
Always verify the current price on the bookmaker’s site before placing a bet. Odds can move significantly between detection and placement — especially on player props and live markets.

Error handling and retries

Handle the three common failure modes:
import time, requests

def api_get(url: str, params: dict, api_key: str, max_retries: int = 3):
    headers = {"X-Api-Key": api_key}
    delay = 1

    for attempt in range(max_retries):
        resp = requests.get(url, params=params, headers=headers, timeout=10)

        if resp.status_code == 200:
            return resp.json()

        if resp.status_code == 429:
            # Rate limited — wait for reset
            reset_at = int(resp.headers.get("X-RateLimit-Reset", time.time() + 60))
            wait = max(reset_at - time.time(), 1)
            print(f"Rate limited. Waiting {wait:.0f}s")
            time.sleep(wait)

        elif resp.status_code == 503:
            # Server temporarily unavailable — exponential backoff
            print(f"503, retrying in {delay}s (attempt {attempt + 1}/{max_retries})")
            time.sleep(delay)
            delay = min(delay * 2, 30)

        elif resp.status_code in (401, 403):
            raise ValueError(f"Auth error {resp.status_code}: {resp.json()}")

        else:
            resp.raise_for_status()

    raise RuntimeError(f"Failed after {max_retries} retries")

Monitor rate limits

Check X-RateLimit-Remaining on every response to avoid hard 429 errors:
resp = requests.get(url, headers=headers)
remaining = int(resp.headers.get("X-RateLimit-Remaining", 999))
if remaining < 20:
    time.sleep(5)  # back off proactively
const remaining = Number(resp.headers.get("X-RateLimit-Remaining"));
if (remaining < 20) await new Promise(r => setTimeout(r, 5_000));

Never expose your key

  • Make all API calls from your server, never from browser JavaScript
  • Never commit keys to git — add .env to .gitignore
  • Rotate immediately if a key is accidentally exposed
  • Use environment variables in all environments (local, staging, production)