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 case | Recommended interval | Endpoint |
|---|
| Pre-match value betting | 30–60 s | GET /api/value-bets |
| Pre-match odds comparison | 30–60 s | GET /api/odds |
| Live odds monitoring | Use WebSocket instead | WS /api/stream |
| Event list discovery | 60 s | GET /api/events |
| Sports / bookmakers | Once at startup | GET /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 5per100 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")
function isFresh(scrapedAt, maxAgeSeconds = 60) {
const ageMs = Date.now() - new Date(scrapedAt).getTime();
return ageMs <= maxAgeSeconds * 1000;
}
for (const bet of resp.data) {
if (!isFresh(bet.odds_updated_at)) {
console.warn(`Stale: ${bet.match_name} — verify before placing`);
}
}
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)