Appearance
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):
| Stage | Typical timing | delayed stream (live) | processed stream (postgame) |
|---|---|---|---|
| Before tip | From 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 game | A new update at least every 2 to 3 minutes, often faster | game_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 update | Typically 5 to 10 minutes after the game ends | game_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 data | Normally within 90 minutes after the end of game buzzer | Unchanged. | 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:
- 1v1 Advantage Creation (
advantage_creation_1v1) - After Shot Positioning (
after_shot_positioning) - Ball Screens (
ball_screens) - Challenges (
challenges) - Chances (
chances) - Closeouts (
closeouts) - Deflections (
deflections) - Drives (
drives) - Fouls (
fouls) - Free Throws (
free_throws) - Interior Shot Contests (
interior_shot_contests) - Jump Balls (
jump_balls) - Jump Shot Contests (
jump_shot_contests) - Jumps (
jumps) - Matchups (
matchups) - Passes (
passes) - Possession Touches (
possession_touches) - Possession Touches Location Velocity Defender (
possession_touches_location_velocity_defender) - Possessions (
possessions) - Rebounds (
rebounds) - Shots (
shots) - Shots Location Velocity Defender (
shots_location_velocity_defender) - Turnovers (
turnovers) - Violations (
violations)
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 type | Fields | Value during the game |
|---|---|---|
shots | shooter_context | "random" |
chances | chance_context, bring_up_ball_handler_id_nba, bring_up_ball_handler_defender_id_nba | null |
chances | defense_starts_in_zone | false |
drives | ballhandler_movement_side, offensive_weak_side_player_count, defensive_weak_side_player_count, has_offensive_off_ball_player_moving_at_play_start | null |
passes | pass_type | "kick_out" or null (see below) |
passes | passer_possession_touch_id_ctg | null |
possession_touches | was_double_teamed_during_touch | false |
chances, possessions | in_garbage_time | false |
ball_screens | Every 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_screen | null (slip_screen_type is "none" on handoffs) |
ball_screens | is_dribble_handoff, led_to_event | false |
closeouts | Every field except the IDs, players, period, frames, and clocks | null |
closeouts | is_direct | false |
advantage_creation_1v1 | event_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_start | null |
advantage_creation_1v1 | was_double_teamed, led_to_event | false |
jump_shot_contests | Every 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_in | null |
interior_shot_contests | Every field measured at ball_in_hands | null |
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 asshot_id_ctgandchance_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:
| Key | Notes |
|---|---|
game_id_nba | Same on both streams |
player_id_nba, team_id_nba | Includes role-specific names such as shooter_id_nba and offensive_team_id_nba |
pbp_event_id | On marking types that link to a play-by-play event, such as shots |
period with game clock | Use 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.)
Recommended Polling Loop
- Use
/entities/gamesto know which games are scheduled. Its status comes from NBA schedule data and can lag live action by several minutes. Use/game_statusto know what CTG has published. - About every 30 seconds, call
/game_status?stream_type=delayed&only_in_progress=true. - For each game, compare
ctg_last_updatedwith the value you stored. If it changed, call/markingsfor that game withstream_type=delayedand only themarking_typesyou need. - Replace all live data you stored for that game with the new response. Do not append to it.
- When a game drops out of the
only_in_progress=truelist, call/game_status?game_id_nba=<game>&stream_type=delayedonce. Ifctg_last_updatedchanged, fetch/markingsone more time to get the end-of-game update. Thegame_statusvalue tells you whether the game endedcompletedorpartial. - Poll
/game_statuson theprocessedstream 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."}| Status | Body | Meaning | What to do |
|---|---|---|---|
403 | {"error": "Authorization Required. Please include your API key in the X-API-KEY header.", "result": "..."} | No X-API-KEY header | Send the header |
403 | {"error": "Authorization Error: invalid API key.", "result": "..."} | The key is not valid | Check 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 varies | Use stream_type=processed |
400 | {"error": "game_id_nba is required"} | /markings without a game ID | Send game_id_nba |
400 | {"error": "Invalid stream_type 'live'. Valid options: delayed, processed"} | Unknown stream_type | Use 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 year | Send 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 type | Fix the list. Every marking type is valid on both streams |
400 | {"error": "Unknown or unavailable league_id: 99"} | /game_status with an unknown league_id | Use 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 ID | During 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 endpoint | Check 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 methods | Use GET |
500 | {"error": "Internal error. Please try again later."} | Server error | Retry 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=trueabout every 30 seconds. Call/markingsfor a game only when itsctg_last_updatedchanges. - Full payload every time:
/markingsalways 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_typesparameter so you can request only the markings you want. - Compression: Send
Accept-Encoding: gzip. The API compresses responses for clients that sendAccept-Encoding(gzip,br,zstd, ordeflate). - No push delivery: The API has no websocket, streaming, or webhook delivery. Poll
/game_statusto find new data.
