Turtle +EV Data API
A REST API for point-in-time correct sports edge data, no-vig fair value, expected value, calibrated probability, and graded outcomes, with full per-pick provenance so you can reconcile every number field by field. Built for quant and product integrators, not another raw odds screen.
Authentication
Every data endpoint is deny-by-default. Pass your key as either header, no key (or a revoked/expired one) returns 401:
Authorization: Bearer <your_key>
# or
X-API-Key: <your_key>Keys are issued per client with scoped permissions, a per-minute rate limit, and a daily quota. Request a key →
Errors & validation
Every parameter is validated. Nothing is silently ignored, a typo must never look like an empty result:
400 unknown_parameter, a query param this endpoint does not accept (the response lists the ones it does).?cursor=on/picksis a 400, because/pickshas no cursor.400 invalid_parameter, a known param with a bad value: an unknownsport, a non-integerlimit,min_proboutside 0–1, a malformedas_of, a non-numericcursor.401missing/invalid/revoked key ·403 insufficient_scope(e.g.?include=raw_oddswithoutraw_odds:read) ·404 not_found(JSON, never HTML, once authenticated; without a key every path is a 401) ·429withRetry-After.
A known sport with no rows returns 200 with an empty array, not a 400, ?sport=nfl in July is a valid question with a legitimately empty answer. Only an unrecognised sport is a 400. Note that ?league= on the discovery endpoints validates against the wider ingest store (about 40 active leagues, 42 on Sept 2, 2026, including ones we do not model), so /events?league=cricket is valid while /picks?sport=cricket is a 400.
Data responses carry an ETag; send If-None-Match to get a 304 when the board has not changed.
Reading the data honestly
The things most likely to make you draw a wrong conclusion, stated plainly. All of this is machine-readable in /schema under field_readiness and field_units.
- Scales are not uniform across a row. On
/sharp,edgeis a percent whileexpected_valueis a fraction, assuming both are percent is a 100× error.divergence_ppis percentage points. Every numeric field’s unit is declared infield_units. - Never average identity coverage across the board. Segment by sport × row type using the
player_name_is_matchupflag. Game-level rows (tennisTotal Gamesis a market on the match) have no single player, socanonical_player_id/teamare null by definition. Worked example: tenniscanonical_player_idreads 12% in aggregate and is 100% on individual-player rows , the board was 194 game-level + 26 player rows. Likewiseteamis always null for tennis: it is an individual sport. - line_movement is null on game-level markets, by construction. Our tracked-line source is keyed (player, stat_type), so Moneyline / Total / Spread can never carry it. A default
/sharp?days=2is dominated by those and can legitimately return zero populated rows. Filter to a market that can carry it,/sharp?league=wnba, and it was 95% (153 of 161 WNBA player-prop rows, measured July 16, 2026). Usemarketas the tell: a game-level market (Moneyline, Total, Spread) can never carry line movement. It is also forward-only: the source retains 3 days, so older rows are permanently null.FLATis the explicit no-movement value, null never means “no movement”. - /picks is pre-game, /results is post-game, and they resolve identity from different sources, do not read the
/resultspopulation rates as/picksrates. - We serve null rather than a guess. A soccer player’s team is accepted only if it is one of the two sides named in that row’s own
matchup; uncorroborated candidates are dropped. We do not serve tennisbest_of/format at all, because our only source for it is stale and deriving it from a tournament name would be a heuristic. - Dedup before you count.
/resultsis one row per pick × book (deduplicated server-side since Sept 2, 2026: a pick graded twice for the same book counts once, most recent grade wins);gradedis book-level anddistinct_picksis the unique-pick count. Dedup onpick_keybefore computing n / win-rate / ROI, and expect your pick-level figure to differ fromsummary.roi_pct, which is book-level (units divided by wins + losses + pushes).
Build with AI
The surface is described by a typed OpenAPI 3.0.3 spec covering every endpoint (the data endpoints are fully typed; the five discovery endpoints are described in prose). Point an AI coding assistant (Claude, ChatGPT, Cursor) or an API client (Postman, Insomnia) at the URL below and it can generate a working, typed client for you:
# Give these three URLs to your AI agent, all public, no key needed to read them:
https://turtleevlabs.com/api/v1/openapi.json # typed spec: every endpoint, param, response
https://turtleevlabs.com/api/v1/schema # live field contract: units, population, caveats
https://turtleevlabs.com/llms.txt # what Turtle is, in plain text
# Or generate a typed client in any language:
npx @openapitools/openapi-generator-cli generate \
-i https://turtleevlabs.com/api/v1/openapi.json \
-g python -o ./turtle-clientAll three are public. Contract tests in the repository check the spec's enums, defaults and endpoint list against the live serializers. Fetch /schema before you model on any number , it carries per-field units, measured population rates, and the caveats above in a form an agent can read, so your assistant does not have to guess whether a field is a percent or a fraction, or whether a null means “zero” or “not applicable”.
A prompt that works well: “Read https://turtleevlabs.com/api/v1/openapi.json and https://turtleevlabs.com/api/v1/schema. Using field_units and field_readiness, write a client that pulls graded results, dedups on pick_key, and computes realized ROI , respecting which fields are percents vs fractions.”
Endpoints
| Endpoint | Scope | Description |
|---|---|---|
| GET /api/v1/schema | public | Self-describing contract: fields, units, per-endpoint supported sports, live stat vocabulary, version policy. |
| GET /api/v1/health | public | Liveness check. |
| GET /api/v1/picks | picks:read | Current +EV board, one row per served pick. Params: sport, limit. |
| GET /api/v1/history | picks:read | As-served snapshots. Params: as_of (ISO 8601), sport, limit, cursor. |
| GET /api/v1/results | picks:read | Graded outcomes, one row per pick x book (deduplicated), with a record summary over the window. Params: sport, player, market, days, result, limit, cursor, include=expansion_markets. |
| GET /api/v1/sharp | picks:read | Sharp-money signals (all leagues) + graded record. Params: league, days, grade, result, limit, cursor. |
| GET /api/v1/status | picks:read | Momentum status per sport x stat_type x side x book, precomputed every 15 min. Values: HOT, HOT_CAUTION, STEADY, MONITORING, COOLING, COLD, TAG_ONLY, DEACTIVATED, NO_EDGE, BLOCKED. Params: sport, market, side, book, status, limit. |
| GET /api/v1/odds/markets | picks:read | Catalog of no-vig odds grids. Params: sport. |
| GET /api/v1/odds | picks:read* | No-vig fair value for a market; best-book odds via ?include=raw_odds (raw_odds:read). Every grid carries generated_at and a stale flag. Params: sport, market. |
| GET /api/v1/projections | picks:read | Current-slate model projections across ALL modeled markets, raw model output, not vetted bets. Params: sport, market, passed_filters, min_prob, limit. |
| GET /api/v1/edges | picks:read* | Opportunity feed: best price vs no-vig fair on grids regenerated in the last 12 hours, ranked by edge. Venue + price via ?include=raw_odds. Params: sport, min_edge, include_thin, limit. |
| GET /api/v1/prediction-markets | picks:read | Kalshi order-book prices (bid, ask, mid, volume, open interest) and Polymarket game moneylines for sports; settled games excluded. Params: sport, venue, min_oi, limit. |
| GET /api/v1/divergence | picks:read | Kalshi and Polymarket vs the no-vig sportsbook consensus on the same game moneyline (mlb, nfl, ncaaf), ranked by gap, with book count and honesty flags. Params: sport, venue, min_gap, limit. |
| GET /api/v1/books | picks:read | Every book we ingest directly + a live flag (tick in the last 30 min). |
| GET /api/v1/leagues | picks:read | Active leagues with per-league book + selection counts. |
| GET /api/v1/events | picks:read | Upcoming events for a league + per-book selection counts. Params: league (required). |
| GET /api/v1/markets | picks:read | Markets for a league grouped by game/player + book_count. Params: league (required). |
| GET /api/v1/filters | picks:read | Discovery enumerations: sports, market_types, sides, books. |
| GET /api/v1/openapi.json | public | Typed OpenAPI 3.0.3 spec for AI / codegen clients (data endpoints fully typed; the five discovery endpoints are described in prose). |
Endpoint reference
Every endpoint shares the envelope below; detail endpoints add a summary object over your filter.
GET /api/v1/pickspicks:readThe current +EV board, the picks we publish and stand behind.
sport, any accepted sport code (optional). Live modeled sports on Sept 2, 2026: mlb, soccer, tennis, wnba; other accepted codes return 200 with an empty arraylimit, 1 to 1000 (default 250)pick_key (joins /results), prediction_id (numeric board row id), player_name, canonical_player_id, team, opponent, canonical_fixture_id, matchup_canonical, matchup_sides, stat_type, line, pick, book, raw_prob, calibrated_prob, probability, cap_applied, ev, fair_value, payout, payout_over, payout_under (both sides; null = side not priced), game_date, game_datetime
https://turtleevlabs.com/api/v1/picks?sport=mlb&limit=25GET /api/v1/historypicks:readPoint-in-time snapshots, the board exactly as it stood at any past instant.
as_of, ISO 8601 instant (optional)sport, optionallimitcursor, opaqueone row per logical pick x book: pick_key, prediction_id, sport, player_name, stat_type, line, pick, book, prob_version, model_id, raw_prob, calibrated_prob, probability, payout, payout_over, payout_under, payout_multiplier, ev, computed_at, game_date, game_datetime, plus as_served_at, capture_reason (null = periodic snapshot, UPDATE, DELETE) and locked_published (lock-time publication verdict: false rows never appear in /results; null = not locked yet). Identity fields, fair_value and cap_applied are not on /history; join to /picks or /results on pick_key. Window: the last 48 hours, or the 48 hours ending at as_of
https://turtleevlabs.com/api/v1/history?sport=tennis&as_of=2026-07-09T14:30:00ZGET /api/v1/resultspicks:readGraded outcomes, one row per pick x book (deduplicated server-side), with a factual record summary over the window. Graded in the 30 days to Sept 2, 2026: 4,795 distinct picks across MLB, WNBA, soccer and tennis. NBA and NHL graded history returns with their seasons; NFL grading starts when the NFL model goes live.
sport, optional; a sport with no rows in the window returns 200 emptyplayer, exact player namemarket, stat typedays, 1 to 365 (default 30)result, WIN·LOSS·VOID·PUSHlimitcursor, keysetinclude, expansion_markets appends the HR 1+ / NRFI / YRFI ledger (page 1 only, different row shape, excluded from summary)id, pick_key (dedup across books; joins /picks and /history), model_tag (producing engine tag; renamed from prediction_id 2026-07-19), canonical_player_id, team, opponent, canonical_fixture_id, matchup_canonical, result, units, payout, beat_close, clv{ open_line, close_line, line_delta, favorable, basis, n_books } or null (null whenever the tracked line is a different line family), probability (final, on every graded pick), ev, actual (read actual_provenance: official_stat or synthetic_settlement), settlement_basis, grade_source, grade_disposition · summary{ wins, losses, pushes, voids, units, roi_pct = units / (wins + losses + pushes), graded (book-level, deduplicated), distinct_picks }
https://turtleevlabs.com/api/v1/results?sport=mlb&days=90GET /api/v1/sharppicks:readSharp-money signals across every league + graded record. summary.roi_pct is the true realized figure, never a promise. Note the unit split: `edge` is a PERCENT, `expected_value` is a FRACTION (see Field conventions).
league, optional (alias: sport)days, 1–365 (default 30)grade, T·M·Sresult, WIN·LOSS·VOID·PUSHlimitcursor, keysetid, event, player (player-prop signals only, null on game-level), side, conviction, whale_volume (USD), edge (%), expected_value (fraction), fair_odds, best_odds, kelly_fraction (declared, null since the 2026-07-29 in-house cutover), line_movement_direction (WITH·AGAINST·FLAT), line_movement_pct, result, units, grade, model_version, game_datetime
https://turtleevlabs.com/api/v1/sharp?league=wnba&days=3GET /api/v1/projectionspicks:readRaw model projections across ALL modeled markets, data, not vetted bets. Far broader than /picks (14 MLB markets and 22 overall on Sept 2, 2026). Hard cap 1,000 rows per call and no cursor yet: filter by sport and market. model_prob can reach 1.0 on capped or degenerate markets; fair_decimal and ev are null there.
sportmarket, stat type, e.g. Total Basespassed_filters, true·falsemin_prob, 0–1limitmodel_prob, saturated (true when model_prob is within 1% of 0 or 1: a capped or degenerate market, not a display-grade probability), fair_decimal (null when saturated), line, pick, passed_filters (cleared the shadow gate, NOT a published-pick guarantee)
https://turtleevlabs.com/api/v1/projections?sport=mlb&market=Total%20BasesGET /api/v1/edgespicks:read (raw_odds:read for venue + price)Opportunity feed: markets where the best available price beats no-vig fair, ranked by edge (line-shopping vs consensus, not a model claim). Coherent multi-book fairs only by default. Only grids regenerated in the last 12 hours are searched and finished games are dropped; summary.stale tells you when a sport has no fresh grid.
sport, requiredmin_edge, EV %, default 3include_thin, include 1-2 book fairsinclude, raw_odds → venue + pricelimit, 1 to 1000 (default 250)market, player, line, side, edge_pct, fair_prob, coherent_fair, game_datetime, grid_generated_at · venue + price_decimal only with raw_odds:read · summary{ grid_generated_at, stale }
https://turtleevlabs.com/api/v1/edges?sport=mlb&min_edge=3GET /api/v1/prediction-marketspicks:readPrediction-market prices for sports. Kalshi rows carry the order book (implied_prob = bid-ask mid when quoted, volume, open interest). Polymarket rows are game moneylines from the international venue as ingested by our odds fleet (implied_prob = 1/price, no order book). Settled games are excluded. Hard cap 1,000 rows per call: filter by sport and venue.
sport, mlb·nfl·ncaaf·tennis·soccer·golf·nba·wnba·nhl·mmavenue, kalshi·polymarketmin_oi, min open interest (Kalshi)limitvenue, market_id (Kalshi ticker; null for Polymarket game rows), title, sport, market_type, implied_prob (YES 0-1), yes_bid, yes_ask, spread, volume_24h, open_interest, game_datetime, plus team, opponent, home, quote_at on Polymarket game rows
https://turtleevlabs.com/api/v1/prediction-markets?sport=mlb&venue=kalshi&min_oi=1000GET /api/v1/divergencepicks:readWhere Kalshi or Polymarket disagrees with the no-vig SPORTSBOOK consensus on the same game moneyline. Exchanges and prediction markets are excluded from the consensus they are compared against. Kalshi rows key to a book game by team pair and kickoff; Polymarket rows come from the same book feed, so their identity is exact. Sports: mlb, nfl, ncaaf (Kalshi college markets are not yet keyed; CFB arrives through the Polymarket lens). A disagreement signal, not a bet recommendation.
sport, mlb·nfl·ncaaf (default all)venue, kalshi·polymarket (default both)min_gap, min |divergence| in pplimitvenue, sport, team, opponent, team_name, opponent_name, home, pm_prob, kalshi_prob (venue=kalshi only), book_fair_prob, n_books, divergence_pp (pm minus book, percentage points), prices_higher_on, quote_age_s, flags (thin_consensus, stale_quote, wide_spread), game_datetime, title
https://turtleevlabs.com/api/v1/divergence?sport=nfl&venue=kalshi&min_gap=3GET /api/v1/odds · /api/v1/odds/marketspicks:read (raw_odds:read for book prices)No-vig fair value across dozens of books, with coherence flags so you can tell a thin fair from a solid one. Best-book odds require raw_odds:read + ?include=raw_odds. Discover markets via /odds/markets. Every grid carries generated_at and stale (older than 12 hours); on Sept 2, 2026 the MLB, WNBA, soccer, tennis, NFL, NBA and NHL grids dated from Aug 21 while NCAAF regenerated every few minutes. Skip a stale grid.
sport, requiredmarket, required, from /odds/marketsinclude, raw_odds (needs scope)include_stale, on /odds/markets: list grids older than 12h (hidden by default)fair{ over_prob, under_prob, n_books, trustworthy, coherence_ok }; best_over / best_under only with raw_odds:read
https://turtleevlabs.com/api/v1/odds?sport=mlb&market=Total%20BasesResponse envelope
Every response is wrapped in a stable envelope. Each pick carries full provenance , you can trace raw_prob → calibrated_prob → probability (final, capped, graded) alongside ev, fair_value, and cap_applied.
{
"schema_version": 1,
"generated_at": "2026-07-10T19:47:07Z",
"count": 25,
"cursor": null,
"data": [
{
"sport": "mlb", "player_name": "J. Crawford",
"canonical_player_id": "crawfjp01", "team": "SEA", "opponent": "LAA",
"canonical_fixture_id": "746215",
"stat_type": "Total Bases", "line": 1.5, "pick": "UNDER", "book": "...",
"raw_prob": 0.70, "calibrated_prob": 0.70, "probability": 0.70,
"cap_applied": false, "ev": 30.2, "fair_value": -233,
"payout": 1.86, "computed_at": "...", "game_datetime": "..."
}
]
}Field conventions
- Identity: most rows carry canonical
canonical_player_id,team,opponent, andcanonical_fixture_id, stamped from maintained per-sport sources so you can join straight onto your data (coverage varies by sport; see/schema.field_readiness). Null means unresolved, not absent. For game-level marketsplayer_nameis a matchup, checkplayer_name_is_matchupand usematchup_sides.stat_typefollows a documented per-sport vocabulary in/schema. - CLV: on
/results,clvgives honest closing-line value from real tracked movement,open_line(first seen),close_line(last before start),line_delta, andfavorable. It isnullwhen no tracked data exists (never an empty object).basislabels whose line it is:bookmeans the row’s own book;consensusmeans the median across then_bookstracked books for that player/stat/date, served only when the row’s book was not tracked for that stat. Filter onbasisif you want book-exact only. It is never derived from a synthetic opening line, and it is served only when the pick line sits inside the tracked open/close band: a tracked line from another line family (alternate lines) is served asnull. - Joining picks to results:
pick_keyis served on/picks,/history, and/resultsand is identical for the same logical pick, so the board-to-graded join is exact (addbookfor the row-level match). The board is pre-lock: publication crystallizes when a pick locks near game time, so filter/historyrows tolocked_published != falsebefore reconciling against/results. If you build your own join instead, includegame_date: without it, consecutive-day picks at the same line collide and read as cross-book grading inconsistency (we measured 98 colliding groups without the date vs 1 real divergence with it, over 7 days). - Units: numeric encodings are declared per field in
/schema.field_units, American (fair_value,best_odds), decimal (payout,fair_decimal), probability 0–1 (probability,win_probability), percent (ev,roi_pct). - Dedup:
/resultsreturns one row per pick × book, already deduplicated across engine tags. Dedup onpick_key(stable across books) before computing sample size, win rate, or ROI; thesummary.gradedcount is book-level andsummary.distinct_picksis the unique-pick count. HR 1+, NRFI and YRFI grades are a separate ledger, opt-in via?include=expansion_markets.
Rate limits & scopes
- Your key’s limits are authoritative in the response headers:
X-RateLimit-Limitis the per-minute cap, withX-RateLimit-RemainingandX-RateLimit-Reset(unix epoch). A separate daily quota also applies. A breach returns429withRetry-After. - Invalid query params return
400 invalid_parameter(unparseable or out-of-rangelimit,days,as_of,cursor,min_prob, or enum filters); alimitabove 1000 clamps to 1000. Unknown/api/v1paths return a JSON404once authenticated. - Conditional requests: every data response carries an
ETag; send it back asIf-None-Matchand an unchanged board returns304 Not Modified, poll frequently without transferring bytes. - An OpenAPI 3.0 spec is published for typed-client codegen.
- picks:read, the live edge board and point-in-time history (derived fields only).
- raw_odds:read, approved raw per-book odds/props from covered sources (explicit client entitlement required). Requesting
?include=raw_oddswithout this scope returns403.
Versioning
/v1 is GA. Changes within a major version are additive only , we never remove or re-type a field in place. One documented exception: on 2026-07-19,/results.prediction_id was renamed to model_tag. That field held the producing engine’s tag, never an id, and its name collided with /picks' numeric prediction_id, so any join between them produced garbage. We treated a field that could not be used as named as a defect and renamed it while the integrator base was small, rather than preserving a trap. Breaking changes ship as /v2 with at least 90 days of deprecation notice.
Coverage & honesty
The vetted /picks board is a narrow, real edge. Live modeled sports on Sept 2, 2026: MLB, soccer, tennis, WNBA, UNDER-focused, from a few dozen picks in the morning to a few hundred at slate peak (summary.pipeline_stale_seconds tells you which you are looking at). NFL model picks are not live yet; NBA and NHL return with their seasons. Graded results in the 30 days to Sept 2, 2026: 4,795 distinct picks across MLB, WNBA, soccer and tennis. Sharp-money signals: 98,730 graded since April 6, 2026 across nine leagues, five active in the last 30 days. /projections exposes every market we model. We are downstream of official settlement, make no official-league-rights claim, and disclose recency on every response. Where a number is a track record we say so; where it's a raw projection we label it. If you need cheap multi-sport raw odds, we'll tell you we aren't the cheapest option.
Examples
cURL
# List the current MLB edge board (one row per served pick)
curl -H "Authorization: Bearer $TEV_API_KEY" \
"https://turtleevlabs.com/api/v1/picks?sport=mlb&limit=25"
# Point-in-time history: the board as it stood at a past instant
curl -H "X-API-Key: $TEV_API_KEY" \
"https://turtleevlabs.com/api/v1/history?sport=tennis&as_of=2026-07-09T14:30:00Z&limit=100"Python
import os, requests
BASE = "https://turtleevlabs.com/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['TEV_API_KEY']}"}
# Page through graded results with the keyset cursor; read the calibration + CLV fields
cursor, rows = None, []
while True:
r = requests.get(f"{BASE}/results", headers=HEADERS,
params={"sport": "mlb", "days": 7, "limit": 200, "cursor": cursor},
timeout=15)
r.raise_for_status()
body = r.json() # {schema_version, count, cursor, data:[...], summary:{...}}
rows += body["data"]
cursor = body["cursor"] # null on the last page
if not cursor:
break
for p in rows[:5]:
clv = p.get("clv") or {}
print(p["player_name"], p["canonical_player_id"], p["result"],
"units", p["units"], "model_prob", p["probability"],
"clv", clv.get("open_line"), "->", clv.get("close_line"))JavaScript
const BASE = "https://turtleevlabs.com/api/v1";
const headers = { Authorization: `Bearer ${process.env.TEV_API_KEY}` };
// /picks is a snapshot (no cursor; poll it with ETag / If-None-Match).
// Keyset cursors live on /results, /history and /sharp. Walk graded results:
async function resultsPage(cursor?: string) {
const url = new URL(`${BASE}/results`);
url.searchParams.set("sport", "mlb");
url.searchParams.set("days", "30");
url.searchParams.set("limit", "200");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, { headers });
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
return res.json(); // {schema_version, count, cursor, data, summary}
}
let cursor: string | undefined;
do {
const page = await resultsPage(cursor);
for (const row of page.data) console.log(row.pick_key, row.book, row.result, row.units);
cursor = page.cursor ?? undefined; // null on the last page
} while (cursor);Request access
Keys are issued to vetted partners. Tell us your use case and expected volume and we'll get you a key and the right plan.
Email blake@turtle-ev.com