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) ·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 (~37 leagues, including ones we do not model), so /events?league=cricket is valid while /picks?sport=cricket is a 400.
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 is ~96%. Useplayeras the tell: null player ⇒ game-level ⇒ no 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;gradedis book-level anddistinct_picksis the unique-pick count. Dedup onpick_keybefore computing n / win-rate / ROI.
Build with AI
The entire surface is described by a complete, typed OpenAPI 3.0.3 spec , every endpoint, parameter, response schema, and field description in one document. 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 and stay in lock-step with the live API. 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 for every governed engine, era-scoped back to 2026-03-25 + record summary. Params: sport, player, market, days, result, limit, cursor. |
| GET /api/v1/sharp | picks:read | Sharp-money signals (all leagues) + graded record. Params: league, days, grade, result, limit, cursor. |
| 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). 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, ranked by edge. Venue + price via ?include=raw_odds. Params: sport, min_edge, include_thin. |
| GET /api/v1/prediction-markets | picks:read | Live Kalshi + Polymarket prediction-market implied probability + liquidity for sports (partner-licensed). Params: sport, venue, min_oi, limit. |
| GET /api/v1/divergence | picks:read | Cross-venue divergence: prediction market vs no-vig sportsbook consensus on the same market (v1: MLB moneyline), ranked by gap. Params: sport, 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 | Complete typed OpenAPI 3.0.3 spec for AI / codegen clients. |
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, mlb · soccer · tennis · wnba (optional)limit, 1–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, opaquethe pick fields as-served (incl. pick_key, payout_over/payout_under, game_date), plus as_served_at, capture_reason, and locked_published (lock-time publication verdict: false rows never appear in /results; null = not locked yet)
https://turtleevlabs.com/api/v1/history?sport=tennis&as_of=2026-07-09T14:30:00ZGET /api/v1/resultspicks:readGraded outcomes across all 9 sports, with a factual record summary over the window.
sport, incl. nba, nhl history (optional)player, exact player namemarket, stat typedays, 1–365 (default 30); attributable history reaches 2026-03-25result, WIN·LOSS·VOID·PUSHlimitcursor, keysetid, 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, probability (final, on every graded pick), ev · summary{ wins, losses, units, roi_pct, graded (book-level), 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 (0–1), 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 (e.g. 19 MLB markets).
sportmarket, stat type, e.g. Total Basespassed_filters, true·falsemin_prob, 0–1limitmodel_prob, fair_decimal, 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.
sport, requiredmin_edge, EV %, default 3include_thin, include 1-2 book fairsinclude, raw_odds → venue + pricemarket, player, line, side, edge_pct, fair_prob, coherent_fair · venue + price_decimal only with raw_odds:read
https://turtleevlabs.com/api/v1/edges?sport=mlb&min_edge=3GET /api/v1/prediction-marketspicks:readLive prediction-market prices from Kalshi + Polymarket for sports, the real-money crowd’s implied probability with liquidity, a cross-venue lens alongside our sportsbook fair value. Partner-licensed redistribution. Polymarket sports depth is thinner (wide spreads), filter with min_oi / spread.
sport, mlb·golf·tennis·nfl·nba·wnba·nhl·soccer·mmavenue, kalshi·polymarketmin_oi, min open interestlimitvenue, market_id, title, sport, market_type, implied_prob (YES 0-1), yes_bid, yes_ask, spread, volume_24h (Kalshi), open_interest, game_datetime
https://turtleevlabs.com/api/v1/prediction-markets?sport=mlb&venue=kalshi&min_oi=1000GET /api/v1/divergencepicks:readWhere the prediction market disagrees with the no-vig sportsbook consensus on the same market, v1 covers MLB moneyline (the cleanest match: no line to align), matched by team + exact game date, ranked by the size of the gap. A disagreement signal, not a bet recommendation.
sport, mlb (only market in v1)min_gap, min |divergence| in pplimitteam, opponent, home, kalshi_prob, book_fair_prob, divergence_pp (kalshi − book, percentage points), prices_higher_on, game_datetime
https://turtleevlabs.com/api/v1/divergence?sport=mlb&min_gap=5GET /api/v1/odds · /api/v1/odds/marketspicks:read (raw_odds:read for book prices)No-vig fair value across 30+ 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.
sport, requiredmarket, required, from /odds/marketsinclude, raw_odds (needs scope)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": 58.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. - 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. Dedup onpick_key(stable across books) before computing sample size, win rate, or ROI, thesummary.gradedcount is book-level;summary.distinct_picksis the unique-pick count.
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). Unknown/api/v1paths return a JSON404. - Conditional requests: every 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, MLB, soccer, tennis, WNBA, UNDER-focused, a few hundred live picks at a time. The rest of the surface is broader: /results and /sharp span all 9 sports / every league (NBA, NHL, golf…), and /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}` };
// Fetch the live board; follow `cursor` for the next page. Rows carry canonical IDs.
async function board(sport: string) {
const url = new URL(`${BASE}/picks`);
url.searchParams.set("sport", sport);
url.searchParams.set("limit", "100");
const res = await fetch(url, { headers });
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const { data, cursor } = await res.json();
data.forEach((p: any) =>
console.log(p.player_name, p.canonical_player_id, p.team, p.pick, "EV", p.ev));
return cursor; // pass back as ?cursor= to page
}
await board("soccer");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