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, once authenticated; without a key every path is a 401) · 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 (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, 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 was 95% (153 of 161 WNBA player-prop rows, measured July 16, 2026). Use market as 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. 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 (deduplicated server-side since Sept 2, 2026: a pick graded twice for the same book counts once, most recent grade wins); graded is book-level and distinct_picks is the unique-pick count. Dedup on pick_key before computing n / win-rate / ROI, and expect your pick-level figure to differ from summary.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-client

All 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

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, 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/sharppicks:readSharp-money signals (all leagues) + graded record. Params: league, days, grade, result, limit, cursor.
GET /api/v1/statuspicks:readMomentum 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/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). Every grid carries generated_at and a stale flag. 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 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-marketspicks:readKalshi 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/divergencepicks:readKalshi 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/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.jsonpublicTyped 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:read

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

Params
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)
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

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

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

Params
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)
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 (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=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 (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=3
GET /api/v1/projectionspicks:read

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

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

model_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%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. 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.

Params
sport, requiredmin_edge, EV %, default 3include_thin, include 1-2 book fairsinclude, raw_odds → venue + pricelimit, 1 to 1000 (default 250)
Returns

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

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

Params
sport, mlb·nfl·ncaaf·tennis·soccer·golf·nba·wnba·nhl·mmavenue, kalshi·polymarketmin_oi, min open interest (Kalshi)limit
Returns

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

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

Params
sport, mlb·nfl·ncaaf (default all)venue, kalshi·polymarket (default both)min_gap, min |divergence| in pplimit
Returns

venue, 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=3
GET /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.

Params
sport, requiredmarket, required, from /odds/marketsinclude, raw_odds (needs scope)include_stale, on /odds/markets: list grids older than 12h (hidden by default)
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": 30.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, 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 as null.
  • 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, already deduplicated across engine tags. Dedup on pick_key (stable across books) before computing sample size, win rate, or ROI; the summary.graded count is book-level and summary.distinct_picks is 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-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); a limit above 1000 clamps to 1000. Unknown /api/v1 paths return a JSON 404 once authenticated.
  • Conditional requests: every data 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. 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