Turtle +EVTurtle +EV

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 /picks is a 400, because /picks has no cursor.
  • 400 invalid_parameter, a known param with a bad value: an unknown sport, a non-integer limit, min_prob outside 0–1, a malformed as_of, a non-numeric cursor.
  • 401 missing/invalid/revoked key · 403 insufficient_scope (e.g. ?include=raw_odds without raw_odds:read) · 404 not_found (JSON, never HTML) · 429 with Retry-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, edge is a percent while expected_value is a fraction, assuming both are percent is a 100× error. divergence_pp is percentage points. Every numeric field’s unit is declared in field_units.
  • Never average identity coverage across the board. Segment by sport × row type using the player_name_is_matchup flag. Game-level rows (tennis Total Games is a market on the match) have no single player, so canonical_player_id/team are null by definition. Worked example: tennis canonical_player_id reads 12% in aggregate and is 100% on individual-player rows , the board was 194 game-level + 26 player rows. Likewise team is 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=2 is 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%. Use player as 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. FLAT is 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 /results population rates as /picks rates.
  • 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 tennis best_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. /results is one row per pick × book; graded is book-level and distinct_picks is the unique-pick count. Dedup on pick_key before 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-client

All 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

EndpointScopeDescription
GET /api/v1/schemapublicSelf-describing contract: fields, units, per-endpoint supported sports, live stat vocabulary, version policy.
GET /api/v1/healthpublicLiveness check.
GET /api/v1/pickspicks:readCurrent +EV board, one row per served pick. Params: sport, limit.
GET /api/v1/historypicks:readAs-served snapshots. Params: as_of (ISO 8601), sport, limit, cursor.
GET /api/v1/resultspicks:readGraded 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/sharppicks:readSharp-money signals (all leagues) + graded record. Params: league, days, grade, result, limit, cursor.
GET /api/v1/odds/marketspicks:readCatalog of no-vig odds grids. Params: sport.
GET /api/v1/oddspicks:read*No-vig fair value for a market; best-book odds via ?include=raw_odds (raw_odds:read). Params: sport, market.
GET /api/v1/projectionspicks:readCurrent-slate model projections across ALL modeled markets, raw model output, not vetted bets. Params: sport, market, passed_filters, min_prob, limit.
GET /api/v1/edgespicks: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-marketspicks:readLive Kalshi + Polymarket prediction-market implied probability + liquidity for sports (partner-licensed). Params: sport, venue, min_oi, limit.
GET /api/v1/divergencepicks:readCross-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/bookspicks:readEvery book we ingest directly + a live flag (tick in the last 30 min).
GET /api/v1/leaguespicks:readActive leagues with per-league book + selection counts.
GET /api/v1/eventspicks:readUpcoming events for a league + per-book selection counts. Params: league (required).
GET /api/v1/marketspicks:readMarkets for a league grouped by game/player + book_count. Params: league (required).
GET /api/v1/filterspicks:readDiscovery enumerations: sports, market_types, sides, books.
GET /api/v1/openapi.jsonpublicComplete 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:read

The current +EV board, the picks we publish and stand behind.

Params
sport, mlb · soccer · tennis · wnba (optional)limit, 1–1000 (default 250)
Returns

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=25
GET /api/v1/historypicks:read

Point-in-time snapshots, the board exactly as it stood at any past instant.

Params
as_of, ISO 8601 instant (optional)sport, optionallimitcursor, opaque
Returns

the 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:00Z
GET /api/v1/resultspicks:read

Graded outcomes across all 9 sports, with a factual record summary over the window.

Params
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, keyset
Returns

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, 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=90
GET /api/v1/sharppicks:read

Sharp-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).

Params
league, optional (alias: sport)days, 1–365 (default 30)grade, T·M·Sresult, WIN·LOSS·VOID·PUSHlimitcursor, keyset
Returns

id, 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=3
GET /api/v1/projectionspicks:read

Raw model projections across ALL modeled markets, data, not vetted bets. Far broader than /picks (e.g. 19 MLB markets).

Params
sportmarket, stat type, e.g. Total Basespassed_filters, true·falsemin_prob, 0–1limit
Returns

model_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%20Bases
GET /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.

Params
sport, requiredmin_edge, EV %, default 3include_thin, include 1-2 book fairsinclude, raw_odds → venue + price
Returns

market, 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=3
GET /api/v1/prediction-marketspicks:read

Live 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.

Params
sport, mlb·golf·tennis·nfl·nba·wnba·nhl·soccer·mmavenue, kalshi·polymarketmin_oi, min open interestlimit
Returns

venue, 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=1000
GET /api/v1/divergencepicks:read

Where 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.

Params
sport, mlb (only market in v1)min_gap, min |divergence| in pplimit
Returns

team, 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=5
GET /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.

Params
sport, requiredmarket, required, from /odds/marketsinclude, raw_odds (needs scope)
Returns

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%20Bases

Response 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, and canonical_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 markets player_name is a matchup, check player_name_is_matchup and use matchup_sides. stat_type follows a documented per-sport vocabulary in /schema.
  • CLV: on /results, clv gives honest closing-line value from real tracked movement, open_line (first seen), close_line (last before start), line_delta, and favorable. It is null when no tracked data exists (never an empty object). basis labels whose line it is: book means the row's own book; consensus means the median across the n_books tracked books for that player/stat/date, served only when the row's book was not tracked for that stat. Filter on basis if you want book-exact only. It is never derived from a synthetic opening line.
  • Joining picks to results: pick_key is served on /picks, /history, and /results and is identical for the same logical pick, so the board-to-graded join is exact (add book for the row-level match). The board is pre-lock: publication crystallizes when a pick locks near game time, so filter /history rows to locked_published != false before reconciling against /results. If you build your own join instead, include game_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: /results returns one row per pick × book. Dedup on pick_key (stable across books) before computing sample size, win rate, or ROI, the summary.graded count is book-level; summary.distinct_picks is the unique-pick count.

Rate limits & scopes

  • Your key's limits are authoritative in the response headers: X-RateLimit-Limit is the per-minute cap, with X-RateLimit-Remaining and X-RateLimit-Reset (unix epoch). A separate daily quota also applies. A breach returns 429 with Retry-After.
  • Invalid query params return 400 invalid_parameter (unparseable or out-of-range limit, days, as_of, cursor, min_prob, or enum filters). Unknown /api/v1 paths return a JSON 404.
  • Conditional requests: every response carries an ETag; send it back as If-None-Match and an unchanged board returns 304 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_odds without this scope returns 403.

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