> ## Documentation Index
> Fetch the complete documentation index at: https://oddsstream.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Best Practices

> Production patterns for polling, filtering, EV interpretation, error handling, and data freshness.

## 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:

```bash theme={null}
# 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`:

```python theme={null}
# 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:

```python theme={null}
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']}"
                    )
```

<Tip>
  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.
</Tip>

***

## 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 $5 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:

<Tabs>
  <Tab title="Python">
    ```python theme={null}
    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")
    ```
  </Tab>

  <Tab title="JavaScript">
    ```javascript theme={null}
    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`);
      }
    }
    ```
  </Tab>
</Tabs>

<Warning>
  **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.
</Warning>

***

## Error handling and retries

Handle the three common failure modes:

```python theme={null}
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:

```python theme={null}
resp = requests.get(url, headers=headers)
remaining = int(resp.headers.get("X-RateLimit-Remaining", 999))
if remaining < 20:
    time.sleep(5)  # back off proactively
```

```javascript theme={null}
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)
