{"openapi":"3.1.0","info":{"title":"VividOdds API","version":"1.0.0","summary":"Odds data, in high definition.","description":"One schema. EU decimal default. True line is no-vig from PS3838 (or another sharp book) — never invented. Guest screen on the Sharp hub is a 5-match demo; book lists come from map-audit. Italy warehouse pack (`italy-pack`) and Ontario pack (`canada-pack`) serve authorized JSONL dumps under packages/warehouse/italy and packages/warehouse/canada. We do not claim 200 books or 1M odds/sec.\n\nProtected data routes require an API key via `x-api-key` or `Authorization: Bearer`. Query-string keys are not accepted. Set `VIVIDODDS_API_KEYS` (preferred, `label:key` pairs) and/or `VIVIDODDS_API_KEY` on the server; never put a real key in this document."},"servers":[{"url":"https://api.edgebets.io","description":"this process"}],"tags":[{"name":"catalog"},{"name":"odds"},{"name":"true"},{"name":"value"},{"name":"board"},{"name":"soft-odds"},{"name":"stream"}],"components":{"securitySchemes":{"ApiKeyAuth":{"type":"apiKey","in":"header","name":"x-api-key","description":"API key. Prefer this header."},"BearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"API key","description":"Same key as Authorization: Bearer <key>."}},"schemas":{"SoftOddsObserve":{"type":"object","required":["book","sport","home","away","observed_decimal","side"],"properties":{"book":{"type":"string","example":"888sport"},"sport":{"type":"string","example":"soccer"},"home":{"type":"string","example":"Le Mans"},"away":{"type":"string"},"start":{"type":"string","format":"date-time","description":"Optional fixture start. Day (YYYY-MM-DD) is part of the ticket key. Omit to match any start for that book+teams+market+line+side."},"market_key":{"type":"string","example":"total","description":"Soft EV market key. Aliases: market, market_raw. `ou`/`totals` classify as `total`; `ml`/`1x2` as `1x2`. Exact key match only after classification."},"market_family":{"type":"string","example":"total","description":"Accepted when market_key is omitted."},"line":{"type":"number","example":2.5,"description":"Required for total/spread. Ignored for 1X2."},"side":{"type":"string","example":"over","description":"over/under/home/away/draw/…"},"period":{"type":"string","example":"ft","default":"ft"},"observed_decimal":{"type":"number","exclusiveMinimum":1,"example":1.85,"description":"Live Soft European decimal observed at the book."},"ticket_decimal":{"type":"number","exclusiveMinimum":1,"description":"Warehouse/ticket Soft the placer searched. When set, applied Soft is never raised above this."},"reason":{"type":"string","example":"price_gate","description":"Placer skip reason (`price_gate`, `odds_mismatch`, …)."},"ttl_sec":{"type":"integer","default":3600,"minimum":60,"maximum":86400,"description":"Overlay lifetime. Default 3600; clamped to 60–86400."}}},"SoftOddsClear":{"type":"object","properties":{"key":{"type":"string","description":"Opaque ticket key returned by POST /v1/soft-odds/observe."},"book":{"type":"string"},"sport":{"type":"string"},"home":{"type":"string"},"away":{"type":"string"},"start":{"type":"string"},"market_key":{"type":"string"},"line":{"type":"number"},"side":{"type":"string"}}}}},"paths":{"/v1/health":{"get":{"tags":["catalog"],"summary":"Liveness (public). Process up + path stats; does not parse warehouse JSONL or probe the hub. pin.pinAgeSec is the age in seconds of the newest captured_at inside the PS3838 slate. File mtime is used only when that file has no capture time. pin.stale is true when that capture is missing or older than 30 minutes. pinIndexBuiltAt / pinIndexAgeSec / pinIndexGeneration describe the published PS3838 snapshot /v1/value and the board share. pinIndexWarming is true while a newer file is still building. board.book_ages is the newest, oldest, and median row age per book on the published index (in memory; health does not parse JSONL). board.book_build_ms is milliseconds spent reading each slate (including pinnacle) on the last rebuild that opened it. The board index polls Italy and Pin slate size+mtime (BOARD_REBUILD_INTERVAL_MS, default 20s) and refreshes a changed book in the background, serving the previous snapshot until the swap. A rebuild yields to /v1/value for at most BOARD_VALUE_WAIT_MS (default 2s) so a continuous value poll cannot freeze built_at.","security":[],"responses":{"200":{"description":"ok"}}}},"/v1/sports":{"get":{"tags":["catalog"],"summary":"Sports merged from sharp-hub + italy-pack + canada-pack","responses":{"200":{"description":"sport list"},"401":{"description":"Missing or invalid API key"},"503":{"description":"Server has no API key configured (fail-closed)"}},"security":[{"ApiKeyAuth":[]},{"BearerAuth":[]}]}},"/v1/books":{"get":{"tags":["catalog"],"summary":"Books from hub map-audit + Italy + Ontario warehouse ids","responses":{"200":{"description":"book list"},"401":{"description":"Missing or invalid API key"},"503":{"description":"Server has no API key configured (fail-closed)"}},"security":[{"ApiKeyAuth":[]},{"BearerAuth":[]}]}},"/v1/fixtures":{"get":{"tags":["catalog"],"summary":"Fixtures for a sport/date (hub + Italy + Canada)","parameters":[{"name":"sport","in":"query","schema":{"type":"string"},"example":"soccer"},{"name":"date","in":"query","schema":{"type":"string","format":"date"}}],"responses":{"200":{"description":"fixtures"},"401":{"description":"Missing or invalid API key"},"503":{"description":"Server has no API key configured (fail-closed)"}},"security":[{"ApiKeyAuth":[]},{"BearerAuth":[]}]}},"/v1/odds":{"get":{"tags":["odds"],"summary":"Normalized offers (decimal). Warehouse rows tagged source italy-pack or canada-pack.","parameters":[{"name":"sport","in":"query","schema":{"type":"string"}},{"name":"fixture","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"offers"},"401":{"description":"Missing or invalid API key"},"503":{"description":"Server has no API key configured (fail-closed)"}},"security":[{"ApiKeyAuth":[]},{"BearerAuth":[]}]}},"/v1/odds/stream":{"get":{"tags":["stream"],"summary":"SSE of the same Offer schema","responses":{"200":{"description":"text/event-stream"},"401":{"description":"Missing or invalid API key"},"503":{"description":"Server has no API key configured (fail-closed)"}},"security":[{"ApiKeyAuth":[]},{"BearerAuth":[]}]}},"/v1/true":{"get":{"tags":["true"],"summary":"No-vig True line (true_mode=pin|composite). 409 if no Pin/sharp ref.","parameters":[{"name":"fixture","in":"query","required":true,"schema":{"type":"string"}},{"name":"sport","in":"query","schema":{"type":"string"}},{"name":"true_mode","in":"query","schema":{"type":"string","enum":["pin","composite"],"default":"pin"},"description":"pin: single SHARP_REF (Pin warehouse first). composite: close-quality blend across Pin+PS3838+Circa/Novig/BetOnline when slates exist."}],"responses":{"200":{"description":"true line"},"400":{"description":"missing fixture"},"401":{"description":"Missing or invalid API key"},"409":{"description":"no sharp ref — will not invent"},"503":{"description":"Server has no API key configured (fail-closed)"}},"security":[{"ApiKeyAuth":[]},{"BearerAuth":[]}]}},"/v1/value":{"get":{"tags":["value"],"summary":"Matched soft value rows vs True (Italy + Ontario canada-pack; true_mode=pin|composite). Never invents True.","parameters":[{"name":"sport","in":"query","schema":{"type":"string"},"example":"soccer"},{"name":"book","in":"query","schema":{"type":"string"},"example":"admiralbet","description":"Canada: scope to one soft book. Italy `/v1/value` ignores this and returns every Soft-fresh Italy book."},{"name":"books","in":"query","schema":{"type":"string"},"example":"caesars,thescore","description":"Comma-separated soft book ids. Canada queries stay book-scoped. Italy `/v1/value` ignores this and scans every Soft-fresh Italy book (Value, arb_count, and any middle/low-hold read of this tape)."},{"name":"country","in":"query","schema":{"type":"string"},"example":"canada","description":"`canada`/`ca`/`on` streams canada-pack only and stays book-scoped. `italy`/`it` streams every Soft-fresh Italy book (book= does not narrow it). Omit to scan Italy full-set plus Canada."},{"name":"region","in":"query","schema":{"type":"string","enum":["italy","global","france","spain","canada"],"default":"italy"},"description":"Soft tape. Default italy (current callers unchanged). global reads /var/data/global/{book}-slate.jsonl, france reads /var/data/france/{book}-slate.jsonl, spain reads /var/data/spain/{book}-slate.jsonl, canada reads /var/data/canada-region/{book}_ca-slate.jsonl, each with the same Pin join, soft-age gates, and Pin-universe filter. Each is 404 until its flag is on (ENABLE_REGION_GLOBAL, ENABLE_REGION_FR, ENABLE_REGION_ES). Any other value is 400 region_unsupported."},{"name":"fixture","in":"query","schema":{"type":"string"}},{"name":"true_mode","in":"query","schema":{"type":"string","enum":["pin","composite"],"default":"pin"},"description":"pin: Pin warehouse then hub. Default for every country unless true_mode=composite is set. composite: close-quality blend of Pin+PS3838+Circa/Novig/BOL. `country=canada` without sport= still loads soccer+american_football True slices. Sharp rows older than soft_max_age_sec are dropped (skipped_stale_sharp)."},{"name":"min_ev","in":"query","schema":{"type":"number"},"description":"Optional floor on EV. Default: all matched rows (including negative EV). Join still fail-closes absurd Soft EV (`|EV| > 1.0` any market; `|EV| > 0.5` soccer FT goals OU and american_football mains) so those rows never appear in data[]. Italy tennis handicap (`family=spread`) Soft EV is fail-closed until Pin True side + signed line is proven on live slates (not via an EV% gate). Tennis ML and games U/O still join."},{"name":"min_odds","in":"query","schema":{"type":"number","exclusiveMinimum":0},"example":1.5,"description":"Optional inclusive floor on soft-book European decimal odds (`it_decimal`). Applied before `limit` so longshots do not fill the cap. Omit for no floor. Invalid (non-numeric) values are ignored."},{"name":"max_odds","in":"query","schema":{"type":"number","exclusiveMinimum":0},"example":2.5,"description":"Optional inclusive ceiling on soft-book European decimal odds (`it_decimal`). Applied before `limit`. Omit for no ceiling. `min_odds` > `max_odds` is 400. Invalid (non-numeric) values are ignored."},{"name":"min_arb_count","in":"query","schema":{"type":"integer","minimum":0},"example":8,"description":"Optional floor on Soft↔Soft `arb_count`. Drops rows where arb_count is null (non-2-way) or arb_count < N. Applied before `limit`. Counts other italy-pack + canada-pack books on the same fixture+market+line whose opposite 2-way price `o` satisfies (1/it_decimal + 1/o) < 1. Does not apply Betware odds/edge gates. Invalid (non-numeric / negative) values are ignored."},{"name":"limit","in":"query","schema":{"type":"integer","minimum":0},"description":"Optional cap on data[] after a fair multi-book scan. Highest EV first. Every Soft-fresh book that matched still contributes at least one row, even when that exceeds `limit`. Counts only in-band rows when min_odds/max_odds are set."},{"name":"markets","in":"query","schema":{"type":"string","enum":["mains","all"]},"description":"Default soccer mains only (1X2/ML, match OU+alts, AH). `all` restores props/exotics/BTTS/DC/corners."},{"name":"mains_only","in":"query","schema":{"type":"boolean"},"description":"Default true: 1X2/ML, totals, Asian spreads, scoped team totals, BTTS, and draw-no-bet / double chance derived from Pin 1X2. false also includes corners, bookings, and props."}],"responses":{"200":{"description":"data[] is matched value rows only (soft joined to True with EV): book, home/away, league (PS3838 league), market, selection, line, it_decimal, true_decimal, ev, arb_count, pin_age_sec, start. Default Italy loads every Pin sport in one pass (not soccer only). Draw-no-bet and double chance true_decimal are derived from a complete Pin 1X2; corners stay behind markets=all. arb_count is Soft↔Soft 2-way agreement across italy-pack AND canada-pack (not Pin/True): other soft books on the same fixture+market+line whose opposite price o satisfies (1/it_decimal + 1/o) < 1. Non-2-way markets (soccer 1X2) have arb_count null. Optional min_arb_count drops null or below-N rows before limit. Unmatched soft quotes are counted in skipped_count, not serialized. Soft older than VIVIDODDS_SOFT_MAX_AGE_SEC (default 4h / 14400s; positive clamped to 6h / 21600; 0 disables) is fail-closed via captured_at (slate mtime is only a skip hint), counted in skipped_stale / skipped_count, and never appears in data[]. Composite sharp rows use that same captured_at gate and are counted in skipped_stale_sharp (pin-mode responses omit that field). Italy /v1/value also omits italy-pack rows older than 60 minutes (soft_window_sec), the same window as board opportunities; that omission is not skipped_stale and does not change the 4h gate. VIVIDODDS_SOFT_MAX_AGE_SEC=0 disables the 60-minute window as well as the hard gate. Rows past the 4h gate never appear in data[]. Soft ≤20m is cadence, not a query gate. Envelope echoes soft_max_age_sec. Unexpired POST /v1/soft-odds/observe overlays replace matching Soft it_decimal (never above ticket_decimal; exact market key) and skip the warehouse age gate (overlay TTL). EV is recomputed before min_ev / min_odds. Absurd Soft EV is fail-closed (|EV| > 1.0 any row; |EV| > 0.5 soccer FT goals OU and american_football mains) and stays out of data[]. Mains only by default; optional min_odds/max_odds (European decimal, inclusive) filter soft odds before limit. Envelope echoes min_odds/max_odds when set. Italy or Ontario `{book}-slate.jsonl` is streamed line-by-line against cached Pin indexes. Default Italy query round-robins every Soft-fresh book (not smallest-first) and returns Pin-matched rows from each book that hit. Soft-arb index is lazy per-book (single book= skips that book). Ops meta: scanned_lines, arb_index_ms, join_ms, deadline_hit, partial, books_covered, arb_index_partial, stale_books. early_exit and scan_budget are not public parameters. The join yields the event loop between chunks so /v1/health stays within Render's 5s probe. A slate file whose mtime is already older than the soft max age is skipped without a scan (stale_books). VIVIDODDS_VALUE_DEADLINE_MS (default 6500, max 7500) always returns a JSON body; deadline_hit sets partial=true and still includes matches found so far. Pack-level pin_captured_at / pin_age_sec on the envelope."},"400":{"description":"min_odds > max_odds"},"401":{"description":"Missing or invalid API key"},"503":{"description":"Server has no API key configured (fail-closed)"}},"security":[{"ApiKeyAuth":[]},{"BearerAuth":[]}]}},"/v1/board":{"get":{"tags":["board"],"summary":"Italy soft books side by side with PS3838 (true_mode=pin). Reads a prebuilt index; does not scan slates or take the value lock.","description":"PS3838 fixtures only, joined with the same matcher as /v1/value (match key, token-fuzzy names, kickoff agreement: soccer ±30m, other sports ±3h on the same UTC date). Soft quotes with no Pin fixture are omitted. Scoped team totals join; European 3-way handicaps stay off. Draw-no-bet and double chance fair_decimal are derived from the main PS3838 match-result 1X2 (alternate and is_main=false markets are ignored). Fixtures drop at kickoff. Each quote includes skin_group, and the payload includes skin_groups, so copy-skins can be counted once. Hockey and basketball 2-way moneylines (including overtime) stay off the regulation 3-way. Totals and spreads join only when the soft line matches that PS3838 side. League is the PS3838 league. Moneyline markets include ways (2 or 3) so a basketball 1X2 is not the same row as a 2-way moneyline. dropped_by_reason counts lines discarded on the last rebuild (no_pin_fixture, unmapped_market, line_mismatch, parse_error, and the excluded_* buckets). The index keeps kickoffs from today through +60 days (BOARD_MAX_DAYS) and rebuilds one changed book at a time (Pin changes re-join each book from a per-file parse cache, so unchanged book files are not re-parsed). Pre-match regions rebuild one at a time. Rebuilds wait while /v1/value is busy, cap fixtures (BOARD_MAX_FIXTURES, default 60000) and quotes per book (BOARD_MAX_QUOTES_PER_BOOK, default 250000), and abort without dropping the last good index when RSS passes max(60% of BOARD_MEM_LIMIT_MB (default 4096), build-start RSS + 20%) capped at 92%, or when the V8 heap passes 85% of its limit. Soft quotes older than soft_max_age_sec (default 4h) are returned with stale=true and must not be used for best-price or EV. fair_decimal is PS3838 no-vig and is omitted when that Pin market is incomplete, its margin is above 10% for a three-way (two-way above 7%, logged as pin_overround), the period's main Pin handicap or total margin is above 7% (pin_match_widened), or a three-way Pin limit is $100 or below (pin_low_limit). Team-total markets put home or away in the key (team_total_home, team_total_away) and the label so two clubs at the same line do not collide. country=italy is required. Date window defaults to today through +3 days. from/to may be up to 60 days apart. window=all or days=60 returns the whole indexed range.","parameters":[{"name":"country","in":"query","required":true,"schema":{"type":"string","enum":["italy"]},"description":"Required. Only italy is supported."},{"name":"region","in":"query","schema":{"type":"string","enum":["italy","global","france","spain","canada"],"default":"italy"},"description":"Default italy. global, france, spain, and canada use their sibling slate directory and the same board join. 404 until ENABLE_REGION_GLOBAL / ENABLE_REGION_FR / ENABLE_REGION_ES / ENABLE_REGION_CA is on; any other value is 400. country=italy is still required."},{"name":"true_mode","in":"query","schema":{"type":"string","enum":["pin"],"default":"pin"},"description":"PS3838 no-vig. composite is not supported on this route."},{"name":"sport","in":"query","schema":{"type":"string"},"example":"soccer"},{"name":"date","in":"query","schema":{"type":"string","format":"date"},"description":"Single UTC kickoff day (YYYY-MM-DD). Do not combine with from/to."},{"name":"from","in":"query","schema":{"type":"string","format":"date"},"description":"Window start (UTC day). Alias date_from. Default is today. Inclusive."},{"name":"to","in":"query","schema":{"type":"string","format":"date"},"description":"Window end (UTC day). Alias date_to. Default is today+3 days. Inclusive. from and to may be at most BOARD_MAX_DAYS apart (default 60)."},{"name":"window","in":"query","schema":{"type":"string","enum":["all"]},"description":"window=all returns every indexed kickoff (today through BOARD_MAX_DAYS). Do not combine with date, from/to, or days. Still subject to markets and limit."},{"name":"days","in":"query","schema":{"type":"integer","minimum":0,"maximum":60},"description":"Forward span from today. days=60 returns the whole indexed range (today through +60). Do not combine with date, from/to, or window."},{"name":"markets","in":"query","schema":{"type":"string"},"example":"1x2,total,total_h1,ah","description":"Comma-separated normalised markets. 1x2 (ft), total (ft), total_h1, ah (spread ft), plus other normalised keys (btts, corners_total, …). Omit for every joinable market. mains = 1x2+total+ah full time."},{"name":"limit","in":"query","schema":{"type":"integer","minimum":0},"description":"Cap on fixtures after filters, earliest kickoff first."}],"responses":{"200":{"description":"Pin-matched fixtures. Started fixtures are omitted. books lists pinnacle plus Italian soft books present in the payload. skin_groups maps each book to its copy-skin id. Each quote includes skin_group. book_ages.captured_at, age_sec, and stale are the newest row (stale still follows that newest row: 4h soft, 60 min Pin). oldest_captured_at, oldest_age_sec, and median_age_sec are the oldest row and the median row age, so a few fresh fast-lane prices cannot hide an old slate. selections.fair_decimal is Pin no-vig when the Pin market is complete. Soft book quotes include stale when captured_at is older than soft_max_age_sec (default 14400). PS3838 quotes include stale, and pinAgeSec is the book age. A PS3838 price older than 60 minutes does not set fair_decimal. An explicit three-way label (1X2, (3-way), Match result) does not join a two-way moneyline. Moneyline - 1st Half and Moneyline - Quarter 1 stay on the two-way period line. A full-time moneyline whose favourite is PS3838's underdog by a wide margin drops that book's fixture (orientation_mismatch), unless swapping home and away lands within 0.10 implied probability of PS3838 on both sides, in which case the book's home/away quotes are reoriented. Tennis, table tennis, MMA, and boxing quotes follow the participant name onto PS3838's two players, and the full-time favourite-reversal gate does not apply to those sports. A participant name that already contains a handicap line is dropped and counted as name_has_line. A set, half, quarter, or period moneyline whose favourite is inverted against both that book's full-time line and PS3838's period line drops only that period (period_orientation_mismatch). A period favourite that PS3838 also has the other way stays. A skin-family price far from both PS3838 and the median implied probability of the other families stays on the row with outlier_price and reason outlier_book_group, and is left out of default edges. The wide band is 30% of the larger implied probability versus both, with at least three other families. A tighter band flags one family that is longer than PS3838, longer than the median of at least two other families, and at least 15% of implied probability past the second-longest family, when that median is within 12% of PS3838. Double chance and draw no bet use the derived fair as that reference when PS3838 has no price. ITF, Challenger qualifying, and UTR are pin_thin at any limit. Other tennis is pin_thin at or under a $500 PS3838 limit. ATP and WTA, including qualifying, are not. When every independent family is at least 8% of implied probability away from that thin line on the same side, there is no edge (pin_thin_disagreement). A soccer three-way whose PS3838 limit is at or under $200 is pin_thin when an outcome's fair is above 10, and that longshot alone is cleared when every family disagrees the same way. A Corners or Bookings child matchup that shares the parent fixture id does not replace the match league. Stale quotes are not inputs to fair_decimal. index_built_at is when the in-memory index was built; requests do not stream slate files."},"400":{"description":"Missing country, unsupported country/true_mode, bad date, mixed window params, or from/to more than BOARD_MAX_DAYS apart"},"401":{"description":"Missing or invalid API key"},"503":{"description":"Server has no API key configured (fail-closed)"}},"security":[{"ApiKeyAuth":[]},{"BearerAuth":[]}]}},"/v1/board/mapping-health":{"get":{"tags":["board"],"summary":"Counts of board rows with |edge| > 25%, by book and suspected cause.","description":"Lightweight monitor for mapping bugs. Edge is soft decimal / PS3838 no-vig fair - 1. Causes include post_kickoff, high_pin_margin, hockey_moneyline, basketball_regulation, one_x2_outlier, derived_double_chance, hockey_ot_total, unscaled_total, period_labelled_ft, player_prop, total_line, spread_sign, eu_handicap, basketball_total, soccer_total, tennis_market, btts, corners, team_total, thin_single_book, period_orientation_mismatch, orientation_mismatch, name_has_line, outlier_book_group, pin_thin_disagreement, pin_thin_disagree, single_book_fixture, implausible_two_way, shifted_ladder, longshot_consensus_fair, pin_cross_market, thin_alternate, dc_orientation, tennis_spread_scope, tennis_pin_ft_line, pin_key_ambiguous, spread_wrong_side, spread_incoherent, and other. name_has_line counts soft rows whose participant text already contains a handicap. outlier_book_group, pin_thin_disagree, single_book_fixture, shifted_ladder, pin_cross_market, and thin_alternate are counted even when |edge| is 25% or below. longshot_consensus_fair, dc_orientation, tennis_spread_scope, and tennis_pin_ft_line count those fair moves, reoriented or scope-split quotes, and small tennis games lines kept off generic Pin ah/ou full time. pin_cross_market is a thin or missing match-result longshot that disagrees with the handicap and total; the PS3838 fair is kept. thin_alternate is one book group on a non-main total or spread with a thin or missing limit. Started fixtures are omitted from the counts and reported as started_fixtures_hidden. Each book entry includes skin_group so copy-skins are not counted as independent confirmations. Reads the in-memory board index for region (default italy). region=global is 404 unless ENABLE_REGION_GLOBAL is on (france: ENABLE_REGION_FR, spain: ENABLE_REGION_ES). region=global also returns parse_errors, the last rebuild's parse_error drops by cause (no_price, price_below_min, missing_teams, bad_book, json). line_drops is every line-drop cause from that rebuild, the same counts as the board's dropped_by_reason.","parameters":[{"name":"region","in":"query","schema":{"type":"string","enum":["italy","global","france","spain","canada"],"default":"italy"},"description":"Board index to summarize. Default italy."}],"responses":{"200":{"description":"rows, by_cause, and by_book[].skin_group / by_cause."},"401":{"description":"Missing or invalid API key"},"503":{"description":"Server has no API key configured (fail-closed)"}},"security":[{"ApiKeyAuth":[]},{"BearerAuth":[]}]}},"/v1/board/opportunities":{"get":{"tags":["board"],"summary":"Precomputed value, arb, middle, and low-hold rows from the board index.","description":"Recomputed in memory after each board index rebuild from boardIndexSnapshot(). Does not re-read Italy or PS3838 slate files, does not scan /v1/value, and does not take the value_busy slot. /v1/value is unchanged for the placer and single-book callers. Prematch only. Soft prices older than max_age_min (default 60, maximum 60 — the snapshot window) are omitted. Value rows are soft price / Pin no-vig fair - 1, id fixture|market|line|side|book (market includes _h1/_h2 when the period is not full time). EV above 25% or a price above 1.4× the median of the other fresh soft books on that side is returned with flag=suspicious. Mismap guards drop the row: team totals, non-1X2 EV above 30%, soccer FT goals OU |EV| above 50%, american_football mains |EV| above 50%, any |EV| above 100%, soft price not longer than the Pin raw same side, inverted totals/BTTS (soft price closer to the opposite Pin side), and same-book half-line Over+Under underround. Arb is the best distinct-book price per side with margin = 1 - sum(1/price) > 0. include_pin defaults on, so PS3838 may be an arb or middle leg; include_pin=0 keeps soft books only. Middle is the adjacent overlapping total or spread window (width at most 3). Low hold is the best soft price per side with 0 <= hold < max_hold (default 2%, stored up to 5%). Sharp Action is not a kind — it needs a rolling PS3838 price history the index does not keep. index_built_at is the board snapshot time. snapshot_counts are the stored rows before query filters. truncated is true when a kind hit its cap (BOARD_OPPS_VALUE_MAX default 20000, other kinds 8000).","parameters":[{"name":"kind","in":"query","required":true,"schema":{"type":"string","enum":["value","arb","middle","lowhold"]},"description":"Which precomputed set to read. Sharp Action is not available."},{"name":"region","in":"query","schema":{"type":"string","enum":["italy","global","france","spain","canada"],"default":"italy"},"description":"Which board snapshot to read. Default italy. global, france, spain, and canada are 404 until their flag is on (ENABLE_REGION_GLOBAL, ENABLE_REGION_FR, ENABLE_REGION_ES, ENABLE_REGION_CA). Value, arb, middle, and low-hold use that region's index."},{"name":"sport","in":"query","schema":{"type":"string"},"example":"soccer"},{"name":"min_ev","in":"query","schema":{"type":"number","minimum":0,"default":0.01},"description":"Value only. Decimal EV floor (0.01 = 1%). Default 0.01."},{"name":"max_age_min","in":"query","schema":{"type":"number","minimum":0,"maximum":60,"default":60},"description":"Drop rows whose price (or any leg) was captured more than this many minutes ago. Default 60. Cannot exceed the snapshot window."},{"name":"book","in":"query","schema":{"type":"string"},"description":"Value: that book only. Arb, middle, low hold: at least one leg from that book."},{"name":"include_pin","in":"query","schema":{"type":"boolean","default":true},"description":"Arb and middle. When true (the default), PS3838 may be one of the legs. include_pin=0 keeps soft books only."},{"name":"max_hold","in":"query","schema":{"type":"number","minimum":0,"maximum":0.05,"default":0.02},"description":"Low hold only. Keep markets whose best-price hold is strictly under this. Default 0.02."},{"name":"limit","in":"query","schema":{"type":"integer","minimum":0,"maximum":1000,"default":100},"description":"Page size. Default 100, maximum 1000."},{"name":"cursor","in":"query","schema":{"type":"integer","minimum":0},"description":"Offset into the filtered list from the previous next_cursor. Default 0."}],"responses":{"200":{"description":"data[] for the requested kind. index_built_at is when the board snapshot (and these rows) was built. next_cursor is null on the last page. Value rows include price, fair_decimal, true_decimal (same Pin no-vig), ev, captured_at, pin_captured_at, flag, and skin_group. Arb and low-hold rows include legs (each with skin_group) and margin or hold. Middle rows include window {low, high} and legs."},"400":{"description":"Missing or invalid kind, min_ev, max_age_min, max_hold, limit, cursor, or include_pin"},"401":{"description":"Missing or invalid API key"},"503":{"description":"Server has no API key configured (fail-closed)"}},"security":[{"ApiKeyAuth":[]},{"BearerAuth":[]}]}},"/v1/soft-odds/observe":{"post":{"tags":["soft-odds"],"summary":"Observe a live Soft decimal so /v1/value stops advertising a stale warehouse price","description":"Authenticated write for Italy/Ontario placers (Betware, EdgeBets). Persists an overlay keyed by Soft EV identity (book + fixture + market key + line + side). `/v1/value` replaces matching `it_decimal` with `observed_decimal` (never above `ticket_decimal` when provided) and recomputes EV. Never invents True. Exact market-key match only after classification (`ou`→`total`). Default TTL 3600s, clamped 60–86400. Store: `/var/data/soft-odds-overlay.json` or `VIVIDODDS_SOFT_OVERLAY_PATH`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SoftOddsObserve"},"example":{"book":"888sport","sport":"soccer","home":"Le Mans","away":"Guingamp","start":"2026-09-11T18:00:00Z","market_key":"total","line":2.5,"side":"over","observed_decimal":1.85,"ticket_decimal":2.1,"reason":"price_gate","ttl_sec":3600}}}},"responses":{"200":{"description":"Overlay stored. `/v1/value` will use applied_decimal for that ticket until expires_at. applied_decimal is min(observed_decimal, ticket_decimal) when ticket_decimal is set."},"400":{"description":"Invalid body (missing book/fixture/market/side/observed_decimal, or decimal ≤ 1)"},"401":{"description":"Missing or invalid API key"},"413":{"description":"JSON body too large"},"503":{"description":"Server has no API key configured (fail-closed)"}},"security":[{"ApiKeyAuth":[]},{"BearerAuth":[]}]},"delete":{"tags":["soft-odds"],"summary":"Clear a Soft overlay by key or ticket identity","description":"Same identity fields as POST, or `key` from the observe response. Query-string identity is accepted so clients that cannot send a DELETE body can still clear. Also available as POST /v1/soft-odds/clear.","requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SoftOddsClear"}}}},"responses":{"200":{"description":"ok, cleared true|false"},"400":{"description":"Missing key and identity"},"401":{"description":"Missing or invalid API key"},"503":{"description":"Server has no API key configured (fail-closed)"}},"security":[{"ApiKeyAuth":[]},{"BearerAuth":[]}]}},"/v1/soft-odds/clear":{"post":{"tags":["soft-odds"],"summary":"Clear a Soft overlay (POST alias of DELETE /v1/soft-odds/observe)","requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SoftOddsClear"}}}},"responses":{"200":{"description":"ok, cleared true|false"},"400":{"description":"Missing key and identity"},"401":{"description":"Missing or invalid API key"},"503":{"description":"Server has no API key configured (fail-closed)"}},"security":[{"ApiKeyAuth":[]},{"BearerAuth":[]}]}},"/v1/openapi.json":{"get":{"summary":"This document (public)","security":[],"responses":{"200":{"description":"OpenAPI 3.1"}}}},"/v1/docs":{"get":{"summary":"HTML docs (public)","security":[],"responses":{"200":{"description":"HTML"}}}}},"x-vividodds":{"price_format":"decimal","sharp_hub":"https://www.edgebets.io","adapters":["sharp-hub","italy-pack","canada-pack","sharpbook (stub)"],"italy_dir_env":"VIVIDODDS_ITALY_DIR","global_dir_env":"VIVIDODDS_GLOBAL_DIR","global_region_env":"ENABLE_REGION_GLOBAL","global_upload":"/var/data/global/{book}-slate.jsonl","france_dir_env":"VIVIDODDS_FRANCE_DIR","france_region_env":"ENABLE_REGION_FR","france_books_env":"FRANCE_BOOKS","france_upload":"/var/data/france/{book}-slate.jsonl","spain_dir_env":"VIVIDODDS_SPAIN_DIR","spain_region_env":"ENABLE_REGION_ES","spain_books_env":"SPAIN_BOOKS","spain_upload":"/var/data/spain/{book}-slate.jsonl","canada_region_dir_env":"VIVIDODDS_CANADA_REGION_DIR","canada_region_env":"ENABLE_REGION_CA","canada_region_books_env":"CANADA_REGION_BOOKS","canada_region_upload":"/var/data/canada-region/{book}_ca-slate.jsonl","canada_dir_env":"VIVIDODDS_CANADA_DIR","pin_slate_env":"VIVIDODDS_PIN_SLATE","ps3838_slate_env":"VIVIDODDS_PS3838_SLATE","circa_slate_env":"VIVIDODDS_CIRCA_SLATE","novig_slate_env":"VIVIDODDS_NOVIG_SLATE","betonline_slate_env":"VIVIDODDS_BETONLINE_SLATE","sharpness_json_env":"VIVIDODDS_SHARPNESS_JSON","soft_overlay_env":"VIVIDODDS_SOFT_OVERLAY_PATH","soft_max_age_env":"VIVIDODDS_SOFT_MAX_AGE_SEC","soft_max_age_default_sec":14400,"soft_max_age_hard_max_sec":21600,"true_modes":["pin","composite"],"protected_paths":["/v1/sports","/v1/books","/v1/fixtures","/v1/odds","/v1/odds/stream","/v1/true","/v1/value","/v1/board","/v1/arbs","/v1/arbs/books","/v1/board/opportunities","/v1/board/mapping-health","/v1/soft-odds/observe","/v1/soft-odds/clear"],"auth":"header only — x-api-key or Authorization Bearer; no query-string keys"}}