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. Prop market types are listed here whether or not they are currently servable —status and queryable flip to available/true once a given prop is live — so UIs and agents can plan ahead without inventing market keys. See Player props for the current glossary and coverage.

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": 34,
    "markets_available": 22,
    "markets_planned": 11
  }
}

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, player_pass_yds, player_pass_tds, player_rush_yds, player_receiving_yds, player_strikeouts, player_total_bases, player_rbis, player_runs, player_singles, player_doubles, player_triples, player_hits_runs_rbis, player_hits_allowed, player_earned_runs, player_outs, player_home_runs, player_stolen_bases, player_batter_strikeouts, player_hits. Planned: 11 types (team totals, periods). markets= on /odds / /events only ever accepts the four family values — h2h, spreads, totals, player_props. The individual player_* keys this endpoint lists (e.g. player_strikeouts) are documentation of what exists inside the player_props family — pass markets=player_props to get all of them, never the individual key.

Player props (live)
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,
    "sports_with_events": 11,
    "events_fresh": 184
  },
  "sports": [{
    "sport": "nfl",
    "fresh": true,
    "has_events": true,
    "has_snapshot": true,
    "version": 42,
    "generated_at": "2026-07-18T18:41:54.000Z",
    "age_seconds": 18,
    "event_count": 16,
    "books": ["draftkings", "pinnacle"],
    "books_quoting": ["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, player_pass_yds, player_pass_tds, player_rush_yds, player_receiving_yds, player_strikeouts, player_total_bases, player_rbis, player_runs, player_singles, player_doubles, player_triples, player_hits_runs_rbis, player_hits_allowed, player_earned_runs, player_outs, player_home_runs, player_stolen_bases, player_batter_strikeouts, player_hits
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"],
  "fresh_books": ["draftkings", "pinnacle"],
  "pipeline_fresh_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,
    "books": 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",
            "subject_type": "event",
            "subject_id": "event",
            "statistic": "result",
            "scope": "full_event",
            "period": "full",
            "unit": "none",
            "variant": "main",
            "line": null,
            "outcome_shape": "two_way",
            "settlement": "standard",
            "scale_confidence": "proven",
            "period_confidence": "proven"
          },
          "selections": [
            {
              "side": "home",
              "label": "Kansas City Chiefs",
              "books": [{
                "book": "draftkings",
                "price": -135,
                "price_decimal": 1.74,
                "scraped_at": "2026-07-18T18:41:58.000Z"
              }]
            }
          ]
        }
      ]
    }
  ]
}
  • 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.

Player props

Player props are opt-in: request them with markets=player_props. Omitting markets= — or passing any explicit list that does not include player_props — never returns props, so existing integrations are unaffected now that this is live. There is no per-statistic query filter: request the whole family and read identity.statistic / identity.subject_id (the player) per market, the same way two totals markets are told apart by identity.period.

Live now. Coverage is MLB AND WNBA only — both in-season. Every other tracked sport is out of season or preseason and returns no prop data even with markets=player_props requested.

Request props
bash
curl 'https://www.oddsvault.app/api/v1/odds?sport=mlb&markets=player_props' \
  -H 'Authorization: Bearer ov_live_YOUR_KEY'

# Alongside game lines, one call:
curl 'https://www.oddsvault.app/api/v1/odds?sport=mlb&markets=h2h,totals,player_props' \
  -H 'Authorization: Bearer ov_live_YOUR_KEY'

Confusable pairs — read this before you key on a statistic

These are the exact traps the canonical-statistic vocabulary exists to prevent: two markets that look like the same product and are not. Confusing either side silently returns a right-looking wrong number — same line, different market, no error to catch it.

Market AMarket BWhy they are NOT the same
player prop "strikeouts" (statistic: strikeouts)batter strikeouts (statistic: batter_strikeouts)MLB only. "strikeouts" bare is always the PITCHER's strikeouts thrown — the higher-volume, more heavily bet market. batter_strikeouts is the HITTER striking out (a bad outcome for that player). They are opposite roles on the same word. No live book currently prices batter_strikeouts distinctly from the pitcher market in production — if one starts, it will appear under batter_strikeouts and must not be read as a substitute for strikeouts, or vice versa.
hits (statistic: hits)hits + runs + RBIs (statistic: hits_runs_rbis)MLB only. hits_runs_rbis is a combo that happens to contain the word "hits" and often shares a numeric line with the plain hits market for the same player — they are still two different products with two different lines to clear. Never substitute one for the other.
points (statistic: points)points + assists (statistic: points_assists)WNBA. A distinct market from points alone, usually priced at a materially higher line. Confusing the two misreads the number a player needs to clear.
points + assists (statistic: points_assists) / points + rebounds (statistic: points_rebounds)points + rebounds + assists (statistic: points_rebounds_assists, "PRA")WNBA. Three separate combo products at three separate lines for the same player — a two-stat combo's text is a substring of the three-stat combo's conventional abbreviation ("PRA" contains "PA" and "PR"), which is exactly how a naive text match merges them. They never share a market_id.

Statistic glossary

One row per identity.statistic value served under family: "player_prop". books reflects one production cross-section (2026-08-20) of the live, merged, post-normalization feed — not a live-updating field. Call GET /api/v1/odds?sport=mlb&markets=player_props for current truth.

SportStatisticRoleMeaningSidesBooks observed
mlbhitsbatterTotal hits of any type (1B/2B/3B/HR all count as one hit).over / underbetmgm, draftkings, kalshi, novig, pointsbet, polymarket
mlbtotal_basesbatterTotal bases (1B=1, 2B=2, 3B=3, HR=4).over / underbetmgm, bovada, draftkings, novig, polymarket
mlbrbisbatterRuns batted in.over / underbetmgm, draftkings, kalshi, novig
mlbruns_scoredbatterRuns scored.over / underbetmgm, draftkings, novig
mlbhome_runsbatterHome runs.over / underbetonline, novig, polymarket, unibet
mlbsinglesbatterSingles only.over / underbetmgm, draftkings
mlbdoublesbatterDoubles only.over / underbetmgm, draftkings
mlbstolen_basesbatterStolen bases.over / underbetmgm, draftkings
mlbwalksbatterWalks (bases on balls) drawn by the batter.over / underdraftkings
mlbhits_runs_rbis
combo of hits + runs_scored + rbis
batterCombo: hits + runs scored + RBIs added together. A distinct product from any one of its components — see the confusable-pairs table.over / underbetmgm, betonline, draftkings, kalshi, novig, pointsbet, polymarket
mlbstrikeoutspitcherPITCHER strikeouts thrown. Bare "strikeouts" is always the pitcher market by convention — see the confusable-pairs table for the batter equivalent.over / underbetmgm, betonline, betrivers, bovada, draftkings, fanduel, kalshi, novig, polymarket
mlbhits_allowedpitcherHits allowed by the pitcher.over / underbetmgm, draftkings, novig, polymarket
mlbearned_runspitcherEarned runs allowed by the pitcher.over / underbetmgm, draftkings, novig, polymarket
mlbouts_recordedpitcherOuts recorded (innings pitched × 3, fractional innings included).over / underbetmgm, betonline, bovada, draftkings, novig, polymarket
mlbwalks_allowedpitcherWalks issued by the pitcher.over / underdraftkings, novig, polymarket
wnbapointsplayerPoints scored.over / underbovada, fanduel, kalshi, pinnacle
wnbareboundsplayerTotal rebounds (offensive + defensive).over / underbovada, fanduel, pinnacle
wnbaassistsplayerAssists.over / underbovada, fanduel, kalshi, pinnacle
wnbathree_pointersplayerThree-pointers made.over / underbovada, fanduel, pinnacle
wnbapoints_assists
combo of points + assists
playerCombo: points + assists added together. A distinct product from the points market and the assists market — never merge them.over / underbovada, fanduel
wnbapoints_rebounds
combo of points + rebounds
playerCombo: points + rebounds added together.over / underbovada, fanduel
wnbapoints_rebounds_assists
combo of points + rebounds + assists
playerCombo ("PRA"): points + rebounds + assists added together. A distinct product from points, from points_rebounds, and from points_assists — see the confusable-pairs table.over / underbovada, fanduel, pinnacle
wnbarebounds_assists
combo of rebounds + assists
playerCombo: rebounds + assists added together.over / underbovada, fanduel

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
No live books in the current index (or index predates the books field). Check GET /api/v1/books after the next publish cycle.

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_propavailableyes
player_pass_tdsPlayer passing touchdownsplayer_propavailableyes
player_rush_ydsPlayer rushing yardsplayer_propavailableyes
player_receiving_ydsPlayer receiving yardsplayer_propavailableyes
player_receptionsPlayer receptionsplayer_propplannedno
player_pointsPlayer pointsplayer_propplannedno
player_reboundsPlayer reboundsplayer_propplannedno
player_assistsPlayer assistsplayer_propplannedno
player_threesPlayer threesplayer_propplannedno
player_strikeoutsPlayer strikeoutsplayer_propavailableyes
player_total_basesPlayer total basesplayer_propavailableyes
player_rbisPlayer RBIsplayer_propavailableyes
player_runsPlayer runs scoredplayer_propavailableyes
player_singlesPlayer singlesplayer_propavailableyes
player_doublesPlayer doublesplayer_propavailableyes
player_triplesPlayer triplesplayer_propavailableyes
player_hits_runs_rbisPlayer hits + runs + RBIsplayer_propavailableyes
player_hits_allowedPlayer hits allowedplayer_propavailableyes
player_earned_runsPlayer earned runsplayer_propavailableyes
player_outsPlayer outs recordedplayer_propavailableyes
player_home_runsPlayer home runsplayer_propavailableyes
player_stolen_basesPlayer stolen basesplayer_propavailableyes
player_batter_strikeoutsBatter strikeoutsplayer_propavailableyes
player_hitsPlayer hitsplayer_propavailableyes
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_total_basesplayer_rbisplayer_runsplayer_singlesplayer_doublesplayer_triplesplayer_hits_runs_rbisplayer_hits_allowedplayer_earned_runsplayer_outsplayer_home_runsplayer_stolen_basesplayer_batter_strikeoutsplayer_hitsplayer_shotsplayer_anytime_td

Player-prop rows are documentation of what exists inside the player_props family — see Player props for meanings, sides, and current book/sport coverage. Individual player_* keys are never valid markets= values on their own.

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")  # MLB props are live

# 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 today: moneyline / spread / total (`h2h`, `spreads`, `totals`), plus player props (`markets=player_props`, opt-in — see below).
- 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: draftkings, fanduel, betmgm, betrivers, betonline, pinnacle, bovada, mybookie, unibet, pointsbet, novig, betopenly, kalshi, polymarket, prophetx, kutt, betteredge. 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. Team totals are cataloged as planned, not queryable on /odds.
- Period markets: cataloged as planned, not served via `periods=` — a market SCOPE filter, orthogonal to `markets=`. Accepted values are the canonical period keys (`full`, `1h`, `2h`, `q1`-`q4`, `p1`-`p3`, `i1`, `i1_5`, `f3`, `f7`, `set1`-`set3`) plus `all`; omitting it means `full` only, so existing calls are unaffected either way. `unknown` is never accepted and a market whose period could not be proven is never served.
- Player props: LIVE, opt-in via `markets=player_props` — never in the default `markets=` set, so existing calls are unaffected either way. Covers MLB AND WNBA only today (both in-season; every other tracked sport is out of season or preseason and carries no prop data). There is no per-statistic query filter — request the family and read `identity.statistic` / `identity.subject_id` per market.
- 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. draftkings, fanduel, betmgm, betrivers, betonline, pinnacle, bovada, mybookie, unibet, pointsbet, novig, betopenly, kalshi, polymarket, prophetx, kutt, betteredge |
| markets | no | queryable only: h2h, spreads, totals, player_props |
| event_id | no | single event |
| commence_after | no | ISO-8601 |
| commence_before | no | ISO-8601 |

Available market aliases: h2h→moneyline, spreads→spread, totals→total, player_pass_yds→player_prop, player_pass_tds→player_prop, player_rush_yds→player_prop, player_receiving_yds→player_prop, player_strikeouts→player_prop, player_total_bases→player_prop, player_rbis→player_prop, player_runs→player_prop, player_singles→player_prop, player_doubles→player_prop, player_triples→player_prop, player_hits_runs_rbis→player_prop, player_hits_allowed→player_prop, player_earned_runs→player_prop, player_outs→player_prop, player_home_runs→player_prop, player_stolen_bases→player_prop, player_batter_strikeouts→player_prop, player_hits→player_prop
Player-prop market keys (documentation only — request the whole family with markets=player_props, not these individually): 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_total_bases, player_rbis, player_runs, player_singles, player_doubles, player_triples, player_hits_runs_rbis, player_hits_allowed, player_earned_runs, player_outs, player_home_runs, player_stolen_bases, player_batter_strikeouts, player_hits, player_shots, player_anytime_td

## Player props (opt-in, family filter only)
- Request with `markets=player_props` (never in the default set — omitting `markets=` never returns props).
- Coverage today: mlb, wnba only. Every other tracked sport is out of season/preseason and carries no prop data even with the family requested.
- Statistics served (sport:statistic(role)): mlb:hits(batter), mlb:total_bases(batter), mlb:rbis(batter), mlb:runs_scored(batter), mlb:home_runs(batter), mlb:singles(batter), mlb:doubles(batter), mlb:stolen_bases(batter), mlb:walks(batter), mlb:hits_runs_rbis(batter), mlb:strikeouts(pitcher), mlb:hits_allowed(pitcher), mlb:earned_runs(pitcher), mlb:outs_recorded(pitcher), mlb:walks_allowed(pitcher), wnba:points(player), wnba:rebounds(player), wnba:assists(player), wnba:three_pointers(player), wnba:points_assists(player), wnba:points_rebounds(player), wnba:points_rebounds_assists(player), wnba:rebounds_assists(player)
- Confusable pairs — the exact traps that silently return a right-looking wrong number if conflated:
- player prop "strikeouts" (statistic: strikeouts) vs batter strikeouts (statistic: batter_strikeouts): MLB only. "strikeouts" bare is always the PITCHER's strikeouts thrown — the higher-volume, more heavily bet market. batter_strikeouts is the HITTER striking out (a bad outcome for that player). They are opposite roles on the same word. No live book currently prices batter_strikeouts distinctly from the pitcher market in production — if one starts, it will appear under batter_strikeouts and must not be read as a substitute for strikeouts, or vice versa.
- hits (statistic: hits) vs hits + runs + RBIs (statistic: hits_runs_rbis): MLB only. hits_runs_rbis is a combo that happens to contain the word "hits" and often shares a numeric line with the plain hits market for the same player — they are still two different products with two different lines to clear. Never substitute one for the other.
- points (statistic: points) vs points + assists (statistic: points_assists): WNBA. A distinct market from points alone, usually priced at a materially higher line. Confusing the two misreads the number a player needs to clear.
- points + assists (statistic: points_assists) / points + rebounds (statistic: points_rebounds) vs points + rebounds + assists (statistic: points_rebounds_assists, "PRA"): WNBA. Three separate combo products at three separate lines for the same player — a two-stat combo's text is a substring of the three-stat combo's conventional abbreviation ("PRA" contains "PA" and "PR"), which is exactly how a naive text match merges them. They never share a market_id.
- Every prop market is two-sided (over/under). Compare quotes only within one `market_id`; `identity.statistic` plus `identity.subject_id` (the player) is what distinguishes one prop from another, the same way `identity.period` distinguishes two totals markets.

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)

## Refresh cadence
Every book targets the same default refresh interval — 60s — individually
adjustable per book in the admin panel without a deploy. Catalog books:
draftkings, fanduel, betmgm, betrivers, betonline, pinnacle, bovada, mybookie,
unibet, pointsbet, novig, betopenly, kalshi, polymarket, prophetx. Not every
catalog book is live at a given moment — check `GET /api/v1/books` for the
current live set and each book's actual cadence.

## 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. price_decimal is derived from price and rounded HALF-UP, so validate it with ROUND_HALF_UP — half-even (Python's round(), NumPy, IEEE-754 default) disagrees on -160 and -800, the only two prices in the feed that land on a two-decimal tie.
8. Fetch OpenAPI when generating clients. Pin info.version and read info.x-changelog to detect contract changes.
9. Do not assume Fresh has newer odds than Standard — same snapshot, higher poll allowance only.
10. Size your consensus from fresh_books (books that actually quote in the payload), not pipeline_fresh_books (scrapers that are merely healthy). The two differ whenever a live book does not price the slate you asked for.
11. Compare quotes only within one market_id, and carry every identity field through your keying — including scale_confidence, which separates a proven sets/games scale from an unproven one, and period_confidence, which separates a market whose period is known from one served as period "unknown" because no period could be proven from the source.

## 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.”