API Reference ยท v1

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.

Base URL https://sports-clips.fly.dev Version v1 Format JSON

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 since cursor โ€” 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.

curl โ€” fetch all matched clips for a 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"
JavaScript (Node 18+)
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.

Example request header
X-API-Key: sk_live_a1b2c3d4e5f6...
!
Keep your API key secret. Never embed it in client-side JavaScript or mobile app bundles. Proxy requests through your backend and attach the header server-side.

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:

  1. Scrape โ€” We poll every NBA team, the league account, and active broadcast-partner accounts on X every 2 minutes via Apify.
  2. Resolve game โ€” Each clip is tagged with a gameId based on the posting account and timestamp (for team posts) or caption + broadcaster schedule (for league/partner posts).
  3. 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).
  4. Match & store โ€” Gemini returns the exact play with a confidence level, which we store on the clip row in SQLite.
  5. Serve โ€” When you query the API, we return the pre-matched clip directly. No per-request AI cost.
i
Typical latency from play to matched clip: 3โ€“8 minutes end-to-end during a live game. The biggest variable is how quickly the source account posts the video. Team accounts average 4โ€“6 minutes; broadcast partners average 8โ€“15 minutes.

Game IDs

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

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

GET /v1/nba/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",
      "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.

GET /v1/nba/games/by-nba-id/:nbaGameId Auth required

Path parameters

ParameterTypeDescription
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).

GET /v1/nba/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
totalClipsTotal clips ingested for this game (matched, pending, and skipped combined).
matchedClipsClips with a successful Gemini match (confidence high/medium/low). These are what the clips endpoint returns by default.
processedClipsClips that have finished Gemini processing (includes matched, unmatched, and skipped non-play content like interviews).
pendingClipsClips waiting to be processed. Poll again in 30โ€“60s.
latestClipAtTimestamp 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.

GET /v1/nba/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) โ€” only successfully matched clips
pending โ€” clips still being processed
all โ€” 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"
      }
    }
  ]
}
i
The response includes a slim 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.

POST /v1/nba/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 of the play-maker (e.g. NYK).
quarter OptionalnumberGame period (1โ€“4, or 5+ for OT).
Example request
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 */ ]
}
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.
sourcegemini for pre-matched clips, heuristic for fallback matches while Gemini is still processing.
candidatesUp to 5 runner-up clips with scores, for debugging or UI "related clips" sections.

Data models

Game

FieldTypeDescription
idstringOur deterministic game ID.
nbaGameIdstringNBA's 10-digit official ID.
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).

Clip

FieldTypeDescription
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.
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).
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 NBA 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).
actionTypestringHigh-level action (e.g. Made Shot, Turnover, Rebound).
subTypestringSpecific action (e.g. DUNK, 3PT, LAYUP).
playerstringPlay-maker's name (e.g. O. Anunoby).
teamTricodestringTeam of the play-maker.
descriptionstringOfficial NBA play description.
scoreHomenumberHome score after the play.
scoreAwaynumberAway score after the play.
assistPlayerNameInitialstring | nullAssisting 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.

!
Do not download or re-host the video files. Clips are the property of the posting account (NBA teams, the league, 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 โ€” 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/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.

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 Gemini after analyzing the video. The match endpoint also returns a numeric confidence from 0.00โ€“1.00.

LevelNumeric rangeMeaning
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

DateChange
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/nba/games/by-nba-id/:nbaGameId for Sportradar-style integrations.
2026-04-01Expanded broadcast partner scraping: @NBAonNBC, @NBATV, @NBAonPrime, @ESPNNBA.
2026-03-24Initial public beta: v1 endpoints live, 30 NBA team accounts + @NBA.