Documentation

API reference

Everything for the ScoreTape v1 API — authentication, every endpoint, the response contract, the verified market-to-game join, and the injury webhook channel. Base URL https://api.scoretape.com/v1.

Contents

Introduction

ScoreTape is sports prediction-market data with the join already done. We record sports markets on Polymarket and Kalshi — moneylines, spreads, totals, props, with settlement — and entity-resolve every market to its real-world game: the final score, the multi-sportsbook odds, and the injury report as it stood before the game.

The mapping is verified continuously: settled market winners agree with the real-world result in 99.96% of checked moneylines. Live order books for sports markets and a real-time injury webhook feed (~60-second detection) round out the stack.

Coverage spans 369k+ markets across 13 leagues (nfl · nba · mlb · nhl · wnba · cfb · cbb · epl · ucl · la-liga · soccer · tennis · ufc). Delivery is a metered REST API plus signed webhooks.

Independent project. Not affiliated with Polymarket, Kalshi, ESPN, or any exchange, sportsbook, league, or team. Portions of the historical archive include data from pmxt (CC BY 4.0), nflverse, and football-data.co.uk.

Quickstart

1. Request a free API key — we send them the same day. Keys look like st_… and are shown once.

2. Make your first call — the coverage map:

shell
curl https://api.scoretape.com/v1/leagues \
  -H "X-API-Key: st_your_key_here"

3. Pull one game as one joined document:

shell
curl https://api.scoretape.com/v1/games/nba-sas-nyk-2026-06-10 \
  -H "X-API-Key: st_your_key_here"
json
{
  "data": {
    "game_slug": "nba-sas-nyk-2026-06-10", "league": "nba", "matched": true,
    "result":   { "home_team": "New York Knicks", "home_score": 107,
                  "away_team": "San Antonio Spurs", "away_score": 106, "winner": "home" },
    "odds":     [ { "provider": "DraftKings", "spread": -2.5, "over_under": 216.5,
                    "home_ml": -135, "away_ml": 114 } ],
    "markets":  [ { "kind": "moneyline", "outcomes": ["Spurs", "Knicks"],
                    "resolved": true, "winner_outcome": "Knicks" }, "… 118 markets" ],
    "injuries": [ "… the change-log for both teams up to start + 24h" ]
  },
  "meta": { "request_id": "req_…", "timestamp": "2026-06-12T09:17:08.301Z" }
}

4. Subscribe to injury alerts — see Webhooks.

Authentication

Every endpoint except /v1/health requires an API key. Pass it either way — both schemes are accepted:

shell
# X-API-Key header
curl https://api.scoretape.com/v1/leagues -H "X-API-Key: st_your_key"

# or Authorization: Bearer
curl https://api.scoretape.com/v1/leagues -H "Authorization: Bearer st_your_key"

Keys carry a plan that sets your rate limit and history window. Keys are stored SHA-256-hashed at rest — the raw value is shown exactly once at creation. Quotas are per user, not per key — extra keys do not multiply your limit.

FailureStatuscode
No key supplied401AUTH_MISSING
Unknown / revoked / malformed key401AUTH_INVALID

Base URL & versioning

All endpoints live under a single versioned base. Breaking changes get a new version prefix; v1 responses only gain fields, never lose them.

GEThttps://api.scoretape.com/v1/…
GEThttps://api.scoretape.com/v1/healthno auth

/v1/health reports ClickHouse liveness and the last entity-resolution build — useful as an uptime probe.

Response envelope

Every response is the same envelope. Success:

json
{
  "data": …,                       // object (single resource) or array (listing)
  "pagination": {                  // listings only
    "next_cursor": "…" | null,
    "has_more": true,
    "limit": 50,
    "count": 50
  },
  "meta": { "request_id": "req_…", "timestamp": "2026-06-12T09:17:08.301Z" }
}

Errors:

json
{
  "error": { "code": "GAME_NOT_FOUND", "message": "unknown game_slug" },
  "meta":  { "request_id": "req_…", "timestamp": "…" }
}

Branch on error.code, not the message. Every response carries meta.request_id — include it when reporting an issue. Timestamps are ISO-8601 UTC; raw epoch fields end in _ms.

Pagination

Listings use opaque keyset cursors — O(1) at any depth, stable under concurrent writes. Pass pagination.next_cursor back as ?cursor=; stop when has_more is false.

shell
curl "https://api.scoretape.com/v1/markets?league=nba&kind=moneyline&limit=100" -H "X-API-Key: $KEY"
# → "pagination": { "next_cursor": "31373831…", "has_more": true, … }

curl "https://api.scoretape.com/v1/markets?league=nba&kind=moneyline&limit=100&cursor=31373831…" \
  -H "X-API-Key: $KEY"
EndpointDefault limitMax
/v1/{league}/games50200
/v1/markets50200
/v1/games/{slug}/markets2001000
/v1/{league}/injuries100500

Error codes

codeStatusMeaning
AUTH_MISSING401No API key supplied
AUTH_INVALID401Unknown or revoked key
INVALID_PARAMETER400Malformed path/query value
INVALID_CURSOR400Cursor failed to decode
HISTORY_LIMIT_EXCEEDED403Requested data older than your plan's window
GAME_NOT_FOUND404Unknown game_slug
MARKET_NOT_FOUND404Unknown condition_id
WEBHOOK_NOT_FOUND404Unknown / not your sub_id
SNAPSHOT_NOT_FOUND404No captured book for this asset_id
ENDPOINT_NOT_FOUND404Unknown route
RATE_LIMIT_BURST429Per-second limit exceeded
RATE_LIMIT_SUSTAINED429Per-minute limit exceeded
INTERNAL_ERROR500Query failure — retry, then report with request_id

429 responses set Retry-After; every authenticated response carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-Request-Id.

Plans & rate limits

Plans gate throughput and two history windows: the tape window — how far back Kalshi and Polymarket markets, props, and order books are servable (forward-captured; it cannot be backfilled, which is why it is the product) — and the sports window (results, box scores, player logs, the sportsbook odds archive back to 1999). Injury webhook subscriptions are available on paid plans.

PlanPriceBurstSustainedTapeSports historyWebhooks
Free$01 req/s7 days7 days
Pro$29/mo25 req/s1,000 req/min30 days90 days
Scale$79/mo50 req/s3,000 req/min60 days365 days
Enterprise$200/mo100 req/sunlimitedfull archive

List endpoints silently clamp to your window's floor; an explicit from= (or a single game / market) older than the window returns 403 HISTORY_LIMIT_EXCEEDED. Futures markets with no game date stay visible on every plan. Both token buckets must pass — burst and sustained.

The join (entity resolution)

The spine of ScoreTape: every prediction market is resolved to a game, keyed by game_slug (e.g. nba-sas-nyk-2026-06-10 — league, teams, US/ET date). Markets are matched to ESPN games by league, date, and team identity, with the exact start time as a tie-breaker; tennis and UFC match by competitor names.

Each matched game carries the evidence:

FieldMeaning
matchedWhether an ESPN entity was resolved (unmatched games still serve)
espn.match_methodtime+names (exact start time) · day+names · surnames (tennis/UFC)
espn.confidenceName-match score, 0–1
espn.team0_sideWhich ESPN side (home/away) the market's first team maps to — the key for joining moneylines to results
The mapping is verified continuously against settlement: resolved moneyline winners agree with the ESPN result in 99.96%of checked games (2,268 of 2,269 at last audit). Unmatched games are typically markets for competitions our results source doesn't carry, or phantom playoff games that were never played.

Leagues & overview

GET/v1/leagues

Coverage map: every league with game counts, how many are matched to real-world results, total markets, and the first/last game day.

json
{ "data": [
  { "league": "tennis", "games": 13247, "matched": 1041, "markets": 89541,
    "first_day": "2024-08-30", "last_day": "2026-06-12" },
  { "league": "mlb", "games": 3751, "matched": 1277, "markets": 20905, … }, …
] }
GET/v1/overview

Row counts per data layer plus your plan and history window — a cheap freshness probe.

Games

GET/v1/{league}/games

Games for one league (one of: nfl · nba · mlb · nhl · wnba · cfb · cbb · epl · ucl · la-liga · soccer · tennis · ufc), newest first, with the joined result inline when matched.

ParamTypeNotes
fromYYYY-MM-DDInclusive lower bound on game start
toYYYY-MM-DDInclusive upper bound
matchedtrue|falseOnly games with / without a resolved ESPN entity
teamstringCase-insensitive team-name match on either side (“knicks”, “New York”)
limitintDefault 50, max 200
cursorstringKeyset cursor from the previous page
shell
curl "https://api.scoretape.com/v1/nba/games?from=2026-06-01&matched=true&limit=2" -H "X-API-Key: $KEY"
json
{ "data": [ {
  "game_slug": "nba-sas-nyk-2026-06-10",
  "league": "nba",
  "start": "2026-06-11T00:30:00.000Z",
  "title": "Spurs vs. Knicks",
  "team0": "Spurs", "team1": "Knicks",
  "markets": 118,
  "matched": true,
  "espn": { "league": "basketball/nba", "event_id": "401859966",
            "team0": "San Antonio Spurs", "team1": "New York Knicks",
            "team0_side": "away", "match_method": "time+names", "confidence": 1.0 },
  "result": { "day": "2026-06-10", "completed": true, "winner": "home",
              "home_team": "New York Knicks", "home_score": 107,
              "away_team": "San Antonio Spurs", "away_score": 106 }
} ], "pagination": { … }, "meta": { … } }

Game document (flagship)

GET/v1/games/{game_slug}

One game as one document: the entity-resolution evidence, the final result (with linescores and venue), sportsbook odds (up to 15 books — spread, total, moneylines, favorite, and which side won each bet), every prediction market with settlement (up to 1,000, sorted by volume), and the injury change-log for both teams up to 24h after start.

For tennis and UFC the result is a competitors array (per-athlete winner flags), and UFC carries per-fight moneylines.

GET/v1/games/{game_slug}/markets

Just the markets, with kind= (e.g. moneyline, spreads, totals) and resolved= filters. Limit up to 1,000.

GET/v1/games/{game_slug}/box

Per-athlete box-score lines for the game — every player, every stat group, with ESPN's labels/stats arrays (PTS, REB, AST… per league).

GET/v1/games/{game_slug}/plays

Play-by-play in game order (cursor, limit ≤ 500): period, clock, play text, running score, and which plays scored.

GET/v1/games/{game_slug}/winprob

ESPN's per-play home win probability — replay how the game's likelihood moved against your market ticks (cursor, limit ≤ 500).

shell
curl "https://api.scoretape.com/v1/games/nba-sas-nyk-2026-06-10/markets?kind=moneyline" -H "X-API-Key: $KEY"
json
{ "data": [ {
  "condition_id": "0xcc5ffe6f0dd2eb…",
  "kind": "moneyline",
  "title": "Spurs vs. Knicks",
  "outcomes": ["Spurs", "Knicks"],
  "tokens": ["38917726…", "…"],
  "game_start": "2026-06-11T00:30:00.000Z",
  "resolved": true,
  "winner_outcome": "Knicks",
  "winner_token": "…",
  "closed_time": "2026-06-11T03:54:42.000Z",
  "volume": …
} ], … }

Markets

GET/v1/markets

Browse all 369k+ markets. Filters: league=, kind=, resolved=true|false, plus cursor pagination. Sorted by game start, newest first; futures (no game date) sort last and are visible on every plan.

GET/v1/markets/{condition_id}

One market by its condition_id, with its game context attached — game_slug and the full joined game header when the spine has resolved it.

Injuries

GET/v1/{league}/injuries

The forward-captured injury change-log for nba · wnba · mlb · nhl · nfl · cfb · cbb — one row per status change, newest first. since=YYYY-MM-DD filters, cursor paginates. To reconstruct the report as of instant T, take each athlete's latest row with captured_ms ≤ T.

json
{ "data": [ {
  "league": "basketball/nba",
  "team": "Oklahoma City Thunder",
  "athlete_id": "530853",
  "athlete_name": "Jalen Williams",
  "position": "G",
  "status": "Out",
  "injury_type": "out",
  "short_comment": "Williams (hamstring) has been ruled out for Saturday's Game 7 …",
  "report_date": "2026-05-29T22:22Z",
  "captured": "2026-06-12T08:25:22.902Z"
} ], … }
League-wide reports are polled every 60 seconds. For push delivery instead of polling, use webhooks.

Players & props

Player identity is the ESPN athlete_id. The props↔athlete spine links each player-prop market (points, rebounds, assists, receiving_yards, anytime_touchdowns, baseball_player_home_runs, …) to the real athlete — matched against the game's own box-score roster first, settlement included.

GET/v1/{league}/players

Every athlete the data knows for a league (box scores ∪ rosters ∪ injury log). q= searches by name; cursor paginates.

shell
curl "https://api.scoretape.com/v1/nba/players?q=brunson" -H "X-API-Key: $KEY"
GET/v1/players/{athlete_id}

Profile: roster row (when carried), latest injury status, box-score game counts per league, and how many prop markets link to this player.

json
{ "data": {
  "athlete_id": "3934672",
  "athlete_name": "Jalen Brunson",
  "box": [ { "league": "basketball/nba", "games": 101 } ],
  "latest_injury": null,
  "prop_markets": 235
} }
GET/v1/players/{athlete_id}/injuries

The player's own forward-captured injury change-log — replay every status flip.

GET/v1/players/{athlete_id}/box

Game log: box lines joined with the game's date, opponent, and final score.

GET/v1/players/{athlete_id}/markets

The player's prop markets with settlement — kind= and resolved=filter, cursor paginates. Backtest a player's overs against the box line that settled them.

Historical odds (1999+)

GET/v1/{league}/odds-history

Two open archives behind one endpoint. NFL (nflverse): results + spread/total/moneylines back to 1999 — filter by season=, team= (abbreviation), from=/to=; cursor paginates. Soccer (football-data.co.uk) via epl, la-liga, or soccer + div= (22 division codes): multi-book 1X2, totals, and Asian-handicap odds back to 2000/01 — filter by season= code (2324 = 2023/24) and/or team=.

shell
curl "https://api.scoretape.com/v1/nfl/odds-history?season=1999&limit=1" -H "X-API-Key: $KEY"
json
{ "data": [ {
  "game_id": "1999_21_STL_TEN", "game_type": "SB", "gameday": "2000-01-30",
  "away_team": "STL", "away_score": 23, "home_team": "TEN", "home_score": 16,
  "spread_line": -7, "total_line": 48, "stadium": "Georgia Dome", …
} ], … }
Your plan's history window applies here too — soccer at season granularity (its upstream match dates are raw strings). Full-archive access is an Enterprise feature.

Order books: live & history

GET/v1/books/{asset_id}/latest

The newest captured order book for one outcome token (asset_id from a market's tokens array): full bid_prices/bid_sizes/ask_prices/ask_sizes ladders, best bid/ask, and exchange + receive timestamps.

GET/v1/books/{asset_id}/history

The snapshot time-series for the same token, newest first — from=/to= accept YYYY-MM-DD or epoch-millis, cursor paginates (limit ≤ 500). The hot window covers roughly the last 30 days of capture; deeper history from the cold archive is on the roadmap.

shell
# tokens[] comes from any market response
curl "https://api.scoretape.com/v1/books/3891772625554242…/history?limit=100" -H "X-API-Key: $KEY"

Injury webhooks

The fastest path to injury news in your system: register a URL, get a signed POST for every status change (Questionable → Out) within ~60 seconds of detection. Filter by league and by status substring per subscription.

POST/v1/webhooks
shell
curl -X POST https://api.scoretape.com/v1/webhooks \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{
    "url": "https://your.app/hooks/scoretape",
    "leagues": ["nba", "nfl"],
    "statuses": ["out", "doubtful", "questionable"]
  }'
json
{ "data": {
  "sub_id": "wh_d5acf5e1091fb7ce",
  "secret": "whsec_…",        // shown ONCE — store it to verify signatures
  "url": "https://your.app/hooks/scoretape",
  "leagues": ["nba", "nfl"],
  "statuses": ["out", "doubtful", "questionable"]
} }

Empty leagues/statusesmean “everything”. Status filters are case-insensitive substrings — "injured reserve" matches Injured Reserve (DTR).

The event

json
POST https://your.app/hooks/scoretape
X-ScoreTape-Event: injury.status_change
X-ScoreTape-Signature: sha256=2f1d…        // HMAC-SHA256 of the raw body, keyed by your secret

{
  "event": "injury.status_change",
  "league": "nba",
  "team": "Oklahoma City Thunder",
  "athlete": "Jalen Williams", "athlete_id": "530853", "position": "G",
  "old_status": "Questionable",
  "new_status": "Out",
  "injury_type": "out",
  "comment": "Williams (hamstring) has been ruled out …",
  "report_date": "2026-05-29T22:22Z",
  "captured_ms": 1781252722902,
  "sent_ms": 1781252725000
}

Verify the signature

python
import hashlib, hmac

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    want = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(want, header)

Manage subscriptions

GET/v1/webhooks

Your subscriptions with 24h delivery health (delivered_24h, failed_24h, last_delivery).

POST/v1/webhooks/{sub_id}/test

Fires a signed webhook.test event at your URL right now and returns the delivery result — verify your receiver before a real alert matters.

DELETE/v1/webhooks/{sub_id}
Delivery: 2 attempts with a 6-second timeout, results logged for 30 days. Respond 2xx fast and process async. A storm guard caps any single pass — an upstream bulk edit can never blast your endpoint with hundreds of events.

Code examples

Walk a season of joined games (Python)

python
import requests

BASE = "https://api.scoretape.com/v1"
H = {"X-API-Key": "st_your_key"}

cursor, games = None, []
while True:
    q = f"{BASE}/nba/games?matched=true&limit=200"
    if cursor: q += f"&cursor={cursor}"
    page = requests.get(q, headers=H).json()
    games += page["data"]
    if not page["pagination"]["has_more"]: break
    cursor = page["pagination"]["next_cursor"]

# joined: each game carries result + espn linkage; markets are one call away
print(len(games), "games")

Did the market beat the closing line? (Python)

python
g = requests.get(f"{BASE}/games/nba-sas-nyk-2026-06-10", headers=H).json()["data"]

ml  = next(m for m in g["markets"] if m["kind"] == "moneyline")
dk  = next(o for o in g["odds"] if o["provider"] == "DraftKings")

print("market winner:", ml["winner_outcome"])     # Knicks
print("closing line:", dk["home_ml"], dk["away_ml"])  # -135 / +114
print("final:", g["result"]["home_score"], "-", g["result"]["away_score"])

Injury alert receiver (Node)

javascript
import { createHmac, timingSafeEqual } from "node:crypto";

export async function POST(req) {
  const raw = Buffer.from(await req.arrayBuffer());
  const sig = req.headers.get("x-scoretape-signature") ?? "";
  const want = "sha256=" + createHmac("sha256", process.env.SCORETAPE_WHSEC).update(raw).digest("hex");
  if (!timingSafeEqual(Buffer.from(want), Buffer.from(sig))) return new Response("bad sig", { status: 401 });

  const ev = JSON.parse(raw.toString());
  if (ev.event === "injury.status_change" && ev.new_status === "Out") {
    // re-price before the line moves
  }
  return new Response("ok");
}

Status & changelog

What's live, newest first:

  • Players & props. Player search, profiles, per-player injury history, game logs, and prop markets entity-resolved to athletes (25k+ links, box-roster verified).
  • Game layers. Box scores, play-by-play, and per-play win probability on every matched game.
  • Historical odds. NFL closing lines to 1999 (nflverse) and 22 divisions of European soccer odds to 2000/01 (football-data) behind /v1/{league}/odds-history.
  • Book history. Order-book snapshot time-series over the hot window, plus team= filtering on games.
  • Injury webhooks. Per-user signed subscriptions, 60s detection, league/status filters, test endpoint, delivery health.
  • Full v1 API. Joined game documents, markets with settlement, multi-book odds, injury change-log, live books, cursor pagination, plan history enforcement.
  • The verified join. 369k markets entity-resolved to games; settlement agreement 99.96% at last audit.
  • Public HTTPS. https://api.scoretape.com over TLS.

On the roadmap: order-book history past the 30-day hot window (cold-archive views), account dashboard with self-serve keys and billing, and additional event types on the webhook channel (line movement). Questions or a coverage request? Get in touch.