Skip to content

Live Data ​

CTG produces markings while a game is in progress based on Hawk-Eye's delayed tracking stream and live events stream, plus NBA play-by-play. You can access this data using the stream_type parameter on the /game_status and /markings endpoints. The default is processed, but if you specify the delayed stream type then you will get responses during games based on that data. This page explains how to poll for live updates and what to expect returned from these endpoints during a game.

Game Flow ​

Data should flow as follows during the game (note that the times are rough estimates and can vary based on the game):

StageTypical timingdelayed stream (live)processed stream (postgame)
Before tipFrom the listed start time until about 10 minutes after it, when the game usually tips/game_status lists the game with game_status in_progress as soon as CTG connects to the tracking stream, about 10 minutes before the real tip. /markings returns 404 until the first live update.No row. /markings returns 404.
During the gameA new update at least every 2 to 3 minutes, often fastergame_status is in_progress. ctg_last_updated changes with each update. /markings returns the whole game so far on a limited set of markings.No row. /markings returns 404.
End-of-game updateTypically 5 to 10 minutes after the game endsgame_status becomes completed, or if tracking data was incomplete it becomes partial. /markings returns every marking type with all fields. This data does not change again.No row yet. /markings returns 404.
Postgame dataNormally within 90 minutes after the end of game buzzerUnchanged.A processed row appears. /markings returns every marking type. This data is corrected and backfilled over time.

NBA games tip about 10 minutes after the listed start time. CTG lists the game on /game_status about 10 minutes before tip, but /markings returns 404 until the first live update, which follows within about 2 minutes after tip.

Note that we publish new data only when there is new tracking, events, or play-by-play data, so during a long stoppage or at halftime you won't see new data and ctg_last_updated will not change.

If the live tracking stream for a game never starts, the game gets no delayed row. Its processed data comes as usual after the game.

What Is Available During the Game ​

At the moment, we are only producing a smaller set of data in order to make sure we can keep our latency low. We will continue to work on lowering the latency and expanding this live data set. If there is anything you need to see live that is not included below, please let us know and we will probably be able to add it to the live data set.

For now, these 24 data sets will come through on the delayed stream:

If you request any other marking type in the marking_types parameter, during the game it will return an empty list ([]) until the end of game update.

The available_marking_types will help you distinguish between whether: (a) the marking is not something we currently produce live or (b) we do produce it live but there are no instances of it occurring yet in the game. It lists every marking type that the update contains, whatever you request in marking_types. A type in the list with [] in markings was produced, and is just empty. A type not in the list is not produced live at the moment.

Fields Not Available During the Game ​

Some fields of shots, chances, possessions, drives, passes, possession_touches, jump_shot_contests, interior_shot_contests, ball_screens, closeouts, and advantage_creation_1v1 are not computed during the game. In every live update they hold the placeholder value shown below, until the end of the game when they will be populated with correct values.

WARNING

Some placeholders are false or a fixed string, not null. During the game, do not read these fields as real values.

Marking typeFieldsValue during the game
shotsshooter_context"random"
chanceschance_context, bring_up_ball_handler_id_nba, bring_up_ball_handler_defender_id_nbanull
chancesdefense_starts_in_zonefalse
drivesballhandler_movement_side, offensive_weak_side_player_count, defensive_weak_side_player_count, has_offensive_off_ball_player_moving_at_play_startnull
passespass_type"kick_out" or null (see below)
passespasser_possession_touch_id_ctgnull
possession_toucheswas_double_teamed_during_touchfalse
chances, possessionsin_garbage_timefalse
ball_screensEvery field except the IDs, players, frames, clocks, locations, attacking_positive_x_basket, is_handoff, is_direct, is_illegal_screen, the double_screen_* fields, ballhandler_num_dribbles_after_screen, and handoff_screener_num_dribbles_before_screennull (slip_screen_type is "none" on handoffs)
ball_screensis_dribble_handoff, led_to_eventfalse
closeoutsEvery field except the IDs, players, period, frames, and clocksnull
closeoutsis_directfalse
advantage_creation_1v1event_type, pbp_event_id, event_frame_number_he, ballhandler_outcome, the ballhandler_possession_touch_* and drive_id* fields, offensive_weak_side_player_count, defensive_weak_side_player_count, has_offensive_off_ball_player_moving_at_play_startnull
advantage_creation_1v1was_double_teamed, led_to_eventfalse
jump_shot_contestsEvery field measured at ball_in_hands or min_hips, the min_*_hand_to_ball_dist_after_release_* fields, and contester_ball_dist_at_first_both_hands_on_ball_innull
interior_shot_contestsEvery field measured at ball_in_handsnull

During the game, pass_type is kick_out for a pass that matches the kick-out geometry and null for every other pass.

During the game, jump_shot_contests and interior_shot_contests choose shots and contesters by the same rules as the end-of-game update, and every field measured at release or at the shooter's jump start has its value. So do jump_timing, did_contester_jump, is_verticality, contester_jump_distance_in, and the *_hand_eye_height_diff_at_max_jump_in fields.

During the game, the four make-probability fields of shots (league_avg_make_probability_at_release, player_adj_make_probability_at_release, league_avg_make_probability_at_start, and player_adj_make_probability_at_start) are null until the shot settles: its tracking frames, its play-by-play action and a later action have arrived, and the shot has a defender. A later update fills them in.

During the game, ball_screens, closeouts, and advantage_creation_1v1 find events by the same rules as the end-of-game update. Each event has its players (the ball handler and their defender, and for ball screens the screener and the screener's defender), its frames, and its game and shot clocks. advantage_creation_1v1 also has its post-up fields, such as is_post and has_back_down.

During the game, drive_context on drives is never transition or off_ball_screen. A drive that gets one of these at the end of the game shows another context during the game, usually other.

WARNING

During the game, CTG adds a rebound or steal that is only in the NBA play-by-play once the live events feed has reached that point in the game. Until then, the most recent chances and possessions can have boundaries or outcomes that change in a later update.

End of Game ​

After the live streams end, we run one final update to compute every marking type with all fields and publish that to the delayed stream. This gives you a full marking set at the end of the game in case you don't want to wait for the more accurate processed data to come in. The delayed row's game_status then changes from in_progress to completed (or to partial if tracking data was incomplete). This update will typically be available within about 5 to 10 minutes after the game ends.

After that last end of game update, the delayed data for the game never changes. We do not reprocess or backfill it.

Postgame data is a separate step. Once the NBA marks the game complete and Hawk-Eye's postgame tracking is available, we process the game and publish a processed row on /game_status. Poll /game_status (default processed stream) to find out when it is ready. Postgame data is the highest quality data, and it is the data that we correct and backfill over time.

A game can be published twice on the processed stream. CTG publishes interim markings built with the live events feed as soon as postgame tracking is ready, then rebuilds the game when the official postgame events feed becomes available. The events_source field on /game_status is live for the interim set and postgame for the final set. ctg_last_updated changes with the second publication, so re-pull the game's markings when it changes.

Live Versus Postgame Data ​

Note that the delayed and processed streams are independent data sets built from different source feeds. You cannot join records between them by frame or by CTG ID:

  • Hawk-Eye frame numbers (*_he_frame) are specific to each feed. The same moment has different frame numbers on the two streams.
  • CTG IDs (*_id_ctg, such as shot_id_ctg and chance_id_ctg) come from frame-level timing. They are not guaranteed to match between live and postgame data, or between two live updates if an event's start or end moves. See CTG Internal IDs.

Only these fields will match across streams:

KeyNotes
game_id_nbaSame on both streams
player_id_nba, team_id_nbaIncludes role-specific names such as shooter_id_nba and offensive_team_id_nba
pbp_event_idOn marking types that link to a play-by-play event, such as shots
period with game clockUse as a fallback, for example period with start_game_clock

How to Consume Live Data ​

Authentication ​

Send your API key in the X-API-KEY header, as described in Authentication. Live data needs a key with live access. Evaluation keys cannot read the delayed stream. (If you would like to experiment with our live data, please reach out and we will see what we can do.)

  1. Use /entities/games to know which games are scheduled. Its status comes from NBA schedule data and can lag live action by several minutes. Use /game_status to know what CTG has published.
  2. About every 30 seconds, call /game_status?stream_type=delayed&only_in_progress=true.
  3. For each game, compare ctg_last_updated with the value you stored. If it changed, call /markings for that game with stream_type=delayed and only the marking_types you need.
  4. Replace all live data you stored for that game with the new response. Do not append to it.
  5. When a game drops out of the only_in_progress=true list, call /game_status?game_id_nba=<game>&stream_type=delayed once. If ctg_last_updated changed, fetch /markings one more time to get the end-of-game update. The game_status value tells you whether the game ended completed or partial.
  6. Poll /game_status on the processed stream for the game. When its row appears, switch to the postgame data.

We write the markings before we update the /game_status row. When ctg_last_updated changes, the new markings are already available. There are two status changes that can happen without new markings: (1) the first in_progress row, which appears when we connect to the stream, before any markings exist, and (2) a change to partial when live processing fails.

In both cases /markings returns the last update, or 404 if there is no data yet. So be sure to handle 404 on every fetch.

It is recommended that you use /game_status to poll our data, as it is much smaller than /markings.

Example: Games in Progress ​

bash
curl --request GET \
  --url 'https://api.cleaningtheglass.com/pro/game_status?stream_type=delayed&only_in_progress=true' \
  --header 'X-API-KEY: YOUR_API_KEY'
json
{
  "games": [
    {
      "game_id_nba": "0022500243",
      "nba_game_id": "0022500243",
      "league_id": "00",
      "game_status": "in_progress",
      "tracking_stream": "delayed.pose.clean",
      "ctg_last_updated": "2025-11-15T20:48:01.326940+00:00",
      "data_quality_status": "complete",
      "issues": []
    }
  ]
}

data_quality_status and issues describe the processed stream only. On delayed rows they are always complete and []. See data_quality_status.

Example: Live Markings ​

This request asks for two live marking types and one marking type that is not produced during the game (ball_screens):

bash
curl --request GET \
  --url 'https://api.cleaningtheglass.com/pro/markings?game_id_nba=0022500243&stream_type=delayed&marking_types=shots,possessions,off_ball_screens' \
  --header 'Accept-Encoding: gzip' \
  --header 'X-API-KEY: YOUR_API_KEY'

During the game, the response has the shape below. Each record is trimmed. The shot shows shooter_context, which holds a placeholder until the end-of-game update, and off_ball_screens is an empty list.

off_ball_screens is not in available_marking_types, so its empty list means "not produced live". After the end-of-game update, available_marking_types lists every marking type.

json
{
  "game_id_nba": "0022500243",
  "nba_game_id": "0022500243",
  "league_id": "00",
  "stream_type": "delayed",
  "available_marking_types": [
    "advantage_creation_1v1",
    "after_shot_positioning",
    "ball_screens",
    "challenges",
    "chances",
    "closeouts",
    "deflections",
    "drives",
    "fouls",
    "free_throws",
    "interior_shot_contests",
    "jump_balls",
    "jump_shot_contests",
    "jumps",
    "matchups",
    "passes",
    "possession_touches",
    "possession_touches_location_velocity_defender",
    "possessions",
    "rebounds",
    "shots",
    "shots_location_velocity_defender",
    "turnovers",
    "violations"
  ],
  "markings": {
    "shots": [
      {
        "shot_id_ctg": "shot_105f89542695fcebf740bb7c426ac4c3",
        "game_id_nba": "0022500243",
        "pbp_event_id": 7,
        "chance_id_ctg": "chance_51bfc1fda2cc0deb125bee9988859667",
        "possession_id_ctg": "possession_640fb5737252cbe98b4f4a70dcee9d8f",
        "shooter_id_nba": 203076,
        "team_id_nba": 1610612742,
        "period": 1,
        "start_game_clock": 708,
        "start_he_frame": 749,
        "start_wall_clock": "2025-11-15T20:12:46.939+00:00",
        "is_made": true,
        "is_three": false,
        "shot_distance_in": 42.82,
        "loc_x": 460.5,
        "loc_y": 13.91,
        "shot_type": "layup",
        "shooter_context": "random",
        "league_avg_make_probability_at_release": 0.6342,
        "player_adj_make_probability_at_release": 0.6518,
        "league_avg_make_probability_at_start": 0.6027,
        "player_adj_make_probability_at_start": 0.6203,
        ...
      }
    ],
    "possessions": [
      {
        "possession_id_ctg": "possession_640fb5737252cbe98b4f4a70dcee9d8f",
        "game_id_nba": "0022500243",
        "period": 1,
        "offensive_team_id_nba": 1610612742,
        "start_game_clock": 720.0,
        "end_game_clock": 708.0,
        "possession_outcome": "fg2m",
        "points": 2,
        ...
      }
    ],
    "off_ball_screens": []
  }
}

Example: Polling One Game in Python ​

This example follows one game until its live data is final. It reads the API key from an environment variable.

python
import os
import time

import requests

BASE_URL = "https://api.cleaningtheglass.com/pro"
HEADERS = {"X-API-KEY": os.environ["CTG_API_KEY"]}
GAME_ID = "0022500243"


def delayed_status(game_id):
    response = requests.get(
        f"{BASE_URL}/game_status",
        headers=HEADERS,
        params={"game_id_nba": game_id, "stream_type": "delayed"},
        timeout=30,
    )
    response.raise_for_status()
    games = response.json()["games"]
    return games[0] if games else None


def live_markings(game_id, marking_types):
    response = requests.get(
        f"{BASE_URL}/markings",
        headers=HEADERS,
        params={
            "game_id_nba": game_id,
            "stream_type": "delayed",
            "marking_types": marking_types,
        },
        timeout=120,
    )
    if response.status_code == 404:
        return None  # Not published yet. Try again on the next poll.
    response.raise_for_status()
    return response.json()["markings"]


last_updated = None
while True:
    status = delayed_status(GAME_ID)
    if status and status["ctg_last_updated"] != last_updated:
        markings = live_markings(GAME_ID, "shots,possessions")
        if markings is not None:
            store_live_markings(GAME_ID, markings)  # Replace, do not append.
            last_updated = status["ctg_last_updated"]
    if status and status["game_status"] != "in_progress":
        break
    time.sleep(30)

# The delayed data is now final (status["game_status"] is "completed" or
# "partial"). Poll /game_status on the processed stream and switch to the
# postgame data when its row appears.

requests sends Accept-Encoding: gzip, deflate by default and decompresses the response for you. store_live_markings stands for your own storage code. For a game that never gets live data, this loop waits forever, so add a time limit in production code.

What Can Change Between Updates ​

Each live update recomputes the whole game from the start and replaces the previous update. Treat each /markings response as the complete live data for that game and stream, not as new records to append.

Between two updates, records from earlier in the game can:

  • Change field values, for example after a play-by-play correction or a revised event.
  • Appear, for example when later frames complete an event.
  • Disappear or merge, for example when an event's start or end moves.

A record's CTG ID can change when its start or end moves. Use the NBA-keyed fields to match records across updates.

The newest shot in an update can arrive before its tracking frames. Until the next update, that shot can have null for shot_type and the make probabilities, and its game clock is not final.

Errors ​

Every error response is JSON with the message under an error key. Authorization errors (403) also carry the same message under a result key, which is kept for existing clients:

json
{"error": "game_id_nba is required"}
json
{"error": "Authorization Error: invalid API key.", "result": "Authorization Error: invalid API key."}
StatusBodyMeaningWhat to do
403{"error": "Authorization Required. Please include your API key in the X-API-KEY header.", "result": "..."}No X-API-KEY headerSend the header
403{"error": "Authorization Error: invalid API key.", "result": "..."}The key is not validCheck the key
403{"error": "Authorization Error: API key does not have access to delayed data.", "result": "..."}The key has no live access (processed for no postgame access)Contact CTG for access
403{"error": "Authorization Error: evaluation keys have access to processed (postgame) data only. This evaluation key has access to 50 games. Call /game_status to list them.", "result": "..."}Evaluation keys cannot read the delayed stream. The game count variesUse stream_type=processed
400{"error": "game_id_nba is required"}/markings without a game IDSend game_id_nba
400{"error": "Invalid stream_type 'live'. Valid options: delayed, processed"}Unknown stream_typeUse delayed or processed
400{"error": "Invalid season '25'. Use 4-digit year (e.g., 2025 for 2025-26 season)"}season is not a 4-digit yearSend a 4-digit year
400{"error": "Invalid marking types for delayed stream: shot. Valid options: ..."}A name in marking_types is not a marking type. A space after a comma or a trailing comma also causes this. The message lists every valid typeFix the list. Every marking type is valid on both streams
400{"error": "Unknown or unavailable league_id: 99"}/game_status with an unknown league_idUse a league from /entities/leagues
404{"error": "No markings found for game_id_nba='0022500243' with stream_type=delayed"}No data for this game and stream yet, or an unknown game IDDuring a game, treat it as "not yet" and retry on the next poll
404{"error": "The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again."}The URL under /pro/ does not match an endpointCheck the path
405{"error": "The method is not allowed for the requested URL."}The request did not use GET. The Allow header lists the allowed methodsUse GET
500{"error": "Internal error. Please try again later."}Server errorRetry with backoff

In each 403 row, result holds the same message as error. An unknown game on /game_status returns 200 with an empty games list.

Limits and Performance ​

  • Polling: CTG does not enforce a rate limit today. Our current recommendation given our latency is to poll /game_status?stream_type=delayed&only_in_progress=true about every 30 seconds. Call /markings for a game only when its ctg_last_updated changes.
  • Full payload every time: /markings always returns the full game so far.
  • Payload size: A complete game is large, on the order of 20 MB of uncompressed JSON for all marking types. We have the marking_types parameter so you can request only the markings you want.
  • Compression: Send Accept-Encoding: gzip. The API compresses responses for clients that send Accept-Encoding (gzip, br, zstd, or deflate).
  • No push delivery: The API has no websocket, streaming, or webhook delivery. Poll /game_status to find new data.