API Reference · v1

Highlight API Documentation

Real-time sports content, structured for your app. Pair play-by-play events with the highlight clip that shows them, and receive every verified-insider news break parsed into typed events. REST API, JSON responses.

Base URL https://api.highlightapi.com Version v1 Format JSON

Overview

Highlight API is two products behind one REST interface. Clip matching pulls highlight videos from official team, league, and broadcast-partner X accounts and pairs each one with the exact play it shows in the official play-by-play feed. Breaking news watches verified insiders (Shams, Schefter, Passan, Rosenthal, Fabrizio Romano, Ornstein, and more) around the clock and hands your app a typed event with structured facts within a minute of a tweet going out.

Both pipelines pre-compute everything upfront. We scrape clips, feed each video into our matching model along with candidate plays from that game's play-by-play, and store the structured match. We scrape insider tweets, classify each as breaking or not, and extract structured event facts (players, teams, contract terms, injury details) when they are. By the time your app asks for either, the work has already happened. Lookups are a single indexed SQL query.

Supported sports

Highlight API is sport-agnostic. Every endpoint takes a :sport path parameter so you integrate once and get coverage across every league you care about.

  • nba: National Basketball Association
  • nfl: National Football League
  • mlb: Major League Baseball
  • nhl: National Hockey League
  • epl: English Premier League
  • ucl: UEFA Champions League
  • laliga, bundesliga, seriea, ligue1: top-five European football
  • pga: PGA Tour
  • mls, wnba, ncaam, ncaaf: additional leagues

What you get

  • A tweet ID and embed URL for every matched clip. You render the official X embed.
  • A structured match object linking the clip to an exact play: period, game clock, player, action type, score, and more.
  • A confidence level (high, medium, low) so you can set your own thresholds. We thoroughly backtest every match, and 100% of our high-confidence clips are accurate. Roughly 95% of matched clips come back at high.
  • Live polling support via a since cursor for in-progress games.
  • A structured NewsEvent for every verified-insider break, with a top-level type (trade, extension, signing, injury, firing, hiring, suspension, …), a unified subjects[] array, a type-specific detail block (contract, injury, tenure), and a ready-to-render headline + subheadline pair sized for push notifications.

Quickstart

Get your first matched clip in under a minute. Every endpoint takes a :sport path parameter; the examples below use nba, but you can swap in nfl, mlb, epl, or any supported league.

curl: fetch all matched clips for a game
# 1. List games for a date
curl https://api.highlightapi.com/v1/nba/games?date=2026-04-06 \
  -H "X-API-Key: YOUR_API_KEY"

# 2. Fetch matched clips for a game
curl https://api.highlightapi.com/v1/nba/games/nba-2026-04-06-nyk-atl/clips \
  -H "X-API-Key: YOUR_API_KEY"
JavaScript (Node 18+)
const res = await fetch(
  'https://api.highlightapi.com/v1/nba/games/nba-2026-04-06-nyk-atl/clips',
  { headers: { 'X-API-Key': process.env.HIGHLIGHT_API_KEY } }
);
const { clips } = await res.json();

for (const clip of clips) {
  console.log(clip.matchedPlay.description, '→', clip.embedUrl);
}

// For live games, hold open one SSE connection instead of polling:
// new EventSource('.../clips/stream?apiKey=...').onmessage = ...
// See "Streaming (recommended for live)" under List clips below.

Authentication

All production endpoints under /v1/:sport/* require an API key, passed in the X-API-Key header. Keys are issued per client and can be rotated at any time.

Example request header
X-API-Key: sk_live_a1b2c3d4e5f6...

Browser EventSource clients (used by the streaming endpoints) can't attach custom headers, so the same key is also accepted as an ?apiKey= query param. Server-side clients should always prefer the header.

!
Keep your API key secret. Never embed it in a client-side JavaScript bundle or a mobile app binary. Proxy requests through your backend and attach the header server-side, or mint short-lived per-session keys before passing them as ?apiKey=.

Requests with a missing or invalid key return 401 Unauthorized.

Base URL & versioning

All API calls are made over HTTPS to:

https://api.highlightapi.com

The current API version is v1, included in the path. Any breaking changes will ship under a new version prefix. Additive changes (new fields, new optional parameters) can land in v1 without notice; clients should ignore unknown fields.

How matching works

Matching is pre-computed, so API calls hit a single indexed SQL query. Here is what happens between a player hitting a shot and the clip landing in your app:

  1. Scrape. We poll every team, league, and active broadcast-partner account on X every couple of minutes.
  2. Resolve game. Each clip is tagged with a gameId based on the posting account and timestamp (for team posts) or caption and broadcaster schedule (for league and partner posts).
  3. Download and analyze. A background worker pulls the video and runs it through our matching model along with candidate plays from the game's play-by-play. The window starts narrow (around 15 minutes around the post) and widens to 30 minutes or the full game if needed.
  4. Match and store. The model returns the exact play with a confidence level, which we store on the clip row.
  5. Serve. When you query the API, we return the pre-matched clip. No per-request AI cost.
i
Typical latency from play to matched clip: 3 to 8 minutes end-to-end during a live game. The biggest variable is how fast the source account posts the video. Team accounts average 4 to 6 minutes, broadcast partners average 8 to 15 minutes.

Game IDs

Every game has two IDs you can use to reference it:

FieldFormatDescription
id string Our deterministic ID: {sport}-{date}-{away}-{home} in lowercase (e.g. nba-2026-04-06-nyk-atl, nfl-2025-11-09-ne-tb, epl-2026-03-15-ars-liv). Stable and human-readable.
leagueGameId string The league's official game ID (for example NBA's 10-digit ID, or a provider-specific ID like Sportradar's UUID). Useful when integrating with upstream providers that use those formats.

If your system already has a league-assigned game ID, use GET /v1/:sport/games/by-league-id/:leagueGameId to translate to our ID.

Integration patterns

Live game streaming (recommended)

Hold open one EventSource per game and each clip is pushed the moment our matcher finishes it. Same per-clip shape as the list endpoint. Browser clients pass the API key as ?apiKey= because EventSource can't set custom headers; server-side clients can use either the query param or the X-API-Key header. Reconnect is automatic and replays the last 15 minutes via Last-Event-ID.

const es = new EventSource(
  `/v1/${sport}/games/${gameId}/clips/stream?apiKey=${key}`
);

es.onmessage = (msg) => {
  const clip = JSON.parse(msg.data);
  renderClip(clip);
};

Live game polling (fallback)

For server environments that can't hold an open HTTP connection, poll the clips endpoint with a since cursor every 30 seconds.

let lastSeen = '2026-04-06T23:00:00Z';

setInterval(async () => {
  const url = `/v1/${sport}/games/${gameId}/clips?since=${lastSeen}`;
  const { clips } = await (await fetch(url, { headers })).json();

  for (const clip of clips) {
    renderClip(clip);
    lastSeen = clip.postedAt;
  }
}, 30_000);

Point-in-time lookup

For ad-hoc lookups ("give me the clip for this specific play"), use the match endpoint. Pass the play details and we return the best matching clip.

const res = await fetch('/v1/nba/clips/match', { // or /v1/nfl/clips/match, /v1/epl/clips/match, etc.
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'X-API-Key': key },
  body: JSON.stringify({
    gameId: 'nba-2026-04-06-nyk-atl',
    wallClockTime: '2026-04-07T02:14:00Z',
    playType: 'three_pointer',
    players: ['J. Brunson'],
  }),
});

Batch hydration

Have a list of past plays you want to find clips for? Call the clips endpoint once for the game and join locally against the matchedPlay.timeActual and matchedPlay.player fields. This is far cheaper than per-play lookups.

Breaking news streaming (recommended)

News breaks aren't tied to a game, so you subscribe to one global stream. Hold open a EventSource connection to /v1/news/stream and each finalized event is pushed the moment we store it, typically 1-2 seconds after the insider tweets. Filter by sport, event type, or the breaking flag so you only wake up for the events that matter. Reconnect is automatic and resumes via Last-Event-ID, so missed events during a dropped connection are replayed.

const es = new EventSource(`/v1/news/stream?breaking=true&sport=nfl`);

es.onmessage = (msg) => {
  const evt = JSON.parse(msg.data);
  pushNotification(evt);     // render evt.headline + evt.type
};

Breaking news polling (fallback)

For server environments that can't hold an open HTTP connection, poll GET /v1/news with a since cursor. 60-second cadence is the sweet spot: the top breakers are polled every 60 seconds on our side, so tighter polling yields no extra coverage.

let lastSeen = new Date(Date.now() - 10 * 60 * 1000).toISOString();

setInterval(async () => {
  const url = `/v1/news?breaking=true&sport=nfl&since=${lastSeen}`;
  const { events } = await (await fetch(url, { headers })).json();

  for (const evt of events) {
    pushNotification(evt);     // render evt.headline + evt.type
    lastSeen = evt.postedAt;
  }
}, 60_000);

Outgoing webhooks (push to your server)

For platforms where holding open a long-lived SSE connection is inconvenient (Elastic Beanstalk, App Runner, Cloud Run, any autoscaled web tier behind an ALB), register a webhook and we POST each finalized event to a URL you own. Every delivery is a normal short-lived HTTPS request your web tier can service directly, no dedicated consumer process required.

Clips and news share one subscription model. Pick the events you care about, provide optional filters (sport, game, breaking, category), and we retry with HMAC-signed requests until you 2xx or we exhaust the retry budget. Full details including signature verification, envelope shape, and retry policy live in the Outgoing webhooks section.

// One-time setup: create a subscription for both feeds.
const res = await fetch('https://api.highlightapi.com/v1/webhooks', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'X-API-Key': key },
  body: JSON.stringify({
    url:     'https://yourapp.com/webhooks/highlightapi',
    events:  ['news.finalized', 'clips.finalized'],
    filters: { sport: 'nba' },
  }),
});
const { id, secret } = await res.json();
// Store `secret` immediately. It's shown exactly once and is what you
// verify the X-Highlight-Signature header against on each delivery.

List games

Returns all games in a sport for a given date, ordered by start time.

GET /v1/:sport/games?date=YYYY-MM-DD Auth required

Query parameters

ParameterTypeDescription
date Required string Date in YYYY-MM-DD format (U.S. Eastern calendar date). Example: 2026-04-06.

Response

{
  "date": "2026-04-06",
  "count": 7,
  "games": [
    {
      "id": "nba-2026-04-06-nyk-atl",
      "leagueGameId": "0022501178",
      "date": "2026-04-06",
      "homeTeam": "ATL",
      "awayTeam": "NYK",
      "status": "final",
      "startTime": "2026-04-06T23:30:00Z"
    }
  ]
}

Lookup game by league ID

Look up a game using the league's official game ID (for example NBA's 10-digit ID, or a Sportradar UUID for a Premier League match). Useful when you're already keying on an upstream provider's ID.

GET /v1/:sport/games/by-league-id/:leagueGameId Auth required

Path parameters

ParameterTypeDescription
leagueGameId Required string The league's official game ID (e.g. 0022501178 for NBA).

Response

{
  "game": {
    "id": "nba-2026-04-06-nyk-atl",
    "leagueGameId": "0022501178",
    "homeTeam": "ATL",
    "awayTeam": "NYK",
    "status": "final",
    "date": "2026-04-06",
    "startTime": "2026-04-06T23:30:00Z"
  }
}

Game coverage

Returns clip availability stats for a game. Use it to decide whether to start polling clips, or to gate UI state on whether any matched clips exist yet.

GET /v1/:sport/games/:gameId/coverage Auth required

Response

{
  "gameId": "nba-2026-04-06-nyk-atl",
  "game": {
    "status": "final",
    "homeTeam": "ATL",
    "awayTeam": "NYK",
    "date": "2026-04-06"
  },
  "coverage": {
    "totalClips": 73,
    "matchedClips": 51,
    "processedClips": 73,
    "pendingClips": 0,
    "latestClipAt": "2026-04-07T02:48:11Z"
  }
}
FieldDescription
totalClipsEvery clip ingested for this game, regardless of match status.
matchedClipsClips with a model match at high or medium confidence. These are the ones most apps will want to surface.
processedClipsClips that have finished model processing (matchStatus = 'done'). Includes matched clips and any that came back without a confident match.
pendingClipsClips still queued or in flight (matchStatus of pending or processing). Poll again in 30 to 60 seconds.
latestClipAtTimestamp of the most recently posted clip for this game. Useful as an initial since cursor.

Game highlights

Returns official highlight videos for a game, grouped by type. Today fullGame is populated across every supported league (sourced from each league's official YouTube channel). The response shape is forward-compatible with future types such as recap, topPlays, and condensed.

GET /v1/:sport/games/:gameId/highlights Auth required

Response

{
  "gameId": "nba-2026-04-06-nyk-atl",
  "game": {
    "status": "final",
    "homeTeam": "ATL",
    "awayTeam": "NYK",
    "date": "2026-04-06",
    "leagueGameId": "0022501178"
  },
  "count": 1,
  "highlights": {
    "fullGame": [
      {
        "videoId": "abc123XYZ",
        "title": "Knicks vs. Hawks FULL GAME HIGHLIGHTS",
        "embedUrl": "https://www.youtube.com/embed/abc123XYZ",
        "watchUrl": "https://www.youtube.com/watch?v=abc123XYZ",
        "thumbnailUrl": "https://i.ytimg.com/vi/abc123XYZ/hqdefault.jpg",
        "postedAt": "2026-04-07T04:12:03Z",
        "ingestSource": "webhook"
      }
    ]
  }
}

Full-game highlights typically land 1 to 2 hours after the final buzzer. Ingest happens via the YouTube PubSubHubbub webhook in seconds, with a game-end-aware RSS poller as a fallback.

List clips

The primary endpoint. Returns all clips for a game, sorted chronologically by posting time. Each clip includes the matched play event.

GET /v1/:sport/games/:gameId/clips Auth required

Path parameters

ParameterTypeDescription
gameId Required string Our game ID (e.g. nba-2026-04-06-nyk-atl).

Query parameters

ParameterTypeDescription
status Optional string Filter by match status. One of:
matched (default): clips with a model match at high, medium, or low confidence.
pending: clips still queued or being processed.
all: every clip, including unmatched and skipped non-play content.
since Optional string ISO 8601 timestamp. Return only clips posted after this time (tweet post time). Use for incremental polling against a game feed.
sinceProcessed Optional string ISO 8601 timestamp. Return only clips whose match finalized after this time (matchProcessedAt > value), sorted oldest-first. Use this as the sanctioned catchup path after an outgoing webhook retry budget exhausts: pass the last delivered event's occurredAt and every missed clip comes back in delivery order. Pending and skipped rows are excluded.

Response

{
  "gameId": "nba-2026-04-06-nyk-atl",
  "game": {
    "status": "final",
    "homeTeam": "ATL",
    "awayTeam": "NYK",
    "date": "2026-04-06",
    "leagueGameId": "0022501178"
  },
  "count": 51,
  "clips": [
    {
      "id": 109,
      "tweetId": "2041302975646179663",
      "teamAccount": "nbaonnbc",
      "teamId": null,
      "gameId": "nba-2026-04-06-nyk-atl",
      "postedAt": "2026-04-06T23:52:09.000Z",
      "caption": "KAT beats everyone down the floor for the SLAM.",
      "embedUrl": "https://x.com/nbaonnbc/status/2041302975646179663",
      "thumbnailUrl": "https://pbs.twimg.com/media/HFQqN-PW8AAPeo5.jpg",
      "playType": ["dunk"],
      "playersMentioned": [],
      "quarter": null,
      "gameClock": null,
      "clipType": "single_play",
      "matchStatus": "done",
      "matchConfidence": "high",
      "matchedPlay": {
        "timeActual": "2026-04-06T23:27:58.0Z",
        "period": 1,
        "clock": "PT04M59.00S",
        "actionType": "2pt",
        "subType": "DUNK",
        "player": "K. Towns",
        "teamTricode": "NYK",
        "description": "K. Towns running DUNK (5 PTS) (M. McBride 1 AST)",
        "scoreHome": "15",
        "scoreAway": "20",
        "assistPlayerNameInitial": "M. McBride"
      }
    }
  ]
}
i
The response includes a slim game object and a matchedPlay stripped to client-useful fields. Internal metadata (videoUrl, matchWindow, matchReasoning) is excluded. matchedPlay can be null even on a done clip if the model processed the video but could not tie it to a specific PBP action. Filter by matchedPlay != null on the client if you only want fully-resolved matches.

Streaming (recommended for live)

Hold open one Server-Sent Events connection per game and each clip is pushed the moment the model finishes matching it. Each event carries the same per-clip shape as the list response above. Browser clients pass the API key as a query param because EventSource can't set custom headers; server-side clients can use either the query param or the X-API-Key header. EventSource reconnects automatically and replays the last 15 minutes via Last-Event-ID.

GET /v1/:sport/games/:gameId/clips/stream Auth required

Accepts status=matched (default, only emits done + high/medium/low confidence) or status=all (every finalized clip including failed and skipped). Pending and processing transitions are never streamed.

const es = new EventSource(
  `/v1/nba/games/${gameId}/clips/stream?apiKey=${key}`
);

es.onmessage = (msg) => {
  const clip = JSON.parse(msg.data);
  renderClip(clip);
};

Match a play

Convenience endpoint. Given a play event, return the best matching clip. The API first checks pre-matched clips from our model (instant indexed lookup) and falls back to heuristic scoring on caption and timing for clips that are still in the processing queue.

POST /v1/:sport/clips/match Auth required

Request body

FieldTypeDescription
gameId RequiredstringOur game ID.
wallClockTime RequiredstringISO 8601 timestamp of when the play occurred in real time.
playType OptionalstringOne of our supported play types (see Play types). Improves match quality.
players Optionalstring[]Player names involved in the play. Full or last name is fine.
description OptionalstringThe raw play-by-play description. Helps disambiguation.
team OptionalstringTeam tricode or short code of the play-maker (e.g. NYK, NE, LIV).
period OptionalnumberGame period (quarter, half, inning, or set depending on the sport).
Example request
POST /v1/nba/clips/match   // or /v1/nfl/, /v1/epl/, etc.
Content-Type: application/json
X-API-Key: sk_live_...

{
  "gameId": "nba-2026-04-06-nyk-atl",
  "wallClockTime": "2026-04-07T02:19:48Z",
  "playType": "dunk",
  "players": ["O. Anunoby"],
  "description": "Anunoby Dunk (16 PTS)",
  "team": "NYK",
  "period": 3
}

Response

{
  "match": {
    "tweetId": "2041319802384347608",
    "caption": "YESSSIRRR OG",
    "embedUrl": "https://x.com/nyknicks/status/2041319802384347608",
    "playType": ["dunk"],
    "playersMentioned": ["Anunoby"],
    "matchedPlay": { /* see MatchedPlay model */ },
    "matchConfidence": "high"
  },
  "confidence": 0.96,
  "source": "model",
  "candidates": [ /* other close matches */ ]
}
FieldDescription
matchThe best matching clip, or null if no clip met the minimum threshold.
confidenceNumeric confidence score from 0.00 to 1.00. See Confidence levels.
sourcemodel for clips already matched by our modeling pipeline (the common case), heuristic for fallback matches served while the clip is still in the processing queue.
candidatesUp to 5 runner-up clips with scores, for debugging or UI "related clips" sections.

List news events

Returns breaking news events from our tracked insiders, sorted newest first. Filter by sport, event type, impact, or the breaking classification flag. Every event conforms to the NewsEvent schema.

GET /v1/news Auth required

Query parameters

ParameterTypeDescription
sport Optional string Filter by sport: nba, nfl, mlb, nhl, soccer. Omit to receive cross-sport events.
type Optional string Filter by event type: trade, extension, signing, injury, firing, hiring, suspension, retirement, release.
impact Optional string Minimum impact tier: low, medium, high, or record. Each value includes all higher tiers (e.g. high also returns record).
breaking Optional boolean When true (default), returns only tweets our classifier flagged as substantive breaking news (trades, signings, injuries, firings, etc.). Set to false to include every insider post, including commentary and press-conference quotes.
reporter Optional string X handle of a specific insider, without the @ (e.g. ShamsCharania, AdamSchefter). Case-insensitive.
since Optional string ISO 8601 timestamp. Returns only events posted after this time (tweet post time). Use for incremental polling.
sinceExtracted Optional string ISO 8601 timestamp. Returns only events whose extractor verdict finalized after this time (extractedAt > value), sorted oldest-first. Use this as the sanctioned catchup path after an outgoing webhook retry budget exhausts: pass the last delivered event's occurredAt and every missed event comes back in delivery order. Rows still pending extraction are excluded.
limit Optional number Max results. Default 50, hard ceiling 200.

Response

{
  "count": 1,
  "events": [
    {
      "id": "news_2045190996686680171",
      "type": "extension",
      "sport": "nfl",
      "impact": "record",
      "postedAt": "2026-04-17T17:21:36Z",
      "latencyMs": 38000,
      "headline": "Will Anderson signs $150M extension",
      "subheadline": "Highest-paid non-QB in NFL history. $100M guaranteed.",
      "source": {
        "reporter": "Adam Schefter",
        "handle": "AdamSchefter",
        "outlet": "ESPN"
      },
      "subjects": [
        {
          "kind": "player",
          "name": "Will Anderson Jr.",
          "position": "DE",
          "teamTricode": "HOU"
        }
      ],
      "contract": {
        "years": 3,
        "totalValueUsd": 150000000,
        "guaranteedUsd": 100000000,
        "avgPerYearUsd": 50000000,
        "noTradeClause": true
      },
      "tweet": {
        "id": "2045190996686680171",
        "url": "https://x.com/AdamSchefter/status/2045190996686680171"
      }
    }
  ]
}

Every event shares the same envelope: type, sport, impact, source, subjects[], headline, subheadline, and tweet. A type-specific detail block (contract, injury, tenure) is attached when it applies. headline + subheadline are sized to drop straight into a push notification. See NewsEvent for the full shape.

Streaming (recommended for live)

Hold open one Server-Sent Events connection and each finalized event is pushed the moment we store it (typically 1-2s after the insider tweets). No polling, no 30-second REST cache, no wasted round-trips. Each SSE event carries the same NewsEvent payload as the list endpoint. The browser's EventSource reconnects automatically and resumes via Last-Event-ID; on reconnect we replay events from the last 15 minutes.

GET /v1/news/stream Auth required

Accepts the same sport, breaking, and category filters as the list endpoint. Default is breaking=true (only extracted, structured events). Pass breaking=all to receive every finalized row, including ones the extractor skipped.

// Browser clients pass the API key as ?apiKey= because EventSource
// can't attach custom headers. Server-side: use the X-API-Key header.
const es = new EventSource(
  `https://api.highlightapi.com/v1/news/stream?sport=nfl&apiKey=${key}`
);

es.onmessage = (msg) => {
  const event = JSON.parse(msg.data);
  pushNotify(event.headline, event.subheadline);
};

es.onerror = () => {
  // EventSource auto-reconnects with the last event id it saw,
  // so we replay anything finalized during the outage (up to 15m).
};

Server-side clients that can't use EventSource should send the Last-Event-ID header themselves on reconnect. The value is the extractedAt ISO timestamp of the last event you received.

List insider sources

Returns every insider currently in your coverage: handle, canonical reporter name, outlet, and the primary sport they cover. Use it to render an attribution column in your UI, or to audit which handles you're receiving events from.

GET /v1/news/sources

Response

{
  "count": 26,
  "sources": [
    {
      "handle": "ShamsCharania",
      "name": "Shams Charania",
      "outlet": "ESPN",
      "sport": "nba"
    }
  ]
}
i
Need a handle we don't cover? Request additional insiders per sport (a local beat reporter, a team-specific breaker, a league-office account) and we'll add them to your curated list. Self-serve via API is on the roadmap; today, email support@highlightapi.com with the handle, sport, and why it matters.

Story clusters

When Shams breaks a trade and Rapoport confirms it three minutes later, the two tweets are the same story. Story clusters group every insider tweet about the same event into one row and track a confidence ladder that moves forward as more reporters confirm. Prop market makers, prediction-market traders, and sportsbook trading desks care about the gap between "one reporter says so" and "two independent Tier-A reporters both say so" almost as much as the initial break.

The confidence ladder

Every cluster carries a confidence value that only ever moves forward. Each transition fires a cluster.upgraded webhook and an SSE frame so your system reacts in real time.

RungMeaningLights up when
singleOne independent Tier-A insider reported it.Any Tier-A reporter breaks a new story, or a Tier-B aggregator tweet creates a cluster that no Tier-A has touched yet.
multiTwo or more independent Tier-A insiders both reported it.A second distinct Tier-A handle posts on the same story. Same-reporter follow-ups (Shams tweeting about Shams) do not count.
teamThe team's official PR account posted it.Reserved. Lights up automatically as team-PR handles come online; wire your subscriber for it today.
officialThe league office posted or confirmed it.Reserved. Same deal: the rung is defined and your receiver can filter on it. We'll start emitting once league-office ingest ships.
i
Tier B reporters never bump the ladder. They're included in reporters[] and members[] (you get their tweets), but only independent Tier A handles (weight >= 1.0) count toward multi. This is load-bearing: in backtests, 11 of 14 multi-member clusters were one reporter echoing their own story 2-4 times. Counting tweet volume instead of distinct handles would have falsely upgraded all of them.

How clustering works

Each extracted insider tweet is assigned a canonical key shaped like ${type}:${sport}:${normalized_last_name}, where the last name is pulled from the first subject and normalized (NFKD + diacritic strip, suffixes like Jr. / III removed). Tweets hashing to the same key within a 12-hour rolling window join the same cluster. Each new member extends the window another 12 hours; a stale story whose last member is 13 hours old forks into a fresh cluster instead of false-merging.

The cluster's rolledUpPayload is the NewsEvent shape merged across every member. Headline and subheadline stick to the first member (the initial break sets the framing). Numeric fields (impact, injury status) take the most-official value across members. Subjects and detail blocks union across members, preferring the richer entry. You also get the raw members[] array with each member's own structuredPayload, so if you want to implement your own dispute or contradiction logic client-side, the inputs are already there.

List clusters

Returns recent story clusters, newest first by updatedAt. Each cluster carries its full StoryCluster envelope including members[] and reporters[].

GET /v1/news/clusters Auth required

Query parameters

ParameterTypeDescription
sport Optional string Filter by sport: nba, nfl, mlb, nhl, soccer.
type Optional string Filter by event type: trade, extension, signing, injury, firing, hiring, suspension, retirement, release, other.
confidence Optional string Exact rung match: single, multi, team, official. Unlike impact on /v1/news, this is not a minimum: confidence=single returns only single clusters.
since Optional string ISO 8601 timestamp. Returns only clusters whose updatedAt is after this value. Use for incremental polling.
limit Optional number Max results. Default 50, hard ceiling 200.

Response

{
  "count": 1,
  "clusters": [
    {
      "id":            "7a1c8b24-3e9f-4a2b-9d6c-8f12aa34e501",
      "canonicalKey":  "injury:nba:wembanyama",
      "keyVersion":    1,
      "sport":         "nba",
      "type":          "injury",
      "impact":        "high",
      "confidence":    "multi",
      "headline":      "Wembanyama out indefinitely with concussion",
      "subheadline":   "Stepped into protocol after last night's contact.",
      "subjects": [
        { "kind": "player", "name": "Victor Wembanyama", "teamTricode": "SAS" }
      ],
      "injury": { "status": "out", "bodyPart": "concussion protocol", "seasonEnding": false },
      "firstMemberPostedAt": "2026-04-22T23:14:08Z",
      "updatedAt":          "2026-04-22T23:15:11Z",
      "reporters": [
        { "handle": "ShamsCharania", "tier": "A", "weight": 1.0, "firstConfirmedAt": "2026-04-22T23:14:08Z" },
        { "handle": "ChrisBHaynes",  "tier": "A", "weight": 1.0, "firstConfirmedAt": "2026-04-22T23:15:11Z" }
      ],
      "members": [
        {
          "tweetId":         "2045321...",
          "handle":          "ShamsCharania",
          "postedAt":        "2026-04-22T23:14:08Z",
          "extractedAt":     "2026-04-22T23:14:16Z",
          "ingestSource":    "x-stream",
          "embedUrl":        "https://x.com/ShamsCharania/status/2045321...",
          "reporter":        { "name": "Shams Charania", "outlet": "ESPN", "tier": "A", "weight": 1.0 },
          "structuredPayload": { /* ExtractedNews shape for this specific tweet */ }
        }
      ],
      "contradictions": null,
      "disputedBy":     null
    }
  ]
}
i
Stubs on /v1/news. Every news event on GET /v1/news now carries a cluster: { id, confidence, reporterCount } stub. If you just want a badge on the feed ("2 sources confirmed"), read it off there without fetching the full cluster.

Stream clusters (SSE)

Server-Sent Events. Hold one connection open and receive a frame on every cluster transition: creation at single, progression to multi, and the future team / official rungs the moment they come online.

GET /v1/news/clusters/stream Auth required

Accepts the same sport, type, and confidence filters as the list endpoint. The confidence filter gates on the cluster's current rung, so confidence=multi streams only frames where the transition leaves the cluster at multi.

Frame shape

id: 2026-04-22T23:15:11Z
data: {
  "transition": {
    "previous":          "single",
    "current":           "multi",
    "triggeringTweetId": "2045321...",
    "skippedRungs":      []
  },
  "cluster": { /* full StoryCluster envelope */ }
}

The id field on each SSE frame is the cluster's updatedAt ISO timestamp. EventSource replays this on reconnect via Last-Event-ID and we emit the current state of every cluster whose updatedAt is past the cursor (capped at 15 minutes). On replay, transition is null because we don't persist a transition log; treat replay frames as "catch me up to current state."

i
Creation events. The first member that creates a cluster also fires a frame, with transition.previous: null and transition.current: "single". Filter with transitions: ["creation"] (webhooks) or check transition.previous === null (SSE) to distinguish it from a progression.

Get a cluster

Full StoryCluster envelope for one cluster by ID. Same shape as items in the list response.

GET /v1/news/clusters/:id Auth required

Returns 404 if the cluster ID doesn't exist. id is the UUID v4 returned on every cluster envelope and on the cluster.id stub attached to /v1/news rows.

Outgoing webhooks

Webhooks are the third delivery option alongside REST polling and Server-Sent Events. Register a URL you own, and we POST each finalized event as a signed HTTPS request. Each delivery is short-lived, so your autoscaled web tier can serve webhooks the same way it serves any other request. No long-lived connections, no dedicated consumer process, no fan-out queue.

Clips, news, and story-cluster upgrades share one subscription model. A single subscription can receive any combination of news.finalized, clips.finalized, and cluster.upgraded, with filters scoped per event type.

How a delivery looks

Every webhook is an HTTPS POST with a JSON envelope body and a set of signature headers. For news.finalized and clips.finalized, the data field carries the same payload as the corresponding list endpoint (GET /v1/:sport/games/:gameId/clips for clips.finalized, GET /v1/news for news.finalized). For cluster.upgraded, the envelope is lifted (no data wrapper): the transition and cluster are first-class fields at the top level. See below for the shape.

POST https://yourapp.com/webhooks/highlightapi
Content-Type: application/json
User-Agent: HighlightAPI-Webhook/1.0
X-Highlight-Delivery:  4c3a1e7b-9a2b-4d6f-a1b3-5f7c9d0e8a2c
X-Highlight-Event:     news.finalized
X-Highlight-Timestamp: 1745884817
X-Highlight-Signature: sha256=3b8a7f1d...

{
  "event":       "news.finalized",
  "deliveryId":  "4c3a1e7b-9a2b-4d6f-a1b3-5f7c9d0e8a2c",
  "attempt":     1,
  "occurredAt":  "2026-04-20T01:00:06Z",
  "data": {
    // Exactly the NewsEvent shape from GET /v1/news (see #model-news-event)
    "type":        "signing",
    "sport":       "nba",
    "impact":      "medium",
    "headline":    "Joe Smith signs 2-year deal with Lakers",
    "subheadline": "$12M total, second year non-guaranteed.",
    "source":      { "reporter": "Shams Charania", "handle": "ShamsCharania", "outlet": "ESPN" },
    "subjects":    [ { "kind": "player", "name": "Joe Smith", "teamTricode": "LAL" } ],
    "tweet":       { "id": "2045190996686680171" },
    "latencyMs":   6000
  }
}
FieldTypeDescription
eventstringEvent name: news.finalized, clips.finalized, cluster.upgraded, or ping.
deliveryIdstringUUID that stays stable across retries of the same delivery. Use it to dedupe if your receiver is idempotent.
attemptnumberSend attempt counter, starting at 1 and incrementing on every retry. The same deliveryId with attempt=2 means we re-sent after a failure or timeout.
occurredAtstringISO 8601 time the event finalized on our side (extractedAt for news, matchProcessedAt for clips, updatedAt for cluster upgrades). Use this as the cursor when catching up via the sinceExtracted / sinceProcessed REST params after a retry budget exhausts.
dataobjectPresent on news.finalized and clips.finalized. The same payload shape as the corresponding list endpoint. No shape drift between REST, SSE, and webhooks. Not present on cluster.upgraded: that envelope carries transition and cluster at the top level instead.

Verify the signature

Every delivery is signed with HMAC-SHA256 over the exact raw request body using the subscription's secret. Verify on every request before you trust the body. The secret is returned exactly once when you create the subscription, so store it immediately.

X-Highlight-Signature is formatted as sha256=<hex digest>. Do a constant-time compare against the value you compute, not a plain string equality.

// Node.js / Express. The raw body is required, not JSON-parsed output.
import crypto from 'node:crypto';
import express from 'express';

const app = express();

app.post(
  '/webhooks/highlightapi',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const expected = 'sha256=' + crypto
      .createHmac('sha256', process.env.HIGHLIGHT_WEBHOOK_SECRET)
      .update(req.body)
      .digest('hex');

    const received = req.headers['x-highlight-signature'] || '';
    const a = Buffer.from(expected);
    const b = Buffer.from(received);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).end();
    }

    const envelope = JSON.parse(req.body.toString());
    enqueue(envelope);       // 2xx fast; do real work async
    res.status(202).end();
  },
);
# Python / Flask
import hmac, hashlib, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ['HIGHLIGHT_WEBHOOK_SECRET'].encode()

@app.post('/webhooks/highlightapi')
def receive():
    expected = 'sha256=' + hmac.new(SECRET, request.data, hashlib.sha256).hexdigest()
    received = request.headers.get('X-Highlight-Signature', '')
    if not hmac.compare_digest(expected, received):
        abort(401)
    envelope = request.get_json(force=True)
    enqueue(envelope)
    return ('', 202)
!
Sign the raw bytes, not a re-serialized payload. JSON libraries reorder keys and change whitespace; running JSON.parse then JSON.stringify and HMACing the result will fail verification. Capture req.body as a Buffer / bytes before any parsing (use express.raw() in Node, or request.data in Flask) and pass that exact buffer to the HMAC.

Retry policy

We retry any non-2xx response, connection error, or timeout on an exponential schedule with 20% jitter. Seven attempts total over roughly 31 hours:

AttemptDelay after previous failureWall-clock (cumulative)
1(none)t = 0
230 st ≈ 30 s
32 mint ≈ 2 min 30 s
410 mint ≈ 12 min 30 s
51 ht ≈ 1 h 12 min
66 ht ≈ 7 h 12 min
724 ht ≈ 31 h 12 min

Each attempt has a 10-second total-request timeout (connect + response). If you return a Retry-After header (integer seconds or HTTP-date), we honor it as a floor. Your hint is used when it's larger than our next scheduled delay; our schedule is used when your hint is smaller. Pass Retry-After when you're in a deploy window or a downstream dependency is slow; leave it off otherwise.

After attempt 7 fails, the delivery is marked dead. Nothing else is sent for that event. To catch up, query the REST endpoint with the sinceExtracted / sinceProcessed cursor equal to the last delivered envelope's occurredAt; everything we tried to send during the outage comes back in delivery order.

i
Return 2xx fast. Do verification + a durable enqueue in the request handler, then acknowledge. Push the real work (database writes, downstream fan-out, push-notification dispatch) to a background worker. If you process synchronously and exceed 10 seconds, we treat the request as failed and retry, which can produce duplicate work even on successful receivers.

The cluster.upgraded event

Fires on every transition of a story cluster, including its creation (previous: nullcurrent: "single") and every ladder progression (single → multi, and the reserved team / official rungs once those handle sets come online). The envelope is lifted: transition and cluster are first-class fields, not nested under data.

POST https://yourapp.com/webhooks/highlightapi
Content-Type: application/json
X-Highlight-Event: cluster.upgraded

{
  "event":       "cluster.upgraded",
  "deliveryId":  "b9e4...",
  "attempt":     1,
  "occurredAt":  "2026-04-22T23:15:11Z",
  "transition": {
    "previous":          "single",
    "current":           "multi",
    "triggeringTweetId": "2045321...",
    "skippedRungs":      []
  },
  "cluster": {
    // Full StoryCluster envelope from GET /v1/news/clusters/:id.
    // Includes rolled-up payload + members[] with per-member structuredPayload.
  }
}

Filter grammar

cluster.upgraded exposes a richer filter set than the other events. The same filters object on your subscription applies; keys that don't apply to a given event type are ignored, so one subscription can safely subscribe to multiple event types without dropping events on the wrong ones.

Filter keyTypeMatches when
sportstringCluster's sport equals the value. Same semantics as on news.finalized.
typestringCluster's type equals the value (injury, trade, signing, …).
impactstringCluster's impact equals the value. Exact match, not a minimum.
transitionsstring[] | stringArray or single value drawn from "creation", "progression", "any". creation matches when transition.previous === null; progression matches when it isn't; any (or omitting the key) matches both.
toRungstring[] | stringAllowed values for transition.current: single, multi, team, official. Matches on the rung the transition landed on, not rungs passed through.
teamTricodestringCase-insensitive. Matches any teamTricode, fromTeam, or toTeam inside the cluster's rolled-up subjects[]. Useful for team-specific receivers.
breaking, category, gameId Ignored for cluster.upgraded.

Common filter recipes

// Only the "multi-source confirmed" moment. Perfect for a trader's Slack alert:
// one notification per story at the point a second Tier-A insider confirms.
{ "events": ["cluster.upgraded"],
  "filters": { "transitions": ["progression"], "toRung": ["multi"] } }

// Every new story the instant the first insider tweets it.
{ "events": ["cluster.upgraded"],
  "filters": { "transitions": ["creation"] } }

// NBA injury news, all rungs, team-filtered:
{ "events": ["cluster.upgraded"],
  "filters": { "sport": "nba", "type": "injury", "teamTricode": "LAL" } }
i
Creation fires exactly once per cluster. The first Tier-A tweet that creates a cluster emits transition.previous: null and transition.current: "single". Subsequent Tier-B echoes on the same story join members[] but do not fire another webhook unless they move the ladder. This matches the intuition that a second identical-content alert five seconds later isn't useful.

Create a subscription

Returns the subscription, including the HMAC secret exactly once. Save it to your secret store immediately. Subsequent GETs will not include it, and there is no "show me the secret again" endpoint. To rotate, delete the subscription and create a new one.

POST /v1/webhooks Auth required

Request body

FieldTypeDescription
url RequiredstringAbsolute HTTPS URL we'll POST to. http:// is rejected except on loopback for local testing (localhost, 127.0.0.1).
events Requiredstring[]Subset of ["news.finalized", "clips.finalized", "cluster.upgraded"]. At least one. The canonical list is also returned by GET /v1/webhooks/supported-events.
filters OptionalobjectAny combination of sport, breaking, category, gameId, type, impact, transitions, toRung, teamTricode. Filter keys that don't apply to an event type are ignored for that event (e.g. a gameId filter still lets every news.finalized event through; a transitions filter is only evaluated on cluster.upgraded). See the cluster.upgraded section for the full filter grammar on that event.
const res = await fetch('https://api.highlightapi.com/v1/webhooks', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'X-API-Key': key },
  body: JSON.stringify({
    url:     'https://yourapp.com/webhooks/highlightapi',
    events:  ['news.finalized', 'clips.finalized'],
    filters: { sport: 'nba', breaking: 'true' },
  }),
});
const sub = await res.json();
// sub.secret is shown once; store it now.

Response

{
  "id":        "ea3c9c4e-6c0a-4d1f-a0b2-9f8c6b1a5d34",
  "url":       "https://yourapp.com/webhooks/highlightapi",
  "events":    ["news.finalized", "clips.finalized"],
  "filters":   { "sport": "nba", "breaking": "true" },
  "active":    true,
  "createdAt": "2026-04-22 00:27:34",
  "updatedAt": "2026-04-22 00:27:34",
  "secret":    "5f2e1d3c7a9b4..."
}

List your subscriptions

Returns every subscription owned by the API key making the request. Secrets are never returned after creation.

GET /v1/webhooks Auth required
{
  "subscriptions": [
    {
      "id":        "ea3c9c4e-6c0a-4d1f-a0b2-9f8c6b1a5d34",
      "url":       "https://yourapp.com/webhooks/highlightapi",
      "events":    ["news.finalized", "clips.finalized"],
      "filters":   { "sport": "nba" },
      "active":    true,
      "createdAt": "2026-04-22 00:27:34",
      "updatedAt": "2026-04-22 00:27:34"
    }
  ]
}

Get one subscription

GET /v1/webhooks/:id Auth required

Returns the subscription without the secret. Cross-customer reads return 404 rather than 403 so we don't leak the existence of other tenants' subscriptions.

Update a subscription

PATCH /v1/webhooks/:id Auth required

Patch any subset of url, events, filters, active. Omitted fields are unchanged. Set active: false to pause delivery without deleting the subscription; flip back to true when you're ready. The secret is not patchable (delete and re-create to rotate).

await fetch(`https://api.highlightapi.com/v1/webhooks/${id}`, {
  method: 'PATCH',
  headers: { 'Content-Type': 'application/json', 'X-API-Key': key },
  body: JSON.stringify({ active: false }),          // pause delivery
});

Delete a subscription

DELETE /v1/webhooks/:id Auth required

Returns 204 on success. Cascades into the delivery history: pending retries for that subscription stop, and already-sent deliveries are purged from the listing endpoint.

Send a test delivery (ping)

POST /v1/webhooks/:id/ping Auth required

Synthesize one delivery so you can verify your receiver end-to-end (HTTPS reachability, signature verification, response timing) without waiting for a live event. The ping is queued with event: "ping" and sent by the delivery worker exactly like any other event (same signing, same retry schedule).

POST /v1/webhooks/ea3c9c4e-.../ping
    → 202 Accepted
{ "deliveryId": "7f4d3b1e-9c8a-4a9f-b2a0-1c5d7e9f2b43" }

// Then on your receiver:
POST /webhooks/highlightapi  { event: "ping", deliveryId: "7f4d3b1e-...", attempt: 1,
                               occurredAt: "...", data: { ok: true, subscriptionId: "..." } }

Inspect recent deliveries

GET /v1/webhooks/:id/deliveries Auth required

Returns the most recent delivery attempts for a subscription, newest first. Useful for debugging: confirming we fired, reading the response code we got back, and seeing how the retry schedule played out. Default limit=50, hard ceiling 200.

FieldTypeDescription
idstringThe deliveryId we sent as X-Highlight-Delivery.
eventstringnews.finalized / clips.finalized / cluster.upgraded / ping.
sourceIdstringThe underlying event id: tweetId for news / clips, cluster UUID for cluster.upgraded, null for pings.
statusstringpending (waiting to fire or in backoff), sending (in flight), delivered (2xx received), dead (retry budget exhausted).
attemptnumberHow many attempts have been made so far (0 before the first send).
maxAttemptsnumberTotal attempts allowed before dead-lettering. Defaults to 7.
lastResponseCodenumber|nullHTTP status from the most recent attempt. null on connection errors or timeouts.
lastErrorstring|nullShort description of the most recent failure (first 500 chars of the response body, or a fetch error like fetch: TimeoutError).
nextAttemptAtstringISO 8601 timestamp for the next scheduled attempt. In the past when status is sending or terminal.
deliveredAtstring|nullSet when status becomes delivered.
createdAtstringWhen we enqueued the delivery (right after the event finalized).

Supported events

EventFires whendata shape
news.finalizedExtractor writes a verdict on an insider tweet (either a structured NewsEvent or a skip).Same as GET /v1/news.
clips.finalizedOur matcher finishes processing a clip (done, high/medium/low match, or failed).Same as GET /v1/:sport/games/:gameId/clips.
pingYou called POST /v1/webhooks/:id/ping.{ "ok": true, "subscriptionId": "<id>" }.

The canonical list is also available at GET /v1/webhooks/supported-events so SDKs can introspect without hardcoding.

i
Private beta. Outgoing webhooks are live for design-partner integrations today; contact support@highlightapi.com if you want your API key enabled. The contract on this page is what's already running against our beta partners. We're not changing signing, envelope shape, or retry policy before GA.

Data models

Game

FieldTypeDescription
idstringOur deterministic game ID.
leagueGameIdstringThe league's official game ID. Format varies by sport (10-digit string for NBA, UUID or numeric ID for most others).
datestringYYYY-MM-DD (ET calendar date).
homeTeamstringHome team tricode (e.g. ATL).
awayTeamstringAway team tricode.
statusstringOne of scheduled, live, final.
startTimestringISO 8601 scheduled tip-off time (UTC).
broadcastersstring | nullJSON-encoded list of national broadcast partners for the game, or null if none are known. Sourced from the league's official schedule feed.

Clip

FieldTypeDescription
idnumberInternal row ID. Stable per clip but not meaningful across environments; prefer tweetId for cross-system references.
tweetIdstringX (Twitter) status ID. Unique across the system.
teamAccountstringX handle that posted the clip (without @).
teamIdstring | nullTeam tricode if posted by a team account; null for league/broadcast-partner clips.
gameIdstringThe game this clip belongs to.
postedAtstringISO 8601 post timestamp (UTC).
captionstringThe tweet's text content.
embedUrlstringCanonical X URL for the tweet. Use this with Twitter's oEmbed or widgets.js.
thumbnailUrlstring | nullStatic image you can show as a poster frame before the embed hydrates. null if no thumbnail was available at ingest time.
gameClockstring | nullGame clock parsed from the caption. Often null; use matchedPlay.clock for the authoritative value.
playTypestring[]Play types extracted from the caption. See Play types.
playersMentionedstring[]Player names extracted from the caption.
quarternumber | nullQuarter extracted from the caption. Often null; rely on matchedPlay.period when you need the authoritative value.
clipTypestringOne of single_play, montage, interview, stats, pregame, other.
matchStatusstringpending, processing, done, failed, or skipped (non-play content).
matchConfidencestring | nullhigh, medium, low, or null if unmatched.
matchedPlayMatchedPlay | nullThe play this clip was matched to. See below.

MatchedPlay

A subset of the official play-by-play event, stripped to client-useful fields.

FieldTypeDescription
timeActualstringISO 8601 wall-clock timestamp of when the play occurred.
periodnumberQuarter number (1-4, 5+ for overtime).
clockstringISO 8601 duration on the game clock (e.g. PT04M12.00S = 4:12).
actionTypestringThe play-by-play action type, normalized across sports. NBA example values: 2pt, 3pt, freethrow, rebound, turnover, block, steal, foul. NFL adds touchdown, field_goal, interception; football adds goal, assist, save; and so on.
subTypestring | nullSpecific action variant (e.g. DUNK, Jump Shot, Layup, Defensive).
playerstringPlay-maker's name (e.g. O. Anunoby).
teamTricodestringTeam of the play-maker.
descriptionstringThe official play description straight from the league feed.
scoreHomestringHome score after the play. Strings (not numbers) because that is what upstream play-by-play feeds return.
scoreAwaystringAway score after the play.
assistPlayerNameInitialstring | nullAssisting player, if any.

NewsEvent

The payload returned by GET /v1/news. Every event has the same top-level envelope regardless of its type, plus an optional detail block (contract, injury, tenure) that depends on the event type. Clients can safely do switch (event.type) without traversing nested objects to find the classification.

FieldTypeDescription
idstringStable unique ID. Format: news_<tweetId>.
typestringEvent classification: trade, extension, signing, injury, firing, hiring, suspension, retirement, release, or other.
sportstringPrimary league: nba, nfl, mlb, nhl, soccer.
impactstringImpact tier: low, medium, high, record.
postedAtstringISO 8601 timestamp of the originating tweet.
latencyMsnumberMilliseconds from tweet posted to event finalized in our pipeline. Typically 6,000 to 15,000 (ingest P99 is ~6-7s and structured extraction adds 1-5s). The streaming endpoint delivers each event the moment this latency clock stops; the REST endpoint adds your polling interval on top of that.
headlinestringShort, punchy one-line summary. Designed to drop directly into a push notification title or feed card header. Typically under 60 characters.
subheadlinestringSupporting one-liner with the key stat or context. Pairs with headline to form a push notification body or a two-line card preview. Example: "Axillary artery surgery, likely out for 2026 season."
sourceobjectThe reporter: { reporter, handle, outlet }. handle is the X handle without @.
subjectsobject[]Array of people or teams the event is about. Same shape regardless of event type; see below.
contractobject | absentPresent for extension and signing events. See below.
injuryobject | absentPresent for injury events. See below.
tenureobject | absentPresent for firing, hiring, and retirement events. See below.
classifierReasonstringOne-line reasoning for why this event was classified the way it was. Useful for debugging and for showing your own users why an event surfaced.
clusterobject | nullStub pointer to the StoryCluster this event belongs to. Shape: { id, confidence, reporterCount }. null for rows the extractor skipped (the row was never clustered) and legacy pre-clusters rows. Fetch the full envelope with GET /v1/news/clusters/:id.
tweetobject{ id, url } for rendering the source tweet as an official X embed.

Subject

An entry in the subjects[] array. Fields not relevant to the kind are omitted.

FieldTypeDescription
kindstringplayer, coach, executive, or team.
namestringFull name.
positionstring | absentPosition for player subjects (e.g. DE, RP, PG).
rolestring | absentRole for coach or executive (e.g. head coach, general manager).
teamTricodestring | absentCurrent team for the subject in this event. For trades, use fromTeam / toTeam instead.
fromTeamstring | absentTrade-only: team the subject is leaving.
toTeamstring | absentTrade-only: team the subject is joining.

Contract block (extension, signing)

FieldTypeDescription
yearsnumberContract length in years.
totalValueUsdnumberTotal reported value in USD.
guaranteedUsdnumber | nullGuaranteed money. null when not reported.
avgPerYearUsdnumberAAV, computed when not explicitly reported.
noTradeClauseboolean | nullNFL- and NBA-specific. null when not mentioned.

Injury block

FieldTypeDescription
statusstringOne of surgery, il, out, questionable, day-to-day.
bodyPartstringInjury description (e.g. torn ACL, right knee).
estimatedReturnMonthsnumber | nullMonths until projected return. null when unreported.
seasonEndingbooleanWhether the injury is likely to end the subject's current season.

Tenure block (firing, hiring, retirement)

FieldTypeDescription
seasonsnumberSeasons with the team.
recordstringWin-loss record as a string (e.g. 97-103).
playoffsstringShort summary of playoff results during the tenure.

StoryCluster

The payload returned by GET /v1/news/clusters and GET /v1/news/clusters/:id, and wrapped inside cluster.upgraded webhooks. One cluster represents one story; its members are the individual insider tweets about that story, each of which is itself a NewsEvent.

FieldTypeDescription
idstringUUID v4. Stable for the life of the cluster.
canonicalKeystringThe deterministic key used for membership: ${type}:${sport}:${normalized_last_name}. Exposed for debugging; do not parse it, the algorithm can change (see keyVersion).
keyVersionnumberVersion of the canonical-key algorithm that produced this cluster. Bumped when we change normalization rules. Old clusters are re-keyed on bump, but keep their original IDs.
sportstringnba, nfl, mlb, nhl, soccer.
typestringEvent classification. Same enum as NewsEvent.
impactstringHighest impact tier across all members. low, medium, high, record.
confidencestringCurrent rung on the ladder: single, multi, team, official. Only ever moves forward.
headline, subheadline, subjects, contract, injury, tenure Rolled-up NewsEvent shape merged across all members. Headline and subheadline stick to the first member. Structured fields take the richer / more-official value across members.
membersClusterMember[]Every insider tweet in this cluster, oldest first. See below.
reportersClusterReporter[]Distinct reporters in this cluster with their tier and weight. See below.
firstMemberPostedAtstringISO 8601. postedAt of the first member tweet. Does not change once the cluster exists.
updatedAtstringISO 8601. Bumped on every new member and every ladder transition. This is the cursor used by the SSE replay (Last-Event-ID) and the since query param.
contradictionsobject | nullReserved. Always null today. Populated by a future branch that detects when a later tweet retracts or contradicts an earlier one in the cluster.
disputedByobject | nullReserved. Always null today. Paired with contradictions; will list the members whose payload disagrees with the rolled-up view.

ClusterMember

One entry in members[]. Every member is one insider tweet that hashed to this cluster's canonical key.

FieldTypeDescription
tweetIdstringX tweet ID. Matches the tweet.id on the corresponding NewsEvent.
handlestringX handle of the reporter (no @).
postedAtstringISO 8601. When the tweet was posted on X.
extractedAtstringISO 8601. When our extractor finalized this member's structured payload.
ingestSourcestringx-stream, apify, or apify-redundancy. Rarely needed in production; useful when reconciling which ingest path caught the tweet first.
embedUrlstringCanonical https://x.com/<handle>/status/<tweetId> URL for the official X embed.
reporterobject | null{ name, outlet, tier, weight }. Tier "A" (weight 1.0) reporters advance the ladder; Tier "B" (weight 0.3) do not.
structuredPayloadobjectThe full NewsEvent shape extracted from this specific tweet. Use it if you want to implement your own dispute / contradiction logic client-side: compare members' payloads against each other directly.

ClusterReporter

Distinct reporters on the cluster, in the order they first confirmed.

FieldTypeDescription
handlestringX handle (no @).
tierstringA or B.
weightnumber1.0 for Tier A, 0.3 for Tier B. Only weight >= 1.0 counts toward the multi rung.
firstConfirmedAtstringISO 8601 of this reporter's first member tweet in the cluster.

ClusterTransition

Attached to every SSE frame and cluster.upgraded webhook. On SSE replay frames it is null.

FieldTypeDescription
previousstring | nullnull on cluster creation. Otherwise the rung the cluster was at before this transition.
currentstringThe rung the cluster is at after this transition.
triggeringTweetIdstringThe tweet whose arrival caused the transition.
skippedRungsstring[]Rungs skipped over (e.g. a jump from single straight to team would carry ["multi"]). toRung filters match on current, not on rungs the cluster passed through.

Displaying clips

Every clip we return is an official X (Twitter) embed. We do not host or redistribute the video files ourselves. This is both a legal requirement and the mechanism that attributes the clip to its owner (team, league, or broadcast partner). Use the embedUrl or tweetId to render the embed in your app.

Web (recommended)

Use Twitter's official embedded tweets (widgets.js) or oEmbed API.

<!-- Include widgets.js once in your app -->
<script async src="https://platform.twitter.com/widgets.js"></script>

<!-- For each clip, inject a blockquote -->
<blockquote class="twitter-tweet" data-media-max-width="560">
  <a href="https://x.com/nyknicks/status/2041319802384347608"></a>
</blockquote>

Mobile (iOS & Android)

On native, render the embed in a WKWebView / WebView pointing at https://publish.twitter.com/oembed?url={embedUrl}, or use Twitter's TwitterKit-compatible libraries. Deep-link to the X app on tap for the full experience.

!
Do not download or re-host the video files. Clips are the property of the posting account (teams, leagues, or broadcast partners). Our terms of service require that all playback goes through official X embeds.

Embedding at scale: one embed at a time

Twitter's embed infrastructure rate-limits hydration when many tweets render on the same page in the same render-burst. If you exceed this limit, users see a "The media could not be played" error inside the embed.

Recommended integration pattern:

  • Render at most one active Twitter embed in a given view at a time. When the user taps a new clip, destroy the previous embed before hydrating the new one.
  • For clip feeds, show play cards (metadata only: player, score, timestamp) and hydrate the embed only when a card is expanded or enters the main viewport.
  • If you must show multiple embeds on one screen, stagger hydration with a 300-500ms delay between each.
  • Lazy-load embeds with an IntersectionObserver rather than rendering all of them on page load.
i
Most sports apps naturally follow this pattern since users view one play detail at a time. The live feed on our home page demo uses the single-active-embed strategy and you can see it in action.

API rate limits

Rate limits are enforced per API key. Contact us if your integration needs higher limits.

TierRequests / minuteBurst
Development60120
Production6001,200
EnterpriseCustomCustom

When you exceed the limit, the API returns 429 Too Many Requests with a Retry-After header indicating when to retry.

Error handling

Errors are returned as JSON with an error field describing what went wrong. HTTP status codes follow conventional REST semantics.

200 OK
Success
Request succeeded. Response body contains the requested data.
400 Bad Request
Invalid parameters
Missing or malformed query/body parameters. Check the error field.
401 Unauthorized
Missing or invalid key
Include a valid X-API-Key header.
404 Not Found
Resource not found
Unknown game ID, tweet ID, or route.
429 Too Many Requests
Rate limit exceeded
Back off using the Retry-After header.
5xx Server Error
Upstream or internal
Retry with exponential backoff. Report if persistent.

Error response shape

{
  "error": "date query parameter is required (YYYY-MM-DD)"
}

Play types

When calling POST /v1/:sport/clips/match, use one of these canonical play type strings for best match quality. Play types also appear in the playType array on each clip.

The list below covers NBA. Each supported sport has its own canonical vocabulary (e.g. NFL uses touchdown, field_goal, sack, interception; football uses goal, assist, save, yellow_card, red_card; MLB uses home_run, strikeout, double_play). See the changelog or contact us for the full per-sport list.

dunk
Any dunk or slam.
alley_oop
Lob-to-dunk sequence.
three_pointer
Made 3-point shot.
layup
Layup, finger roll, reverse.
jumper
Mid-range jump shot.
hook_shot
Hook or sky hook.
floater
Floater or teardrop.
fadeaway
Fadeaway jumper.
block
Shot block or rejection.
steal
Steal or deflection-to-turnover.
rebound
Offensive or defensive board.
assist
Passing play leading to a score.

Additional types: putback, tip_in, and_one, four_point_play, buzzer_beater, game_winner, free_throw, turnover, foul, poster, chase_down_block, crossover, ankle_breaker, no_look, behind_the_back, and more. Contact us for the full list.

Confidence levels

Every matched clip has a matchConfidence field set by our model after analyzing the video. The match endpoint also returns a numeric confidence from 0.00 to 1.00.

Nearly every clip we serve is high confidence. We thoroughly backtest every match, and 100% of our high-confidence clips are accurate. Roughly 95% of matched clips fall into the high bucket, so for most production surfaces you can safely filter on matchConfidence === "high" and ship without any further qualification.

LevelNumeric rangeMeaning
high 0.85 to 1.00 Unambiguous visual and audio evidence. ~95% of our matches land here, and we thoroughly backtest every one: 100% of high-confidence clips are accurate. Safe to display without qualification.
medium 0.65 to 0.84 Strong match with some ambiguity (for example, two similar plays in the same window). Recommended for display.
low 0.40 to 0.64 Best guess among the candidates. Consider hiding from production feeds or marking it as a "possible match".

The list-clips endpoint returns matches at all three levels when status=matched. Use ?status=all to also see clips the model processed without a confident match, or clips that were skipped as non-play content (interviews, promos, stats cards).

The POST /v1/:sport/clips/match endpoint applies stricter numeric cutoffs. Model-sourced matches return at confidence >= 0.20, and the heuristic fallback returns at confidence >= 0.30. Below those floors, match is null and you still get the top candidates for inspection.

Changelog

DateChange
2026-04-23Added story clusters and the confidence ladder (single → multi → team → official). Every extracted insider tweet is now grouped with prior tweets about the same story and surfaced via GET /v1/news/clusters, /v1/news/clusters/stream (SSE), and the new cluster.upgraded webhook event. GET /v1/news rows now carry a cluster: { id, confidence, reporterCount } stub. Webhook filters gained transitions, toRung, type, impact, and teamTricode for subscribers who want to wake up only on multi-source confirmation.
2026-04-22Added outgoing webhooks (private beta) as a third delivery mode alongside REST polling and SSE. One subscription covers both news.finalized and clips.finalized; deliveries are HMAC-SHA256 signed and retry on a 7-attempt / ~31-hour jittered schedule. Added sinceExtracted to GET /v1/news and sinceProcessed to GET /v1/:sport/games/:gameId/clips as the sanctioned catchup path for retry-exhausted webhooks.
2026-04-20Added Server-Sent Events streaming for both feeds: GET /v1/news/stream and GET /v1/nba/games/:gameId/clips/stream. Each event is pushed the moment our pipeline finalizes the row, replacing the polling round-trip. Auth now also accepts ?apiKey= as a query param for browser EventSource clients that can't attach custom headers.
2026-04-18Added GET /v1/news with per-reporter tiered polling (top breakers at 60s), a classified breaking filter, and structured NewsEvent payloads (contract, injury, tenure detail blocks keyed off top-level type).
2026-04-14Generalized endpoints under /v1/:sport/. NFL, MLB, NHL, and top-five European football leagues available.
2026-04-09Added /api/oembed proxy for server-side Twitter embed caching. Documented single-embed rendering guidance.
2026-04-08Added status=all filter on the clips endpoint.
2026-04-05Added GET /v1/:sport/games/by-league-id/:leagueGameId for provider-ID integrations.
2026-04-01Expanded broadcast partner scraping across every supported league.
2026-03-24Initial public beta: v1 endpoints live.