Submit videos for processing and receive results via webhook
This guide walks through how to submit a video for processing using the SplitStep API. You submit a job and receive results via a webhook when the job completes or fails.
You will need:
click and requests packages installedsplitstep_client.py scriptRun the script from your terminal. Your API key can be passed via the SPLITSTEP_API_KEY environment variable (recommended) or directly with the --api-key flag:
# Using the SPLITSTEP_API_KEY environment variable (recommended) SPLITSTEP_API_KEY=your_key python splitstep_client.py send path/to/job_request.json # Or passing the key directly python splitstep_client.py send path/to/job_request.json --api-key your_key
The script will print the immediate queuing response as JSON, for example:
{
"job_id": "a1b2c3d4-...",
"video_id": "your_match_id",
"status": "queued",
"message": "Job queued successfully"
}
If a required field is missing or invalid, the API will reject the request immediately with an error, for example:
{
"detail": {
"code": "MISSING_REQUIRED_FIELD",
"category": "invalid_input",
"message": "A field required for this job type is missing from the request.",
"detail": "WebhookUrl is required"
}
}
See Error Codes for the full list and for how to handle codes your integration does not recognise.
Reference implementation of the client script used above:
#!/usr/bin/env python3 import json from datetime import datetime, timezone import click import requests BASE_URL = "https://api.example.com" # dummy endpoint — replace with your assigned URL def format_duration(duration: float) -> str: """Format a duration in seconds into a human readable string. Parameters ---------- duration : float Duration in seconds to format Returns ------- str Formatted duration string """ if duration < 1: return f"{duration * 1000:.0f}ms" elif duration < 60: return f"{duration:.1f}s" elif duration < 3600: minutes = str(int(duration // 60)) seconds = str(int(duration % 60)) seconds = "0" + seconds if len(seconds) == 1 else seconds return f"{minutes}m{seconds}s" else: hours = str(int(duration // 3600)) minutes = str(int((duration % 3600) // 60)) minutes = "0" + minutes if len(minutes) == 1 else minutes return f"{hours}h{minutes}m" @click.group() def cli(): """Submit jobs to the SplitStep API, list in-flight jobs, check status, or remove queued jobs.""" @cli.command() @click.argument("job_request_path", type=click.Path(exists=True)) @click.option("--api-key", envvar="SPLITSTEP_API_KEY", required=True) def send(job_request_path, api_key): """Send a job request to the SplitStep API. Parameters ---------- job_request_path : str Path to a JSON file containing the job request payload. api_key : str API key for authenticating with the API. Falls back to the SPLITSTEP_API_KEY environment variable. """ with open(job_request_path, "r") as f: data = json.load(f) response = requests.post( f"{BASE_URL}/jobs", headers={"X-Api-Key": api_key, "Content-Type": "application/json"}, json=data, ) click.echo(json.dumps(response.json(), indent=2)) @cli.command() @click.argument("job_id") @click.option("--api-key", envvar="SPLITSTEP_API_KEY", required=True) def status(job_id, api_key): """Get the status of a job from the SplitStep API. Parameters ---------- job_id : str ID of the job to look up, as returned when the job was submitted. api_key : str API key for authenticating with the API. Falls back to the SPLITSTEP_API_KEY environment variable. """ response = requests.get( f"{BASE_URL}/jobs/{job_id}", headers={"X-Api-Key": api_key}, ) click.echo(json.dumps(response.json(), indent=2)) @cli.command(name="list") @click.option("--api-key", envvar="SPLITSTEP_API_KEY", required=True) def list_jobs(api_key): """List in-flight (queued or processing) jobs for this API key. Parameters ---------- api_key : str API key for authenticating with the API. Falls back to the SPLITSTEP_API_KEY environment variable. """ response = requests.get( f"{BASE_URL}/jobs", headers={"X-Api-Key": api_key}, ) payload = response.json() if response.status_code != 200: click.echo(json.dumps(payload, indent=2)) return jobs = payload.get("jobs") or [] if not jobs: click.echo("No in-flight jobs.") return now = datetime.now(timezone.utc) rows = [] for job in jobs: updated_at = job.get("updated_at") if updated_at: updated = datetime.fromisoformat(updated_at) time_in_status = format_duration((now - updated).total_seconds()) else: time_in_status = "-" rows.append( ( job.get("job_id", ""), job.get("video_id", ""), job.get("status", ""), time_in_status, ) ) headers = ("job_id", "video_id", "status", "time_in_status") widths = [len(h) for h in headers] for row in rows: for i, cell in enumerate(row): widths[i] = max(widths[i], len(cell)) click.echo(" ".join(h.ljust(widths[i]) for i, h in enumerate(headers))) for row in rows: click.echo(" ".join(cell.ljust(widths[i]) for i, cell in enumerate(row))) @cli.command() @click.argument("job_id") @click.option("--api-key", envvar="SPLITSTEP_API_KEY", required=True) def remove(job_id, api_key): """Remove a queued job from the SplitStep API. Only jobs that are still queued can be removed. Processing jobs are rejected. Parameters ---------- job_id : str ID of the job to remove. api_key : str API key for authenticating with the API. Falls back to the SPLITSTEP_API_KEY environment variable. """ response = requests.delete( f"{BASE_URL}/jobs/{job_id}", headers={"X-Api-Key": api_key}, ) payload = response.json() if response.content else {} if response.status_code == 200: click.echo(f"Removed job {job_id} from queue.") return error = payload.get("error") or {} code = error.get("code", "") detail = error.get("detail", "") if response.status_code == 404 or code == "JOB_NOT_FOUND": click.echo(f"Job {job_id} not found.") elif response.status_code == 409 or code == "JOB_NOT_REMOVABLE": if "job_processing" in detail: reason = "already processing" elif "job_completed" in detail: reason = "already completed" elif "job_failed" in detail: reason = "already failed" else: reason = detail or "not removable" click.echo(f"Cannot remove job {job_id}: {reason}.") elif response.status_code == 401 or code == "UNAUTHORIZED": click.echo("Unauthorized.") else: message = error.get("message") or response.reason click.echo(f"Failed to remove job {job_id}: {message}") if __name__ == "__main__": cli()
The job request is a JSON file with the following fields.
| Field | Type | Description |
|---|---|---|
MatchID |
string | A unique identifier for this video. |
VideoUrl |
string (URL) | An Azure Blob Storage URL to the video file, of the form https://<account>.blob.core.windows.net/<container>/<blob>, normally with a SAS token. Other hosts are not supported. |
WebhookUrl |
string (URL) | The URL that will receive a POST request when the job completes or fails. See Webhook Responses below. |
InitialTopPlayer |
string | Name of the player starting at the top of the court at the beginning of the video. |
InitialBottomPlayer |
string | Name of the player starting at the bottom of the court at the beginning of the video. |
StartTime |
float | Time in seconds from the start of the video to begin processing. |
EndTime |
float | Time in seconds from the start of the video to end processing. |
SetGameScores |
array of [number, number] |
The game scores for each set, from the perspective of the top and bottom player respectively. Each entry is a two-element array [top_player_games, bottom_player_games]. For example, a match won 6–4, 6–1 by the top player would be [[6, 4], [6, 1]]. |
FixedCamera |
boolean | Whether the camera is stationary (e.g. on a tripod or fence mount) throughout the match. |
Ad |
boolean | Whether advantage scoring is used. If true, a player must win two consecutive points after deuce to win the game. |
MatchDate |
string (date), optional | The date the match was played, as YYYY-MM-DD. |
{
"MatchID": "my_match_001",
"VideoUrl": "https://example.blob.core.windows.net/videos/match.mp4?...",
"WebhookUrl": "https://your-server.com/webhook/splitstep",
"InitialTopPlayer": "Smith",
"InitialBottomPlayer": "Jones",
"StartTime": 120.0,
"EndTime": 3600.0,
"SetGameScores": [[6, 3], [7, 5]],
"FixedCamera": true,
"Ad": false,
"MatchDate": "2026-05-14"
}
| Resolution | 1080p (1920×1080) or higher. Enforced; lower resolutions are rejected. |
| Frame rate | 30 fps minimum, 60 fps recommended. Enforced; 29.97 fps (NTSC) and higher is accepted. |
| Format | MP4 (H.264) preferred. Any container and codec ffmpeg can decode is accepted. |
| File size | Less than 8,000,000,000 bytes (8 GB). Enforced. |
| Length | Adjust StartTime and EndTime to cover complete games, consistent with the set game scores provided in SetGameScores |
| Mode | Singles only — doubles matches are not supported at this time |
FixedCamera to falseYou will receive two webhook notifications per job: one when the job is first queued, and one when processing completes or fails.
Every webhook is signed; see Verifying Webhooks below.
Sent immediately after a successful submission:
{
"job_id": "a1b2c3d4-...",
"video_id": "my_match_001",
"status": "queued",
"message": "Job queued successfully"
}
Sent when processing finishes successfully:
{
"job_id": "a1b2c3d4-...",
"video_id": "my_match_001",
"status": "job_completed",
"message": "Job completed successfully",
"sas_url": "https://...",
"trimmed_video_url": "https://..."
}
| Field | Description |
|---|---|
sas_url |
Azure Blob SAS URL to a JSON file containing the stroke-by-stroke analysis results (see Result Schemas), valid for 7 days. |
trimmed_video_url |
Azure Blob SAS URL to the trimmed and re-encoded video used for processing, valid for 7 days. |
Sent if an error occurs at any stage of processing:
{
"job_id": "a1b2c3d4-...",
"video_id": "my_match_001",
"status": "job_failed",
"message": "Failed to download video: HTTP 403 fetching video: ...",
"error": {
"code": "VIDEO_UNREACHABLE",
"category": "invalid_input",
"message": "The video could not be retrieved. The SAS token may have expired, or the blob may have been moved or deleted.",
"detail": "HTTP 403 fetching video: ...",
"step": "downloading_video"
}
}
Branch on error.code. Use error.message for end-user display and error.detail for diagnostics. The top-level message summarizes the failure and includes the underlying error detail; it is retained for backward compatibility and should not be parsed.
Your webhook endpoint is a public URL, so you must verify that a request genuinely came from us before acting on it. Every webhook carries a signature computed with a shared secret that only the two of us hold. This secret is not the same value as your API key.
import base64
import hashlib
import hmac
def verify(raw_body: bytes, received_signature: str, secret: str) -> bool:
digest = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).digest()
expected = base64.b64encode(digest).decode("utf-8")
return hmac.compare_digest(expected, received_signature)
Read the signature from the X-HMAC-Signature header and pass it as received_signature.
After submitting a job, check its progress with the status command.
SPLITSTEP_API_KEY=your_key python splitstep_client.py status {job_id}
The script prints the status response as JSON.
| Field | Description |
|---|---|
job_id |
The job ID from the URL. |
video_id |
The video ID from the job request you submitted. |
status |
One of queued, job_processing, job_completed, job_failed. |
queued_at |
When the job was accepted, UTC. |
updated_at |
When the status last changed, UTC. For job_processing, this is when a worker picked the job up. |
error |
Same shape as the webhook error object. See Error Codes. |
{
"job_id": "a1b2c3d4-0000-0000-0000-000000000001",
"video_id": "my_match_001",
"status": "job_processing",
"queued_at": "2026-07-31T18:02:11.482913+00:00",
"updated_at": "2026-07-31T18:07:03.115204+00:00"
}
When the request itself fails, the response contains job_id and error only — no status, video_id, or timestamps. See Returned when checking job status for the possible codes.
{
"job_id": "a1b2c3d4-0000-0000-0000-000000000001",
"error": {
"code": "JOB_NOT_FOUND",
"category": "invalid_input",
"message": "No job was found with that ID.",
"detail": ""
}
}
If a job starts processing and then stops reporting progress — for example because the machine running it was interrupted — we detect this and mark the job job_failed with the code JOB_STALE, and notify your webhook:
{
"job_id": "a1b2c3d4-0000-0000-0000-000000000001",
"video_id": "my_match_001",
"status": "job_failed",
"queued_at": "2026-07-31T18:02:11.482913+00:00",
"updated_at": "2026-07-31T18:22:03.115204+00:00",
"error": {
"code": "JOB_STALE",
"category": "internal",
"message": "The job stopped reporting progress. Contact support with the job_id.",
"detail": "no heartbeat since 2026-07-31T18:11:44.902117+00:00"
}
}
List jobs that are still queued or processing with the list command.
SPLITSTEP_API_KEY=your_key python splitstep_client.py list
The script prints a table:
job_id video_id status time_in_status a1c2c3d4-0000-0000-0000-000000000001 my_match_001 queued 12m34s b2c3d4e5-0000-0000-0000-000000000002 my_match_002 job_processing 3m05s c3d4e5f6-0000-0000-0000-000000000003 my_match_003 queued 45.2s
time_in_status is how long the job has been in its current status. If there are no in-flight jobs, the script prints No in-flight jobs.
Remove a job that is still queued with the remove command. Jobs that have already started processing cannot be removed.
SPLITSTEP_API_KEY=your_key python splitstep_client.py remove {job_id}
On success:
Removed job a1b2c3d4-0000-0000-0000-000000000001 from queue.
The job record is deleted. A later status check for that ID returns JOB_NOT_FOUND.
If the job is already processing:
Cannot remove job b2c3d4e5-0000-0000-0000-000000000002: already processing.
Every error returned by the API or delivered in a job_failed webhook carries an error object with the same shape.
| Field | Description |
|---|---|
code |
Stable machine-readable identifier. Branch on this. |
category |
invalid_input if the request or video needs to change, internal if the fault is ours. |
message |
Human-readable explanation associated with the error code. Display this to end users. |
detail |
Specific diagnostic message for this occurrence (for example, which field was missing, or the underlying HTTP error). Include this when contacting support. |
step |
Pipeline step that failed. Present on webhook errors only. |
New codes will be added over time as failure modes are identified. Your integration must handle unknown codes gracefully via a default branch. Where possible, branch on category rather than on individual codes, so that new codes are handled correctly without a change on your side.
| Code | Status | Meaning |
|---|---|---|
INVALID_REQUEST |
422 | The request body failed schema validation. |
UNAUTHORIZED |
401 | The API key was missing or not recognised. |
MISSING_REQUIRED_FIELD |
422 | A field required for your job type was absent. |
VIDEO_URL_INVALID |
422 | VideoUrl is not an Azure Blob Storage URL. |
QUEUE_ERROR |
503 | The job could not be queued. Contact support. |
| Code | Category | Meaning |
|---|---|---|
VIDEO_UNREACHABLE |
invalid_input |
The video could not be fetched. Usually an expired SAS token or a deleted blob. |
VIDEO_TOO_LARGE |
invalid_input |
The video exceeds the maximum file size of 8,000,000,000 bytes (8 GB). |
VIDEO_UNREADABLE |
invalid_input |
The video could not be decoded, or contains no video stream. |
VIDEO_RESOLUTION_TOO_LOW |
invalid_input |
The video is below 1920×1080. |
VIDEO_FRAME_RATE_TOO_LOW |
invalid_input |
The video is below 29.9 fps (29.97 fps NTSC is accepted). |
INTERNAL_ERROR |
internal |
An unexpected failure on our side. Contact support with the job_id. |
JOB_STALE |
internal |
The job stopped reporting progress and will not complete. Contact support with the job_id. |
See Checking Job Status.
| Code | Status | Category | Meaning |
|---|---|---|---|
UNAUTHORIZED |
401 | invalid_input |
The API key was missing or not recognised. |
JOB_NOT_FOUND |
404 | invalid_input |
No job with that ID exists for your API key. |
STATUS_UNAVAILABLE |
503 | internal |
Status could not be read right now. |
JOB_STALE |
200 | internal |
The job was marked failed after it stopped reporting progress. Contact support with the job_id. |
| Code | Status | Category | Meaning |
|---|---|---|---|
UNAUTHORIZED |
401 | invalid_input |
The API key was missing or not recognised. |
JOB_NOT_FOUND |
404 | invalid_input |
No job with that ID exists for your API key. |
JOB_NOT_REMOVABLE |
409 | invalid_input |
The job exists but is not queued (for example, it is already processing). |
STATUS_UNAVAILABLE |
503 | internal |
The job store could not be updated right now. |
When a job completes successfully, the sas_url in the webhook payload points to a JSON file of stroke-by-stroke analysis. The strokes schema below is our standard delivery format.
Upon request, we can also provide frame-by-frame tracking for players and ball. We can provide a statistics-based schema as well — contact us for more details.
The results file is a JSON array. Each element describes one detected stroke (contact), including court and pixel positions, bounce location, ball flight metrics, and score context. Missing values use type-specific sentinels: -9999.0 for floats, -9999 for integers, and "None" for strings.
Positions ending in _m are in meters on the court plane. The origin is at court center (net mid-point). Positive y is toward the top of the video / far baseline; positive x is toward the right sideline.
Singles court in meters. Sidelines at x ≈ ±4.12; service lines at y ≈ ±6.4; baselines at y ≈ ±11.89.
| Field | Type | Description |
|---|---|---|
video_id |
string | Identifier for the processed video / match. |
event_id |
integer | Zero-based index of this stroke across the full results file. |
frame |
integer | Video frame index of the stroke contact. |
time |
float | Time in seconds since the start of the processed video at stroke contact. |
rally_id |
integer | 1-based rally / point identifier from input rally metadata, if provided. |
rally_stroke_number |
integer | 1-based stroke number within the rally, from input rally metadata, if provided. |
player_id |
string | Name of the player who hit the stroke, from input rally metadata, if provided. |
point_score, game_score, set_score |
string | Point, game, and set scores from input rally metadata, if provided, as hyphenated pairs from the server's perspective (e.g. "30-15", "3-2"). |
pred_rally_id |
integer | Beta 1-based predicted rally / point identifier. |
pred_rally_stroke_number |
integer | Beta 1-based predicted stroke number within the rally. |
pred_player_id |
string | Beta Predicted name of the player who hit the stroke. |
pred_point_score, pred_game_score, pred_set_score |
string | Beta Predicted point, game, and set scores at the stroke, as hyphenated pairs from the server's perspective (e.g. "30-15", "3-2"). |
stroke_type |
string | Stroke category: serve, groundstroke, or volley. |
stroke_side |
string | Stroke side / technique: forehand, backhand, or overhead. |
stroke_score |
float | Model confidence for the stroke detection (0–1). |
side_score |
float | Model confidence for the stroke side classification (0–1). |
player_x_m, player_y_m |
float | Hitting player position on the court at contact, in meters. |
player_x1_px, player_y1_px, player_x2_px, player_y2_px |
float | Pixel bounding box of the hitting player in the video frame (top-left to bottom-right). |
opponent_x_m, opponent_y_m |
float | Opponent position on the court at contact, in meters. |
opponent_x1_px, opponent_y1_px, opponent_x2_px, opponent_y2_px |
float | Pixel bounding box of the opponent in the video frame. |
speed_kmh |
float | Estimated ball speed after contact, in km/h. |
ang_vel_mag_rpm |
float | Estimated ball spin magnitude, in revolutions per minute (RPM). |
spin_type |
string | Estimated spin class: topspin, backspin, sidespin, or flat. |
initial_height_m |
float | Estimated ball height at contact, in meters. |
height_at_net_m |
float | Estimated ball height when crossing the net plane, in meters. |
net_hit |
boolean | Whether the ball hit the net during the stroke's trajectory. |
bounce_frame |
float | Frame index of the subsequent ball bounce. |
bounce_score |
float | Model confidence for the bounce detection (0–1). |
bounce_x_m, bounce_y_m |
float | Bounce location on the court in meters. |
bounce_x_px, bounce_y_px |
float | Bounce location in video pixel coordinates. |
in |
boolean | Whether the ball was judged in after the stroke (based on bounce / landing). |
line_confidence |
float | Confidence associated with the in/out line call (0.5–0.9). |
[
{
"video_id": "my_match_001",
"event_id": 0,
"frame": 1060,
"time": 17.67,
"rally_id": 1,
"rally_stroke_number": 1,
"player_id": "client_player_id",
"point_score": "0-0",
"game_score": "0-0",
"set_score": "0-0",
"pred_rally_id": 1,
"pred_rally_stroke_number": 1,
"pred_player_id": "client_player_id",
"pred_point_score": "0-0",
"pred_game_score": "0-0",
"pred_set_score": "0-0",
"stroke_type": "serve",
"stroke_side": "overhead",
"stroke_score": 0.998,
"side_score": 0.996,
"player_x_m": 0.52,
"player_y_m": -11.97,
"player_x1_px": 985.4,
"player_y1_px": 480.81,
"player_x2_px": 1092.1,
"player_y2_px": 758.45,
"opponent_x_m": -3.08,
"opponent_y_m": 14.46,
"opponent_x1_px": 847.28,
"opponent_y1_px": 178.75,
"opponent_x2_px": 875.84,
"opponent_y2_px": 234.68,
"speed_kmh": 162.27,
"ang_vel_mag_rpm": 1788.28,
"spin_type": "topspin",
"initial_height_m": 2.481,
"height_at_net_m": 0.981,
"net_hit": false,
"bounce_frame": 1085.0,
"bounce_score": 0.939,
"bounce_x_m": -1.311,
"bounce_y_m": 4.646,
"bounce_x_px": 901.875,
"bounce_y_px": 292.5,
"in": true,
"line_confidence": 0.9
}
]
Upon request, we can provide frame-by-frame tracking for players and ball in addition to the standard strokes output. Contact us to enable this for your pipeline.
We can also deliver a statistics-based schema derived from the match analysis. Contact us for more details on fields and delivery format.