Skip to main content
A session is one submitted game and everything the pipeline produced for it. The same object shape is returned by every endpoint and inside every webhook event, so you parse it once.

Sources

POST /v1/sessions takes a source. type is optional — Hooper derives it from the URL, and a Veo link is always routed to the Veo path even if you say url.
Three steps: create an upload, PUT the bytes to the returned resumable URL, then submit. size must be the file’s exact length in bytes, because upload_url accepts exactly that many. expires_in_seconds is how long upload_url accepts the PUT; the stored file doesn’t expire.
Submitting before the bytes have landed returns 409 upload_incomplete.

Request fields

The credit hold

Submitting places a hold on your balance: an estimate (from the file size for uploads, 2 hours for URL and Veo sources), never more than your available balance. Before processing, Hooper measures the video and resizes the hold to its real cost; you’re charged for that measured length. The duration_seconds on the returned session object is the measured length. It is null until the session is processed. See Billing.

Recording options

Everything under options is optional. Values are case-sensitive, and anything outside the listed values is rejected with 400 parameter_invalid, with param naming the field (for example options.camera_setup). Four options change how the video is analysed:
If your camera is fixed but the hoop is not in the upper middle of the frame (for example a wide gym camera), send camera_setup: "panning". Otherwise Hooper finds no hoop and the session fails with no_hoops.
The rest are saved with the session but do not change the results the API returns: A full request, for a 5v5 game filmed on a fixed tripod with numbered jerseys:

Jerseys

jerseys decides whether Hooper reads jersey numbers. Reading them is how Hooper tells players apart in an organized game. It is also what fills in players[].jersey and lets rosters be matched. When Hooper reads numbers, it groups each player’s actions by number and team:
  • An action where Hooper couldn’t read the number isn’t credited to any player.
  • A number has to be read on at least two actions to become a player.
So reading numbers works best when they’re legible in the video. auto gets this right for most videos, but it can decide that a game has no numbered uniforms when it does. Numbers are then not read, and rosters are not matched. If you know the players wear numbers, send enabled.
has_jerseys (true / false) is the older form of this option. It still works, but is deprecated: true means enabled and false means disabled. Send one or the other; a request with both is refused with 400 and param: options.

Branding

Put your own images on the processed video. Both are optional and work with every source type, including Veo. Each image must be a valid PNG, JPEG or WebP of at most 5 MB. Hooper reads the format from the file itself, not from its name or Content-Type.

Sending an image

Send each image as a public URL or as an upload, whichever you have. Use one or the other per image, not both.
Hooper downloads the image when it starts processing the session, before the video.
The URL must be public https:// and answer 200 within 15 seconds. Up to 3 redirects are followed, and private or link-local addresses are rejected, including through a redirect. Hooper keeps its own copy with the session, so the image only needs to stay at that URL until the session is processed. Each new session downloads the URL again, so a changed file applies to the sessions you submit next, not to ones already processed.At submit, Hooper only checks the address. If the image itself is unusable, the session fails during processing (see Branding errors).

Logo size

A landscape logo between about 1.6:1 and 3.6:1 is drawn at most 260×110 px on the 1920×1080 frame, 48 px from the right edge and 52 px from the bottom. That is enough to cover the wordmark Veo burns into its exports, so on a Veo source your logo replaces Veo’s. A squarer or wider logo is enlarged past that box so it still covers the wordmark: a square logo is drawn at 180×180, for example. Any resolution works, since Hooper scales it. To keep it sharp, send it at least as large as it will be drawn.

What to expect

  • The session says what it carries. Every session object has "branding": { "cover": true, "logo": true }, set when you submit. A processed session always has every image it lists.
  • The cover is part of the video. video, every shot’s start_seconds / end_seconds and the measured duration_seconds include its 5 seconds. Those 5 seconds are billed like the rest of the video; branding costs nothing else.
  • A branded video is always 1920×1080. A portrait recording gets black bars at the sides.

Branding errors

Problems Hooper can see at submit are refused at submit, before anything is held. param names the field (branding.logo is shown; branding.cover works the same way): Anything else fails the session. Hooper never processes a video without the branding you asked for. The session ends in failed, the hold is voided (nothing is charged), and you receive session.failed. Its error names the image and says what to fix:
URL images are downloaded before the video, so a bad URL fails the session within moments of processing starting.

Rosters

Send each team’s players, and the players Hooper detects come back with your names and your own ids. Every highlight.player then points at a player you know, so you can follow a player’s highlights and stats across games, even when rosters change from game to game.
recording is your team and opponent the other one, the same two sides as recording_team_name and opponent_team_name. Send one or both: a practice with one squad only needs recording. An empty rosters object is refused with 400 and param: rosters. Each roster: Each player: Two teammates with the same number are refused with 400 and param: rosters.<side>.players, because Hooper could never tell them apart. "7" and "07" count as the same number. The same number on both teams is fine. Rosters are matched by jersey number, so they can’t be sent with jerseys: "disabled" (or has_jerseys: false); that request is refused with 400 and param: options.jerseys.

How players are matched

Hooper reads jersey numbers from the video, then matches the players it detected to your rosters:
  • One player per number and side. A detected player is named when its number is on the roster of its side. The video doesn’t know which side is yours, so Hooper tries both ways round and keeps the one that names more players. players[].team_id then follows your rosters: 1 is recording, 2 is opponent, and players[].team_name is that side’s name.
  • Numbers have to be read. Matching uses the numbers Hooper reads, so it depends on jerseys. With the default auto, Hooper decides from the video, and if it can’t tell, your rosters keep numbers on. Send enabled when you know the players wear numbers.
  • Matching never fails a session. A player whose number couldn’t be read, or isn’t on its side’s roster, comes back without a roster name and with metadata: {}.

What comes back

teams is on every session. A side you didn’t name has Hooper’s default name (Team 1 for recording, Team 2 for opponent) and metadata: {}. Each player carries its side as team_id (1 = recording, 2 = opponent) and team_name, the same name as in teams; both are null for a player Hooper couldn’t place on a side. highlight.player and shot.players.* point at the plr_… ids in players. A plr_… id only exists within one session; your metadata is what stays the same from game to game. To collect each player’s highlights across sessions:
Good to know:
  • Roster players Hooper didn’t detect in the video don’t appear in players. A player is detected once Hooper reads their number on at least two of their actions (see Jerseys).
  • Hooper can’t read 00 yet, so a player wearing it stays unnamed.
  • Now and then the video splits one player into two detected players. Both get the same name and metadata, so add their stats up by your id.

Limits

The API accepts the same envelope a Hooper mobile recording produces: Where the size is knowable at submit (an upload’s blob size, a URL’s Content-Length) an oversize video is refused with 400 video_too_large and nothing is held. Resolution is only known once the video is downloaded and probed; if it exceeds the limit the session ends in failed with error.code = "video_too_large", the credit hold is voided (nothing is charged) and any uploaded bytes are deleted. A URL or Veo source must download at 1 MB/s or faster. A slower host fails the session with error.code = "source_too_slow" and nothing is charged; for those, upload the file with /v1/uploads instead.

Lifecycle

A job that never reports back is swept after 24 hours: the session fails with error.code = "timeout", the hold is voided, and you still receive a session.failed event.

The session object

Things worth knowing:
  • Highlights are the product. Each is a cut clip linked to its shot and its player (the scorer).
  • Embedded lists cap at 100 items. has_more: true means page the url with limit / offset.
  • Cross-links are ids: shot.highlight → hl_…, shot.players.scorer / highlight.player → plr_… matching players.data[].id. A player’s team_id 1 is teams.recording, 2 is teams.opponent; team_name repeats that side’s name.
  • A queued, processing or failed session has the same keys with empty lists — never a missing field.
  • Attribution ignores tags the pipeline marked invalid, using the same rule as the box score, so a shot’s players and a player’s stats always agree.

Idempotency

Send an Idempotency-Key header (any string ≤ 255 chars) on POST /v1/sessions. Replaying the same key with the same body within 24 hours returns the original response with an Idempotent-Replayed: true header; the same key with a different body returns 400 idempotency_key_reused. Concurrent requests with the same key are safe: exactly one session is created, and the others receive its response (or a retryable 409 idempotency_key_in_progress if it hasn’t finished yet). Retrying a 5xx with the same key is also safe — the replay finishes any step the failed attempt left undone. Use your own match id.