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.
- Upload your own file
- Public URL
- Veo
Three steps: create an upload, Submitting before the bytes have landed returns
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.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. Theduration_seconds on the returned session object is the measured length. It is null until the session is processed. See Billing.
Recording options
Everything underoptions 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:
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.
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.- Public URL
- Upload
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. Aprocessedsession always has every image it lists. - The cover is part of the video.
video, every shot’sstart_seconds/end_secondsand the measuredduration_secondsinclude 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. Everyhighlight.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_idthen follows your rosters:1isrecording,2isopponent, andplayers[].team_nameis that side’s name. - Numbers have to be read. Matching uses the numbers Hooper reads, so it depends on
jerseys. With the defaultauto, Hooper decides from the video, and if it can’t tell, your rosters keep numbers on. Sendenabledwhen 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:
- 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
00yet, so a player wearing it stays unnamed. - Now and then the video splits one player into two detected players. Both get the same
nameandmetadata, 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
- Highlights are the product. Each is a cut clip linked to its
shotand itsplayer(the scorer). - Embedded lists cap at 100 items.
has_more: truemeans page theurlwithlimit/offset. - Cross-links are ids:
shot.highlight→hl_…,shot.players.scorer/highlight.player→plr_…matchingplayers.data[].id. A player’steam_id1isteams.recording,2isteams.opponent;team_namerepeats that side’s name. - A
queued,processingorfailedsession 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
playersand a player’sstatsalways agree.
Idempotency
Send anIdempotency-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.