Public API reference · v1

Everything you need to build on Odds Vault

Discover sports, books, and market types. Check freshness. List events cheaply. Pull multi-book odds. Convert prices. Hand the whole contract to Claude Code.

10 endpointsBearer account key~60s refresh target≤180s source ageOpenAPI included

Quick start

Typical flow: status → catalog → events → odds (or event detail). Use the canonical host so Authorization is not dropped.

1 · Freshness
bash
curl 'https://www.oddsvault.app/api/v1/status' \
  -H 'Authorization: Bearer ov_live_YOUR_KEY'
2 · Catalog
bash
curl 'https://www.oddsvault.app/api/v1/catalog' \
  -H 'Authorization: Bearer ov_live_YOUR_KEY'
3 · Event board
bash
curl 'https://www.oddsvault.app/api/v1/events?sport=nba&limit=50' \
  -H 'Authorization: Bearer ov_live_YOUR_KEY'
4 · Odds
bash
curl 'https://www.oddsvault.app/api/v1/odds?sport=nfl&markets=h2h,spreads,totals&books=draftkings,pinnacle' \
  -H 'Authorization: Bearer ov_live_YOUR_KEY'

Base URL: https://www.oddsvault.app. Odds responses are published current objects from private R2 — never a live scrape and never a Supabase snapshot fallback on the read path. Supabase is the control plane (auth, billing, refresh scheduling) only.

Authentication

All routes except OpenAPI need Authorization: Bearer ov_live_…. Create an account key from the dashboard after you are entitled (active subscription or trial — trial is full entitlement at your plan’s rate limits).

Header

Authorization: Bearer ov_live_YOUR_KEY
  • Store as ODDSVAULT_API_KEY. Never commit secrets.
  • 401 missing/invalid key · 403 subscription_required · 429 rate limited.
  • Successful paid responses include six X-RateLimit-* headers (minute + hour windows).
  • GET /api/v1/openapi is public (no auth, outside account limits) for agents and codegen.

Plans & rate limits

Both plans read the same private R2 current snapshots and apply the same ≤180s source-freshness rule. Fresh only raises how often you may poll — it does not buy a separate or newer feed.

PlanStored tierMinuteHourNotes
Standardbasic60360$39/mo
Freshpro300900$79/mo
  • “Unlimited” means no daily or monthly quota — both minute and hour windows always apply.
  • 429 body codes: minute_rate_limit_exceeded, hourly_rate_limit_exceeded, or unauthorized_rate_limit_exceeded (IP abuse before a valid key is presented).
  • Unauthorized abuse windows: 60/min and 360/hour.

Endpoint map

GroupEndpointPurpose
DiscoveryGET /api/v1/catalogFull catalog
Sports, books, markets (incl. planned props), product notes, counts.
DiscoveryGET /api/v1/sportsSports & leagues
Keys for sport=. Optional group=soccer|basketball|…
DiscoveryGET /api/v1/booksSportsbooks
Live books from the R2 index (keys for books=). Soft-empty if index unavailable.
DiscoveryGET /api/v1/marketsMarkets & prop types
status / kind / queryable filters. Planned props included.
DiscoveryGET /api/v1/statusFreshness status
R2 index board: per-sport fresh/has_snapshot, ages, books. 503 if index missing; ok:false when all stale.
EventsGET /api/v1/eventsEvent index
Lightweight board from R2: teams, commence, market/book counts — no quotes.
EventsGET /api/v1/events/{eventId}Event detail
Full multi-book markets for one event (R2). include_best=true optional.
OddsGET /api/v1/oddsOdds snapshot
Full multi-book pregame R2 payload for a sport (+ event_id / commence filters).
UtilitiesGET /api/v1/convertOdds convert
American ↔ decimal ↔ implied, or two-way no-vig (home+away).
UtilitiesGET /api/v1/openapiOpenAPI 3.1
Machine-readable schema for agents/codegen. No auth required.

Discovery

Prefer runtime catalogs over hardcoding keys. Planned prop types are listed so UIs and agents can plan ahead without inventing market keys.

GET /api/v1/catalog

Catalog
bash
curl 'https://www.oddsvault.app/api/v1/catalog' \
  -H 'Authorization: Bearer ov_live_YOUR_KEY'
Catalog shape (trimmed)
json
{
  "api_version": "v1",
  "product": {
    "name": "Odds Vault Pregame API",
    "refresh_target_seconds": 60,
    "max_source_age_seconds": 180,
    "live_odds": false
  },
  "sports": [{ "key": "nfl", "name": "NFL", "group": "american_football", "odds_supported": true }],
  "books": [{ "key": "draftkings", "name": "DraftKings", "domain": "draftkings.com", "region": "us", "odds_supported": true }],
  "markets": [{ "key": "h2h", "status": "available", "queryable": true, "family": "moneyline" }],
  "queryable_markets": ["h2h", "spreads", "totals"],
  "counts": {
    "sports": 20,
    "books": 1,
    "markets": 21,
    "markets_available": 3,
    "markets_planned": 17
  }
}

GET /api/v1/sports

Optional group: american_football, basketball, baseball, hockey, soccer, combat, tennis.

Soccer leagues
bash
curl 'https://www.oddsvault.app/api/v1/sports?group=soccer' \
  -H 'Authorization: Bearer ov_live_YOUR_KEY'

GET /api/v1/books

Returns only the customer-visible live set from the published R2 index (not every catalog book). If the index is missing or predates the books field, the response is a soft empty list with note: "books_unavailable" — not 5xx.

Books
bash
curl 'https://www.oddsvault.app/api/v1/books' \
  -H 'Authorization: Bearer ov_live_YOUR_KEY'

GET /api/v1/markets

Filters: status, kind, queryable. Available today: h2h, spreads, totals. Planned: 17 types (team totals, periods, player props). Only queryable keys may be passed to /odds / /events market filters.

Player props (planned)
bash
curl 'https://www.oddsvault.app/api/v1/markets?kind=player_prop' \
  -H 'Authorization: Bearer ov_live_YOUR_KEY'

Status / freshness

GEThttps://www.oddsvault.app/api/v1/status

Diagnostic board from the R2 index. Call this before polling. Skip sports where fresh: false (no snapshot or all sources older than max_source_age_seconds, ~180s).

  • Valid index but every sport stale → 200 with ok: false (still useful diagnostics).
  • Missing or invalid index → 503 (not a soft empty board).
  • Each sport row includes has_snapshot, version, age_seconds, books, and source_freshness.
Status
bash
curl 'https://www.oddsvault.app/api/v1/status' \
  -H 'Authorization: Bearer ov_live_YOUR_KEY'
Status shape (trimmed)
json
{
  "ok": true,
  "refresh_target_seconds": 60,
  "max_source_age_seconds": 180,
  "checked_at": "2026-07-18T18:42:12.000Z",
  "summary": {
    "sports_total": 20,
    "sports_with_snapshot": 14,
    "sports_fresh": 12,
    "events_fresh": 184
  },
  "sports": [{
    "sport": "nfl",
    "fresh": true,
    "has_snapshot": true,
    "version": 42,
    "generated_at": "2026-07-18T18:41:54.000Z",
    "age_seconds": 18,
    "event_count": 16,
    "books": ["draftkings", "pinnacle"],
    "source_freshness": [
      { "book": "draftkings", "scraped_at": "2026-07-18T18:41:54.000Z", "event_count": 10, "age_seconds": 18 },
      { "book": "pinnacle", "scraped_at": "2026-07-18T18:42:00.000Z", "event_count": 12, "age_seconds": 12 }
    ]
  }]
}

Events

Two-step pattern: list the board without quote bloat, then fetch one event (or filter /odds by event_id). Both routes read private R2 only.

GET /api/v1/events — index

Params: sport (recommended), books, markets, commence_after, commence_before, limit (default 100, max 500). Response includes limit, max_source_age_seconds, and per-event snapshot_version / age_seconds.

Event board
bash
curl 'https://www.oddsvault.app/api/v1/events?sport=nba&limit=50' \
  -H 'Authorization: Bearer ov_live_YOUR_KEY'
Events shape (trimmed)
json
{
  "sport": "nba",
  "count": 2,
  "total_matched": 2,
  "truncated": false,
  "limit": 100,
  "max_source_age_seconds": 180,
  "events": [{
    "event_id": "evt_abc",
    "sport": "nba",
    "competition": null,
    "commence_time": "2026-07-20T00:00:00Z",
    "participants": {
      "home": { "id": "bos", "name": "Boston Celtics" },
      "away": { "id": "nyk", "name": "New York Knicks" }
    },
    "market_count": 3,
    "market_families": ["moneyline", "spread", "total"],
    "book_count": 8,
    "books": ["draftkings", "fanduel"],
    "snapshot_version": 42,
    "age_seconds": 16
  }]
}

GET /api/v1/events/{eventId} — detail

Full markets for one event. Pass sport when known (faster). include_best=true adds a best-price rollup per selection across books (best_lines).

Event detail
bash
curl 'https://www.oddsvault.app/api/v1/events/evt_abc?sport=nba&include_best=true' \
  -H 'Authorization: Bearer ov_live_YOUR_KEY'

Odds snapshot

GEThttps://www.oddsvault.app/api/v1/odds
ParamRequiredDescription
sportYesFrom /api/v1/sports
booksNoFrom /api/v1/books
marketsNoQueryable only: h2h, spreads, totals
event_idNoRestrict to one event
commence_after / commence_beforeNoISO-8601 window on commence_time
Odds
bash
curl 'https://www.oddsvault.app/api/v1/odds?sport=nfl&markets=h2h,spreads,totals&books=draftkings,pinnacle' \
  -H 'Authorization: Bearer ov_live_YOUR_KEY'
Odds response (trimmed)
json
{
  "sport": "nfl",
  "snapshot_version": 42,
  "generated_at": "2026-07-18T18:42:11.000Z",
  "age_seconds": 14,
  "oldest_source_age_seconds": 14,
  "event_count": 1,
  "books": ["draftkings", "pinnacle"],
  "source_freshness": [
    { "book": "draftkings", "scraped_at": "2026-07-18T18:41:58.000Z", "event_count": 1, "age_seconds": 14 },
    { "book": "pinnacle", "scraped_at": "2026-07-18T18:42:00.000Z", "event_count": 1, "age_seconds": 12 }
  ],
  "filters": {
    "event_id": null,
    "commence_after": null,
    "commence_before": null,
    "markets": ["h2h", "spreads", "totals"]
  },
  "events": [
    {
      "event_id": "evt_8f2a1c",
      "sport": "nfl",
      "competition": null,
      "commence_time": "2026-09-10T00:20:00Z",
      "participants": {
        "home": { "id": "kansas_city_chiefs", "name": "Kansas City Chiefs" },
        "away": { "id": "buffalo_bills", "name": "Buffalo Bills" }
      },
      "markets": [
        {
          "market_id": "ml_full",
          "identity": { "family": "moneyline", "scope": "full_event", "line": null },
          "selections": [
            {
              "side": "home",
              "label": "Kansas City Chiefs",
              "books": [{ "book": "draftkings", "price": -135, "price_decimal": 1.74 }]
            }
          ]
        }
      ]
    }
  ]
}
  • Headers: ETag, X-Odds-Snapshot-Version, X-Odds-Age-Seconds, plus rate-limit headers. Send If-None-Match for 304.
  • Only sources ≤ max_source_age_seconds (~180s) are included, with per-book source_freshness.
  • Fresh empty board (validated snapshot, zero events after filters) → 200 with event_count: 0. No fresh requested book → 503.

Convert

GEThttps://www.oddsvault.app/api/v1/convert
  • Single price: exactly one of american, decimal, implied
  • Two-way no-vig: home + away (American)
Convert
bash
curl 'https://www.oddsvault.app/api/v1/convert?american=-110' \
  -H 'Authorization: Bearer ov_live_YOUR_KEY'

# Two-way no-vig
curl 'https://www.oddsvault.app/api/v1/convert?home=-110&away=-110' \
  -H 'Authorization: Bearer ov_live_YOUR_KEY'

OpenAPI

GEThttps://www.oddsvault.app/api/v1/openapi

Public OpenAPI 3.1 JSON — no API key. Point Claude Code, SDK generators, or Postman at this URL.

OpenAPI
bash
curl 'https://www.oddsvault.app/api/v1/openapi'

Errors

StatusMeaningTypical causes
400Bad request
  • Missing or unsupported `sport` where required
  • Invalid books / markets / discovery filters
  • Bad ISO timestamps or convert inputs
401Unauthorized
  • Missing Authorization header
  • Malformed or invalid API key (generic message; does not disclose key existence)
403Subscription required
  • Valid key but account is not entitled (`code: subscription_required`)
  • Subscribe (or start trial) from the dashboard before calling protected routes
404Not found
  • Unknown event_id
  • Event has no markets after filters
429Rate limited
  • Account minute/hour exceeded — Standard 60/360, Fresh 300/900 (`minute_rate_limit_exceeded` | `hourly_rate_limit_exceeded`)
  • Unauthorized IP abuse — 60/min, 360/hour (`unauthorized_rate_limit_exceeded`)
  • Includes Retry-After plus six X-RateLimit-* headers; Cache-Control: no-store
503Unavailable
  • Missing/invalid private R2 sport object or snapshot index
  • No fresh requested book (all sources older than max_source_age_seconds ≈ 180s)
  • Rate-limit provider misconfigured/down (`code: service_unavailable`)
  • Never falls back to Supabase snapshot tables on the read path

Coverage tables

Generated from the same catalog the API serves.

Sports & leagues

KeyNameGroupLeague
nflNFLamerican_footballyes
ncaafNCAAFamerican_footballyes
nbaNBAbasketballyes
ncaabNCAABbasketballyes
wnbaWNBAbasketballyes
mlbMLBbaseballyes
nhlNHLhockeyyes
mlsMLSsocceryes
eplPremier Leaguesocceryes
la_ligaLa Ligasocceryes
serie_aSerie Asocceryes
bundesligaBundesligasocceryes
ligue_1Ligue 1socceryes
uclUEFA Champions Leaguesocceryes
europa_leagueUEFA Europa Leaguesocceryes
world_cupFIFA World Cupsocceryes
soccer_intlSoccer (other / international)soccer
ufcUFCcombatyes
tennisTennis (ATP / WTA)tennis
boxingBoxingcombat

Books (currently live)

This list is the customer-visible live set from the published snapshot index. It updates when books are promoted or paused in admin — no docs deploy required. Source of truth: GET /api/v1/books / GET /api/v1/catalog.

KeyNameDomainRegion
betmgmBetMGMbetmgm.comus
betonlineBetOnlinebetonline.agoffshore
betriversBetRiversbetrivers.comus
draftkingsDraftKingsdraftkings.comus
fanduelFanDuelfanduel.comus
novigNovignovig.usexchange

Markets

KeyNameKindStatusQueryable
h2hMoneylinegame_lineavailableyes
spreadsSpreadgame_lineavailableyes
totalsTotalgame_lineavailableyes
team_totalsTeam totalteam_propplannedno
period_h2hPeriod moneylineperiodplannedno
period_spreadsPeriod spreadperiodplannedno
period_totalsPeriod totalperiodplannedno
player_pass_ydsPlayer passing yardsplayer_propplannedno
player_pass_tdsPlayer passing touchdownsplayer_propplannedno
player_rush_ydsPlayer rushing yardsplayer_propplannedno
player_receiving_ydsPlayer receiving yardsplayer_propplannedno
player_receptionsPlayer receptionsplayer_propplannedno
player_pointsPlayer pointsplayer_propplannedno
player_reboundsPlayer reboundsplayer_propplannedno
player_assistsPlayer assistsplayer_propplannedno
player_threesPlayer threesplayer_propplannedno
player_strikeoutsPlayer strikeoutsplayer_propplannedno
player_hitsPlayer hitsplayer_propplannedno
player_shotsPlayer shots on goalplayer_propplannedno
player_anytime_tdAnytime touchdown scorerplayer_propplannedno
liveLive / in-playspecialunsupportedno
player_pass_ydsplayer_pass_tdsplayer_rush_ydsplayer_receiving_ydsplayer_receptionsplayer_pointsplayer_reboundsplayer_assistsplayer_threesplayer_strikeoutsplayer_hitsplayer_shotsplayer_anytime_td

Planned player-prop keys are documentation-only until the product expands.

Code examples

Python
python
import os
import requests

API_KEY = os.environ["ODDSVAULT_API_KEY"]  # ov_live_...
BASE = "https://www.oddsvault.app"
HEADERS = {"Authorization": f"Bearer {API_KEY}"}

def get(path: str, **params):
    r = requests.get(f"{BASE}{path}", params=params or None, headers=HEADERS, timeout=30)
    r.raise_for_status()
    return r.json()

# 1) Is data fresh?
status = get("/api/v1/status")
fresh = [s["sport"] for s in status["sports"] if s["fresh"]]
print("fresh sports:", fresh)

# 2) Discover coverage
catalog = get("/api/v1/catalog")
soccer = get("/api/v1/sports", group="soccer")
props = get("/api/v1/markets", kind="player_prop")  # planned only

# 3) List games cheaply, then pull one event
board = get("/api/v1/events", sport="nba", limit=25)
if board["events"]:
    eid = board["events"][0]["event_id"]
    detail = get(f"/api/v1/events/{eid}", sport="nba", include_best="true")
    print(detail["summary"]["participants"], detail.get("best_lines", [])[:1])

# 4) Full sport snapshot (or filter to one event / window)
data = get(
    "/api/v1/odds",
    sport="nfl",
    books="draftkings,fanduel,pinnacle",
    markets="h2h,spreads,totals",
)

# 5) Convert prices
print(get("/api/v1/convert", american=-110))
print(get("/api/v1/convert", home=-115, away=-105))
TypeScript / Node
ts
const API_KEY = process.env.ODDSVAULT_API_KEY!;
const BASE = "https://www.oddsvault.app";

async function ovGet<T>(path: string, params?: Record<string, string>): Promise<T> {
  const url = new URL(path, BASE);
  if (params) for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${API_KEY}` },
    cache: "no-store",
  });
  if (!res.ok) throw new Error(`Odds Vault ${res.status}: ${await res.text()}`);
  return res.json() as Promise<T>;
}

const status = await ovGet<{ summary: { sports_fresh: number } }>("/api/v1/status");
const events = await ovGet<{ events: Array<{ event_id: string }> }>("/api/v1/events", {
  sport: "mlb",
  limit: "20",
});

if (events.events[0]) {
  const detail = await ovGet<{ best_lines?: unknown }>(
    `/api/v1/events/${events.events[0].event_id}`,
    { sport: "mlb", include_best: "true" },
  );
  console.log(status.summary.sports_fresh, detail.best_lines);
}

const converted = await ovGet("/api/v1/convert", { american: "-110" });
const openapi = await fetch("https://www.oddsvault.app/api/v1/openapi").then((r) => r.json());
console.log(converted, openapi.info.version);

Claude Code handoff

Full contract: endpoint map, recommended flow, params, planned props, errors, checklist. Copy → paste into Claude Code.

Paste into Claude Code
markdown
# Odds Vault API — agent handoff

Use this contract when building against Odds Vault **v1**.

## Product boundaries
- Pregame full-game markets only today: moneyline / spread / total (`h2h`, `spreads`, `totals`).
- Responses are private R2 **current** snapshots (rolling per-book refresh; default 60s target, ≤180s source age). Supabase is the control plane only. Requests never scrape books and never fall back to Supabase snapshot tables.
- **Standard and Fresh read the same R2 objects.** Fresh only raises request allowance; it does not buy a separate feed or younger source data.
- **Live books** (customer-visible) are published on the snapshot index and listed by `GET /api/v1/books` / catalog — currently: betmgm, betonline, betrivers, draftkings, fanduel, novig. This set changes without a code deploy when admin promotes/pauses books.
- Account-owned keys from the dashboard only (`ov_live_…`). Entitlement required (`has_paid`, including active trial).
- Rate limits (dual fixed windows, always both): Standard 60/min + 360/hr; Fresh 300/min + 900/hr. Unlimited = no daily/monthly quota.
- Live odds out of scope. Player props / team totals / periods are cataloged as planned, not queryable on /odds.
- Internal risk scores and quarantined rows are never returned.

## Auth
- Header: `Authorization: Bearer ov_live_<prefix>_<secret>`
- Env var: `ODDSVAULT_API_KEY`
- All routes require a **paid/trial-entitled** account key **except** `GET /api/v1/openapi` (public OpenAPI JSON).
- Signup alone is not enough: subscribe (or start trial) from the dashboard, then mint a key.
- Successful paid responses include six headers: `X-RateLimit-Minute-Limit|Remaining|Reset` and `X-RateLimit-Hour-Limit|Remaining|Reset`.

## Endpoint map
- GET /api/v1/catalog — Full catalog: Sports, books, markets (incl. planned props), product notes, counts.
- GET /api/v1/sports — Sports & leagues: Keys for sport=. Optional group=soccer|basketball|…
- GET /api/v1/books — Sportsbooks: Live books from the R2 index (keys for books=). Soft-empty if index unavailable.
- GET /api/v1/markets — Markets & prop types: status / kind / queryable filters. Planned props included.
- GET /api/v1/status — Freshness status: R2 index board: per-sport fresh/has_snapshot, ages, books. 503 if index missing; ok:false when all stale.
- GET /api/v1/events — Event index: Lightweight board from R2: teams, commence, market/book counts — no quotes.
- GET /api/v1/events/{eventId} — Event detail: Full multi-book markets for one event (R2). include_best=true optional.
- GET /api/v1/odds — Odds snapshot: Full multi-book pregame R2 payload for a sport (+ event_id / commence filters).
- GET /api/v1/convert — Odds convert: American ↔ decimal ↔ implied, or two-way no-vig (home+away).
- GET /api/v1/openapi — OpenAPI 3.1: Machine-readable schema for agents/codegen. No auth required.

OpenAPI (no auth): https://www.oddsvault.app/api/v1/openapi

## Recommended client flow
1. `GET /api/v1/status` — skip sports that are not `fresh`. (503 if the R2 index itself is missing.)
2. `GET /api/v1/catalog` (or /sports, /books, /markets) — populate validators / UI. Prefer live books from API over hardcoded lists.
3. `GET /api/v1/events?sport=` — cheap board for pickers.
4. `GET /api/v1/events/{eventId}?include_best=true` or `GET /api/v1/odds?sport=&event_id=` — full quotes.
5. `GET /api/v1/convert` — American/decimal/implied or two-way no-vig.

## Odds: GET https://www.oddsvault.app/api/v1/odds
| Param | Required | Notes |
| --- | --- | --- |
| sport | yes | nfl, ncaaf, nba, ncaab, wnba, mlb, nhl, mls, epl, la_liga, serie_a, bundesliga, ligue_1, ucl, europa_league, world_cup, soccer_intl, ufc, tennis, boxing |
| books | no | live set e.g. betmgm, betonline, betrivers, draftkings, fanduel, novig |
| markets | no | queryable only: h2h, spreads, totals |
| event_id | no | single event |
| commence_after | no | ISO-8601 |
| commence_before | no | ISO-8601 |

Available market aliases: h2h→moneyline, spreads→spread, totals→total
Planned player props (not on /odds): player_pass_yds, player_pass_tds, player_rush_yds, player_receiving_yds, player_receptions, player_points, player_rebounds, player_assists, player_threes, player_strikeouts, player_hits, player_shots, player_anytime_td

Odds headers: `ETag`, `X-Odds-Snapshot-Version`, `X-Odds-Age-Seconds`, plus rate-limit headers. Send `If-None-Match` for 304.

## Events
- `GET /api/v1/events` — summaries with market_count, book_count, books[], market_families[], snapshot_version, age_seconds. Params: sport?, books?, markets?, commence_after?, commence_before?, limit? (default 100, max 500). Response includes limit + max_source_age_seconds.
- `GET /api/v1/events/{eventId}` — full event + optional best_lines. Params: sport?, books?, markets?, include_best=true.

## Status
- `GET /api/v1/status` → ok, refresh_target_seconds, max_source_age_seconds, checked_at, summary (sports_total / sports_with_snapshot / sports_fresh / events_fresh), sports[] with fresh, has_snapshot, version, age_seconds, books[], source_freshness[].
- Valid index with every sport stale → **200** `{ ok: false }`. Missing/invalid index → **503**.

## Convert
- Single: `?american=-110` OR `?decimal=1.91` OR `?implied=0.5238`
- Two-way no-vig: `?home=-110&away=-105` (American)

## Errors
400 bad input · 401 auth · 403 subscription_required · 404 event missing · 429 rate limit (minute_rate_limit_exceeded | hourly_rate_limit_exceeded | unauthorized_rate_limit_exceeded) · 503 R2/index/freshness/rate-limit provider

Unauthorized abuse windows: 60/min + 360/hr before generic 401 path.

## Minimal curls
```bash
curl 'https://www.oddsvault.app/api/v1/status' -H 'Authorization: Bearer ov_live_YOUR_KEY'
curl 'https://www.oddsvault.app/api/v1/catalog' -H 'Authorization: Bearer ov_live_YOUR_KEY'
curl 'https://www.oddsvault.app/api/v1/events?sport=nba' -H 'Authorization: Bearer ov_live_YOUR_KEY'
curl 'https://www.oddsvault.app/api/v1/odds?sport=nfl&markets=h2h,spreads,totals&books=draftkings,pinnacle' \
  -H 'Authorization: Bearer ov_live_YOUR_KEY'
curl 'https://www.oddsvault.app/api/v1/convert?american=-110' -H 'Authorization: Bearer ov_live_YOUR_KEY'
curl 'https://www.oddsvault.app/api/v1/openapi'
```

## Implementation checklist
1. Read ODDSVAULT_API_KEY from env (account key from dashboard after entitled subscription/trial).
2. Prefer /status + /catalog + /books over hardcoded sports/books lists.
3. Use /events for boards; /events/{id} or /odds?event_id= for detail.
4. Never send planned market keys to /odds (400).
5. Treat 503 as retry later (missing R2, stale sources, or rate-limit provider). A fresh empty board is 200 with event_count 0.
6. Handle 403 subscription_required by sending the user to checkout/dashboard — not by retrying.
7. Use price_decimal for EV; american for display.
8. Fetch OpenAPI when generating clients.
9. Do not assume Fresh has newer odds than Standard — same snapshot, higher poll allowance only.

## Sport keys
nfl (NFL), ncaaf (NCAAF), nba (NBA), ncaab (NCAAB), wnba (WNBA), mlb (MLB), nhl (NHL), mls (MLS), epl (Premier League), la_liga (La Liga), serie_a (Serie A), bundesliga (Bundesliga), ligue_1 (Ligue 1), ucl (UEFA Champions League), europa_league (UEFA Europa League), world_cup (FIFA World Cup), soccer_intl (Soccer (other / international)), ufc (UFC), tennis (Tennis (ATP / WTA)), boxing (Boxing)

Tip: “Fetch OpenAPI + /status, then build a CLI that lists fresh NBA events and prints best moneylines.”