Sports Clips API Documentation
Match NBA play-by-play events to highlight video clips in real time. Every NBA play, paired with the clip that shows it โ delivered over a simple REST API.
Overview
Sports Clips ingests highlight videos from NBA team, league, and broadcast-partner social accounts and pairs each one with the exact play it shows in the official NBA play-by-play. You call our API with a play event โ or list all clips for a game โ and get back a matched clip with a confidence score.
Under the hood we scrape clips from 30+ X accounts (every NBA team plus @NBA, @NBAonNBC, @NBATV and other broadcasters), feed each video to Gemini 2.5 Flash along with the candidate plays from that game's play-by-play, and store the structured match. By the time your app asks for a clip, we've already done the matching โ the lookup is a single indexed SQL query.
Who this is for
- Sports apps with a live game feed โ ESPN, DraftKings, Real Sports, Underdog, PrizePicks, Yahoo Sports. You have the play-by-play, we give you the video for it.
- Fantasy & betting platforms โ surface highlights inline with player stats, live-bet outcomes, or stat projections.
- Editorial & recap tools โ auto-generate highlight reels from a list of plays.
What you get
- A tweet ID and embed URL for every matched clip (you display 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. - Live polling support via a
sincecursor โ ideal for in-progress games.
Quickstart
Get your first matched clip in under 60 seconds. This example finds every matched highlight for last night's Knicks game.
# 1. List games for a date
curl https://sports-clips.fly.dev/v1/nba/games?date=2026-04-06 \
-H "X-API-Key: YOUR_API_KEY"
# 2. Fetch matched clips for a game
curl https://sports-clips.fly.dev/v1/nba/games/nba-2026-04-06-nyk-atl/clips \
-H "X-API-Key: YOUR_API_KEY"
const res = await fetch(
'https://sports-clips.fly.dev/v1/nba/games/nba-2026-04-06-nyk-atl/clips',
{ headers: { 'X-API-Key': process.env.SPORTS_CLIPS_KEY } }
);
const { clips } = await res.json();
for (const clip of clips) {
console.log(clip.matchedPlay.description, 'โ', clip.embedUrl);
}
Authentication
All production endpoints under /v1/nba/* 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...
Requests with a missing or invalid key return 401 Unauthorized.
Base URL & versioning
All API calls are made over HTTPS to:
https://sports-clips.fly.dev
The current API version is v1, included in the path. Breaking changes โ if any โ will ship under a new version prefix. Additive changes (new fields, new optional parameters) may be made to v1 without notice; clients should ignore unknown fields.
How matching works
The matching pipeline is fully pre-computed so that API calls return in single-digit milliseconds. Here's what happens between a player hitting a shot and a clip landing in your app:
- Scrape โ We poll every NBA team, the league account, and active broadcast-partner accounts on X every 2 minutes via Apify.
- Resolve game โ Each clip is tagged with a
gameIdbased on the posting account and timestamp (for team posts) or caption + broadcaster schedule (for league/partner posts). - Download & analyze โ A background worker pulls the video and sends it to Gemini 2.5 Flash along with candidate plays from the game's play-by-play (narrowed to a ยฑ15 min window around the tweet's post time).
- Match & store โ Gemini returns the exact play with a confidence level, which we store on the clip row in SQLite.
- Serve โ When you query the API, we return the pre-matched clip directly. 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: nba-{date}-{away}-{home} in lowercase (e.g. nba-2026-04-06-nyk-atl). Stable and human-readable. |
| nbaGameId | string | NBA's official 10-digit game ID (e.g. 0022501178). Useful when integrating with Sportradar, NBA Stats, or other providers that use this format. |
If your system already has NBA game IDs, use GET /v1/nba/games/by-nba-id/:nbaGameId to translate to our ID.
Integration patterns
Live game polling (recommended)
The most common integration. Poll the clips endpoint for a game every 30 seconds with a since cursor to get new clips as they're matched.
let lastSeen = '2026-04-06T23:00:00Z';
setInterval(async () => {
const url = `/v1/nba/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 โ e.g. "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', {
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 in your database against the matchedPlay.timeActual and matchedPlay.player fields. This is far cheaper than per-play lookups.
List games
Returns all NBA games 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",
"nbaGameId": "0022501178",
"date": "2026-04-06",
"homeTeam": "ATL",
"awayTeam": "NYK",
"status": "final",
"startTime": "2026-04-06T23:30:00Z"
}
]
}
Lookup game by NBA ID
Look up a game using NBA's official 10-digit game ID. Useful for cross-referencing with Sportradar, NBA Stats, or other providers.
Path parameters
| Parameter | Type | Description |
|---|---|---|
| nbaGameId Required | string | 10-digit NBA game ID (e.g. 0022501178). |
Response
{
"game": {
"id": "nba-2026-04-06-nyk-atl",
"nbaGameId": "0022501178",
"homeTeam": "ATL",
"awayTeam": "NYK",
"status": "final",
"date": "2026-04-06",
"startTime": "2026-04-06T23:30:00Z"
}
}
Game coverage
Returns clip-availability statistics for a game. Use this to decide whether it's worth polling clips (e.g. if a game hasn't started yet or no clips have landed 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 | Total clips ingested for this game (matched, pending, and skipped combined). |
| matchedClips | Clips with a successful Gemini match (confidence high/medium/low). These are what the clips endpoint returns by default. |
| processedClips | Clips that have finished Gemini processing (includes matched, unmatched, and skipped non-play content like interviews). |
| pendingClips | Clips waiting to be processed. Poll again in 30โ60s. |
| latestClipAt | Timestamp of the most recently posted clip for this game. Useful as an initial since cursor. |
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) โ only successfully matched clipspending โ clips still being processedall โ every clip including unmatched and skipped
|
| since Optional | string | ISO 8601 timestamp. Return only clips posted after this time. Use for incremental polling. |
Response
{
"gameId": "nba-2026-04-06-nyk-atl",
"game": {
"status": "final",
"homeTeam": "ATL",
"awayTeam": "NYK",
"date": "2026-04-06",
"nbaGameId": "0022501178"
},
"count": 51,
"clips": [
{
"tweetId": "2041319802384347608",
"teamAccount": "nyknicks",
"teamId": "NYK",
"gameId": "nba-2026-04-06-nyk-atl",
"postedAt": "2026-04-07T02:21:14Z",
"caption": "YESSSIRRR OG",
"embedUrl": "https://x.com/nyknicks/status/2041319802384347608",
"playType": ["dunk"],
"playersMentioned": ["Anunoby"],
"quarter": 3,
"gameClock": null,
"clipType": "single_play",
"matchStatus": "done",
"matchConfidence": "high",
"matchedPlay": {
"timeActual": "2026-04-07T02:19:48Z",
"period": 3,
"clock": "PT06M42.00S",
"actionType": "Made Shot",
"subType": "DUNK",
"player": "O. Anunoby",
"teamTricode": "NYK",
"description": "Anunoby Dunk (16 PTS)",
"scoreHome": 68,
"scoreAway": 72,
"assistPlayerNameInitial": "J. Brunson"
}
}
]
}
game object and a matchedPlay stripped to the client-useful fields. Internal metadata (videoUrl, matchWindow, matchReasoning) is excluded.
Match a play
Convenience endpoint: given a play event, return the best matching clip. Under the hood this performs a pre-matched lookup (instant) with a heuristic fallback for clips that haven't been Gemini-processed yet.
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 of the play-maker (e.g. NYK). |
| quarter Optional | number | Game period (1โ4, or 5+ for OT). |
POST /v1/nba/clips/match
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",
"quarter": 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": "gemini",
"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 | gemini for pre-matched clips, heuristic for fallback matches while Gemini is still processing. |
| candidates | Up to 5 runner-up clips with scores, for debugging or UI "related clips" sections. |
Data models
Game
| Field | Type | Description |
|---|---|---|
| id | string | Our deterministic game ID. |
| nbaGameId | string | NBA's 10-digit official ID. |
| 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). |
Clip
| Field | Type | Description |
|---|---|---|
| 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. |
| 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). |
| 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 NBA 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 | High-level action (e.g. Made Shot, Turnover, Rebound). |
| subType | string | Specific action (e.g. DUNK, 3PT, LAYUP). |
| player | string | Play-maker's name (e.g. O. Anunoby). |
| teamTricode | string | Team of the play-maker. |
| description | string | Official NBA play description. |
| scoreHome | number | Home score after the play. |
| scoreAway | number | Away score after the play. |
| assistPlayerNameInitial | string | null | Assisting player, if any. |
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 ensures proper attribution to the content owner (NBA team, league, or broadcaster). 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/nba/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.
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 Gemini after analyzing the video. The match endpoint also returns a numeric confidence from 0.00โ1.00.
| Level | Numeric range | Meaning |
|---|---|---|
| high | 0.85 โ 1.00 | Gemini identified the play with unambiguous visual + audio evidence. Safe to display without qualification. |
| medium | 0.65 โ 0.84 | Strong match but some ambiguity (e.g. two similar plays in the same window). Recommended for display. |
| low | 0.40 โ 0.64 | Best guess among the candidates. Consider hiding from production feeds or marking as "possible match". |
Clips with confidence below 0.40 are not returned by the clips endpoint. You can surface them with ?status=all if you want to display "unmatched clips" for completeness.
Changelog
| Date | Change |
|---|---|
| 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/nba/games/by-nba-id/:nbaGameId for Sportradar-style integrations. |
| 2026-04-01 | Expanded broadcast partner scraping: @NBAonNBC, @NBATV, @NBAonPrime, @ESPNNBA. |
| 2026-03-24 | Initial public beta: v1 endpoints live, 30 NBA team accounts + @NBA. |