> ## Documentation Index
> Fetch the complete documentation index at: https://developer.hooper.gg/llms.txt
> Use this file to discover all available pages before exploring further.

# Sessions

> Submitting video, what comes back, and the limits.

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`.

<Tabs>
  <Tab title="Upload your own file">
    Two calls: create an upload, `PUT` the bytes to the returned resumable URL, then submit.

    ```bash theme={null}
    # 1. create the upload
    curl https://api.hooper.gg/v1/uploads \
      -H "Authorization: Bearer $HOOPER_API_KEY" -H "Content-Type: application/json" \
      -d '{ "content_type": "video/mp4", "size": 2147483648 }'
    # → { "id": "up_3f9a…", "upload_url": "https://storage.googleapis.com/…", "expires_in_seconds": 604800 }

    # 2. PUT the file (GCS resumable protocol — a single PUT works for most files)
    curl -X PUT "$UPLOAD_URL" -H "Content-Type: video/mp4" --data-binary @game.mp4

    # 3. submit
    curl https://api.hooper.gg/v1/sessions \
      -H "Authorization: Bearer $HOOPER_API_KEY" -H "Content-Type: application/json" \
      -d '{ "source": { "type": "upload", "upload_id": "up_3f9a…" } }'
    ```

    Submitting before the bytes have landed returns `409 upload_incomplete`.
  </Tab>

  <Tab title="Public URL">
    Any public `https://` link to an MP4 or MOV. Hooper downloads it server-side.

    ```json theme={null}
    { "source": { "type": "url", "url": "https://cdn.example.com/games/2026-08-26.mp4" } }
    ```

    The host must be publicly resolvable (private and link-local addresses are rejected).
  </Tab>

  <Tab title="Veo">
    A public Veo match page or CDN link. No download on your side.

    ```json theme={null}
    { "source": { "type": "veo", "url": "https://app.veo.co/matches/20260826-acme-vs-north/" } }
    ```
  </Tab>
</Tabs>

### Options

| Field                                                                  | Notes                                                                                  |
| ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `group`                                                                | A `grp_…` under your organization. Defaults to the organization's default group.       |
| `metadata`                                                             | Up to 20 string key/values; echoed on the session and in every event.                  |
| `duration_seconds`                                                     | Optional. Sharpens the credit hold estimate. The charge is always the measured length. |
| `options.session_type`                                                 | `solo`, `1v1`, `2v2`, `3v3`, `4v4`, `5v5` (default).                                   |
| `options.court_type`, `options.camera_setup`, `options.has_jerseys`, … | See the [API reference](/api-reference/sessions/submit-a-video).                       |

## Limits

The API accepts the same envelope a Hooper mobile recording produces:

|            | Limit                                   |
| ---------- | --------------------------------------- |
| Size       | ≤ 10 GB                                 |
| Resolution | ≤ 1080p (1920×1080, either orientation) |
| Format     | MP4 or MOV, H.264 preferred             |

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.

## Lifecycle

```
queued  →  processing  →  processed
                       ↘  failed
```

| `status`     | Meaning                                                                                |
| ------------ | -------------------------------------------------------------------------------------- |
| `queued`     | Accepted; waiting for a worker.                                                        |
| `processing` | A worker has the video.                                                                |
| `processed`  | Done. `highlights`, `shots`, `players`, `totals` are populated; `cost_cents` is final. |
| `failed`     | Something went wrong; see `error`. The hold is voided — nothing is charged.            |

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

```json theme={null}
{
  "id": "sess_18234", "object": "session", "created": 1756224000, "status": "processed",
  "group": "grp_412",
  "source": { "type": "veo", "url": "https://app.veo.co/matches/…" },
  "metadata": { "external_match_id": "abc-123" },
  "run_version": 1, "duration_seconds": 5412, "cost_cents": 752,

  "highlights": { "object": "list", "total_count": 42, "has_more": false,
                  "url": "/v1/sessions/sess_18234/highlights",
                  "data": [ { "id": "hl_9f2c", "object": "highlight", "shot": "shot_a1b2", "player": "plr_5",
                              "length_seconds": 8.5, "is_lowlight": false,
                              "video": { "default": "…", "horizontal": "…", "1080p": "…", "720p": "…", "480p": "…", "360p": "…", "240p": "…", "144p": "…" },
                              "preview": { "default": "…", "horizontal": "…" } } ] },

  "shots": { "object": "list", "total_count": 118, "has_more": true,
             "url": "/v1/sessions/sess_18234/shots",
             "data": [ { "id": "shot_a1b2", "object": "shot", "index": 7,
                         "start_seconds": 412.0, "end_seconds": 420.5,
                         "made": true, "points": 3, "is_three_point": true, "is_free_throw": false,
                         "location": { "shot_image_normed": [0.42, 0.61] },
                         "excitement_score": 0.87,
                         "players": { "scorer": "plr_5", "assister": "plr_3", "rebounder": null },
                         "highlight": "hl_9f2c" } ] },

  "players": { "object": "list", "total_count": 10, "has_more": false,
               "url": "/v1/sessions/sess_18234/players",
               "data": [ { "id": "plr_5", "object": "player", "name": "Ana", "jersey": "23",
                           "stats": { "points": 22, "fg_2pt_att": 9, "fg_2pt_make": 5, "fg_3pt_att": 6, "fg_3pt_make": 4,
                                      "ft_att": 0, "ft_make": 0, "assist": 3, "rebound": 7 } } ] },

  "totals": { "points": 88, "fg_2pt_att": 41, "…": "…" },
  "video": { "url": "…", "preview_url": "…", "1080p": "…", "720p": "…", "360p": "…", "width": 1920, "height": 1080, "fps": 30 },
  "artifacts": { "actions": "…jsonl", "balls": "…jsonl", "hoops": "…jsonl", "clusters": "…jsonl" },
  "error": null
}
```

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 `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.
