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.
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 Associationnfl: National Football Leaguemlb: Major League Baseballnhl: National Hockey Leagueepl: English Premier Leagueucl: UEFA Champions Leaguelaliga,bundesliga,seriea,ligue1: top-five European footballpga: PGA Tourmls,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 ourhigh-confidence clips are accurate. Roughly 95% of matched clips come back athigh. - Live polling support via a
sincecursor 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 unifiedsubjects[]array, a type-specific detail block (contract,injury,tenure), and a ready-to-renderheadline+subheadlinepair 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.
# 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"
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.
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.
?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:
- Scrape. We poll every team, league, and active broadcast-partner account on X every couple of minutes.
- Resolve game. Each clip is tagged with a
gameIdbased on the posting account and timestamp (for team posts) or caption and broadcaster schedule (for league and partner posts). - 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.
- Match and store. The model returns the exact play with a confidence level, which we store on the clip row.
- Serve. When you query the API, we return the pre-matched clip. No per-request AI cost.
Game IDs
Every game has two IDs you can use to reference it:
| Field | Format | Description |
|---|---|---|
| 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.
Query parameters
| Parameter | Type | Description |
|---|---|---|
| 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.
Path parameters
| Parameter | Type | Description |
|---|---|---|
| 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.
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"
}
}
| Field | Description |
|---|---|
| totalClips | Every clip ingested for this game, regardless of match status. |
| matchedClips | Clips with a model match at high or medium confidence. These are the ones most apps will want to surface. |
| processedClips | Clips that have finished model processing (matchStatus = 'done'). Includes matched clips and any that came back without a confident match. |
| pendingClips | Clips still queued or in flight (matchStatus of pending or processing). Poll again in 30 to 60 seconds. |
| latestClipAt | Timestamp 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.
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.
Path parameters
| Parameter | Type | Description |
|---|---|---|
| gameId Required | string | Our game ID (e.g. nba-2026-04-06-nyk-atl). |
Query parameters
| Parameter | Type | Description |
|---|---|---|
| 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"
}
}
]
}
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.
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.
Request body
| Field | Type | Description |
|---|---|---|
| gameId Required | string | Our game ID. |
| wallClockTime Required | string | ISO 8601 timestamp of when the play occurred in real time. |
| playType Optional | string | One of our supported play types (see Play types). Improves match quality. |
| players Optional | string[] | Player names involved in the play. Full or last name is fine. |
| description Optional | string | The raw play-by-play description. Helps disambiguation. |
| team Optional | string | Team tricode or short code of the play-maker (e.g. NYK, NE, LIV). |
| period Optional | number | Game period (quarter, half, inning, or set depending on the sport). |
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 */ ]
}
| Field | Description |
|---|---|
| match | The best matching clip, or null if no clip met the minimum threshold. |
| confidence | Numeric confidence score from 0.00 to 1.00. See Confidence levels. |
| source | model 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. |
| candidates | Up 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.
Query parameters
| Parameter | Type | Description |
|---|---|---|
| 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.
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.
Response
{
"count": 26,
"sources": [
{
"handle": "ShamsCharania",
"name": "Shams Charania",
"outlet": "ESPN",
"sport": "nba"
}
]
}
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.
| Rung | Meaning | Lights up when |
|---|---|---|
| single | One 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. |
| multi | Two 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. |
| team | The team's official PR account posted it. | Reserved. Lights up automatically as team-PR handles come online; wire your subscriber for it today. |
| official | The 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. |
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[].
Query parameters
| Parameter | Type | Description |
|---|---|---|
| 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
}
]
}
/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.
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."
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.
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
}
}
| Field | Type | Description |
|---|---|---|
| event | string | Event name: news.finalized, clips.finalized, cluster.upgraded, or ping. |
| deliveryId | string | UUID that stays stable across retries of the same delivery. Use it to dedupe if your receiver is idempotent. |
| attempt | number | Send 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. |
| occurredAt | string | ISO 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. |
| data | object | Present 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)
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:
| Attempt | Delay after previous failure | Wall-clock (cumulative) |
|---|---|---|
| 1 | (none) | t = 0 |
| 2 | 30 s | t ≈ 30 s |
| 3 | 2 min | t ≈ 2 min 30 s |
| 4 | 10 min | t ≈ 12 min 30 s |
| 5 | 1 h | t ≈ 1 h 12 min |
| 6 | 6 h | t ≈ 7 h 12 min |
| 7 | 24 h | t ≈ 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.
The cluster.upgraded event
Fires on every transition of a story cluster, including its creation (previous: null → current: "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 key | Type | Matches when |
|---|---|---|
| sport | string | Cluster's sport equals the value. Same semantics as on news.finalized. |
| type | string | Cluster's type equals the value (injury, trade, signing, …). |
| impact | string | Cluster's impact equals the value. Exact match, not a minimum. |
| transitions | string[] | string | Array 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. |
| toRung | string[] | string | Allowed values for transition.current: single, multi, team, official. Matches on the rung the transition landed on, not rungs passed through. |
| teamTricode | string | Case-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" } }
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.
Request body
| Field | Type | Description |
|---|---|---|
| url Required | string | Absolute HTTPS URL we'll POST to. http:// is rejected except on loopback for local testing (localhost, 127.0.0.1). |
| events Required | string[] | Subset of ["news.finalized", "clips.finalized", "cluster.upgraded"]. At least one. The canonical list is also returned by GET /v1/webhooks/supported-events. |
| filters Optional | object | Any 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.
{
"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
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 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
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)
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
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.
| Field | Type | Description |
|---|---|---|
| id | string | The deliveryId we sent as X-Highlight-Delivery. |
| event | string | news.finalized / clips.finalized / cluster.upgraded / ping. |
| sourceId | string | The underlying event id: tweetId for news / clips, cluster UUID for cluster.upgraded, null for pings. |
| status | string | pending (waiting to fire or in backoff), sending (in flight), delivered (2xx received), dead (retry budget exhausted). |
| attempt | number | How many attempts have been made so far (0 before the first send). |
| maxAttempts | number | Total attempts allowed before dead-lettering. Defaults to 7. |
| lastResponseCode | number|null | HTTP status from the most recent attempt. null on connection errors or timeouts. |
| lastError | string|null | Short description of the most recent failure (first 500 chars of the response body, or a fetch error like fetch: TimeoutError). |
| nextAttemptAt | string | ISO 8601 timestamp for the next scheduled attempt. In the past when status is sending or terminal. |
| deliveredAt | string|null | Set when status becomes delivered. |
| createdAt | string | When we enqueued the delivery (right after the event finalized). |
Supported events
| Event | Fires when | data shape |
|---|---|---|
| news.finalized | Extractor writes a verdict on an insider tweet (either a structured NewsEvent or a skip). | Same as GET /v1/news. |
| clips.finalized | Our matcher finishes processing a clip (done, high/medium/low match, or failed). | Same as GET /v1/:sport/games/:gameId/clips. |
| ping | You 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.
Data models
Game
| Field | Type | Description |
|---|---|---|
| id | string | Our deterministic game ID. |
| leagueGameId | string | The league's official game ID. Format varies by sport (10-digit string for NBA, UUID or numeric ID for most others). |
| date | string | YYYY-MM-DD (ET calendar date). |
| homeTeam | string | Home team tricode (e.g. ATL). |
| awayTeam | string | Away team tricode. |
| status | string | One of scheduled, live, final. |
| startTime | string | ISO 8601 scheduled tip-off time (UTC). |
| broadcasters | string | null | JSON-encoded list of national broadcast partners for the game, or null if none are known. Sourced from the league's official schedule feed. |
Clip
| Field | Type | Description |
|---|---|---|
| id | number | Internal row ID. Stable per clip but not meaningful across environments; prefer tweetId for cross-system references. |
| tweetId | string | X (Twitter) status ID. Unique across the system. |
| teamAccount | string | X handle that posted the clip (without @). |
| teamId | string | null | Team tricode if posted by a team account; null for league/broadcast-partner clips. |
| gameId | string | The game this clip belongs to. |
| postedAt | string | ISO 8601 post timestamp (UTC). |
| caption | string | The tweet's text content. |
| embedUrl | string | Canonical X URL for the tweet. Use this with Twitter's oEmbed or widgets.js. |
| thumbnailUrl | string | null | Static image you can show as a poster frame before the embed hydrates. null if no thumbnail was available at ingest time. |
| gameClock | string | null | Game clock parsed from the caption. Often null; use matchedPlay.clock for the authoritative value. |
| playType | string[] | Play types extracted from the caption. See Play types. |
| playersMentioned | string[] | Player names extracted from the caption. |
| quarter | number | null | Quarter extracted from the caption. Often null; rely on matchedPlay.period when you need the authoritative value. |
| clipType | string | One of single_play, montage, interview, stats, pregame, other. |
| matchStatus | string | pending, processing, done, failed, or skipped (non-play content). |
| matchConfidence | string | null | high, medium, low, or null if unmatched. |
| matchedPlay | MatchedPlay | null | The play this clip was matched to. See below. |
MatchedPlay
A subset of the official play-by-play event, stripped to client-useful fields.
| Field | Type | Description |
|---|---|---|
| timeActual | string | ISO 8601 wall-clock timestamp of when the play occurred. |
| period | number | Quarter number (1-4, 5+ for overtime). |
| clock | string | ISO 8601 duration on the game clock (e.g. PT04M12.00S = 4:12). |
| actionType | string | The 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. |
| subType | string | null | Specific action variant (e.g. DUNK, Jump Shot, Layup, Defensive). |
| player | string | Play-maker's name (e.g. O. Anunoby). |
| teamTricode | string | Team of the play-maker. |
| description | string | The official play description straight from the league feed. |
| scoreHome | string | Home score after the play. Strings (not numbers) because that is what upstream play-by-play feeds return. |
| scoreAway | string | Away score after the play. |
| assistPlayerNameInitial | string | null | Assisting 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.
| Field | Type | Description |
|---|---|---|
| id | string | Stable unique ID. Format: news_<tweetId>. |
| type | string | Event classification: trade, extension, signing, injury, firing, hiring, suspension, retirement, release, or other. |
| sport | string | Primary league: nba, nfl, mlb, nhl, soccer. |
| impact | string | Impact tier: low, medium, high, record. |
| postedAt | string | ISO 8601 timestamp of the originating tweet. |
| latencyMs | number | Milliseconds 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. |
| headline | string | Short, punchy one-line summary. Designed to drop directly into a push notification title or feed card header. Typically under 60 characters. |
| subheadline | string | Supporting 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." |
| source | object | The reporter: { reporter, handle, outlet }. handle is the X handle without @. |
| subjects | object[] | Array of people or teams the event is about. Same shape regardless of event type; see below. |
| contract | object | absent | Present for extension and signing events. See below. |
| injury | object | absent | Present for injury events. See below. |
| tenure | object | absent | Present for firing, hiring, and retirement events. See below. |
| classifierReason | string | One-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. |
| cluster | object | null | Stub 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. |
| tweet | object | { 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.
| Field | Type | Description |
|---|---|---|
| kind | string | player, coach, executive, or team. |
| name | string | Full name. |
| position | string | absent | Position for player subjects (e.g. DE, RP, PG). |
| role | string | absent | Role for coach or executive (e.g. head coach, general manager). |
| teamTricode | string | absent | Current team for the subject in this event. For trades, use fromTeam / toTeam instead. |
| fromTeam | string | absent | Trade-only: team the subject is leaving. |
| toTeam | string | absent | Trade-only: team the subject is joining. |
Contract block (extension, signing)
| Field | Type | Description |
|---|---|---|
| years | number | Contract length in years. |
| totalValueUsd | number | Total reported value in USD. |
| guaranteedUsd | number | null | Guaranteed money. null when not reported. |
| avgPerYearUsd | number | AAV, computed when not explicitly reported. |
| noTradeClause | boolean | null | NFL- and NBA-specific. null when not mentioned. |
Injury block
| Field | Type | Description |
|---|---|---|
| status | string | One of surgery, il, out, questionable, day-to-day. |
| bodyPart | string | Injury description (e.g. torn ACL, right knee). |
| estimatedReturnMonths | number | null | Months until projected return. null when unreported. |
| seasonEnding | boolean | Whether the injury is likely to end the subject's current season. |
Tenure block (firing, hiring, retirement)
| Field | Type | Description |
|---|---|---|
| seasons | number | Seasons with the team. |
| record | string | Win-loss record as a string (e.g. 97-103). |
| playoffs | string | Short 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.
| Field | Type | Description |
|---|---|---|
| id | string | UUID v4. Stable for the life of the cluster. |
| canonicalKey | string | The deterministic key used for membership: ${type}:${sport}:${normalized_last_name}. Exposed for debugging; do not parse it, the algorithm can change (see keyVersion). |
| keyVersion | number | Version 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. |
| sport | string | nba, nfl, mlb, nhl, soccer. |
| type | string | Event classification. Same enum as NewsEvent. |
| impact | string | Highest impact tier across all members. low, medium, high, record. |
| confidence | string | Current 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. | |
| members | ClusterMember[] | Every insider tweet in this cluster, oldest first. See below. |
| reporters | ClusterReporter[] | Distinct reporters in this cluster with their tier and weight. See below. |
| firstMemberPostedAt | string | ISO 8601. postedAt of the first member tweet. Does not change once the cluster exists. |
| updatedAt | string | ISO 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. |
| contradictions | object | null | Reserved. Always null today. Populated by a future branch that detects when a later tweet retracts or contradicts an earlier one in the cluster. |
| disputedBy | object | null | Reserved. 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.
| Field | Type | Description |
|---|---|---|
| tweetId | string | X tweet ID. Matches the tweet.id on the corresponding NewsEvent. |
| handle | string | X handle of the reporter (no @). |
| postedAt | string | ISO 8601. When the tweet was posted on X. |
| extractedAt | string | ISO 8601. When our extractor finalized this member's structured payload. |
| ingestSource | string | x-stream, apify, or apify-redundancy. Rarely needed in production; useful when reconciling which ingest path caught the tweet first. |
| embedUrl | string | Canonical https://x.com/<handle>/status/<tweetId> URL for the official X embed. |
| reporter | object | null | { name, outlet, tier, weight }. Tier "A" (weight 1.0) reporters advance the ladder; Tier "B" (weight 0.3) do not. |
| structuredPayload | object | The 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.
| Field | Type | Description |
|---|---|---|
| handle | string | X handle (no @). |
| tier | string | A or B. |
| weight | number | 1.0 for Tier A, 0.3 for Tier B. Only weight >= 1.0 counts toward the multi rung. |
| firstConfirmedAt | string | ISO 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.
| Field | Type | Description |
|---|---|---|
| previous | string | null | null on cluster creation. Otherwise the rung the cluster was at before this transition. |
| current | string | The rung the cluster is at after this transition. |
| triggeringTweetId | string | The tweet whose arrival caused the transition. |
| skippedRungs | string[] | 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.
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
IntersectionObserverrather than rendering all of them on page load.
API rate limits
Rate limits are enforced per API key. Contact us if your integration needs higher limits.
| Tier | Requests / minute | Burst |
|---|---|---|
| Development | 60 | 120 |
| Production | 600 | 1,200 |
| Enterprise | Custom | Custom |
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.
error field.X-API-Key header.Retry-After header.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.
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.
| Level | Numeric range | Meaning |
|---|---|---|
| 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
| Date | Change |
|---|---|
| 2026-04-23 | Added 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-22 | Added 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-20 | Added 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-18 | Added 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-14 | Generalized endpoints under /v1/:sport/. NFL, MLB, NHL, and top-five European football leagues available. |
| 2026-04-09 | Added /api/oembed proxy for server-side Twitter embed caching. Documented single-embed rendering guidance. |
| 2026-04-08 | Added status=all filter on the clips endpoint. |
| 2026-04-05 | Added GET /v1/:sport/games/by-league-id/:leagueGameId for provider-ID integrations. |
| 2026-04-01 | Expanded broadcast partner scraping across every supported league. |
| 2026-03-24 | Initial public beta: v1 endpoints live. |