# Playback

One rule: **nobody builds video URLs by hand.** You ask
`GET /v1/videos/{id}/playback` and give `hls_url` to your player. Our own
dashboard works the same way.

## When to call it

Call `/playback` when the player opens, never earlier. On Strict, a link has
ten minutes to start; a link prepared in advance may be dead by the time the
viewer presses play.

| You call from                     | Use it for                           |
| --------------------------------- | ------------------------------------ |
| Your web page or app, no key      | Public videos on Open or Protected   |
| Your backend, with the secret key | Private videos and Strict workspaces |

Optional query parameters:

- `viewer_id`: an opaque id of your own (your user id, a course seat). It
  travels inside the link and lands in our logs, so a leaked link can be traced
  back to whoever received it.

## What you get

| Field            | What it is                                                               |
| ---------------- | ------------------------------------------------------------------------ |
| `hls_url`        | Master playlist for the player. `null` while rendering or on `original`. |
| `original_url`   | The untouched original. Same expiry and security as `hls_url`.           |
| `poster_url`     | Still image for the player poster, or `null`.                            |
| `thumbnails_url` | WebVTT sprite for seek previews, or `null`.                              |
| `embed_url`      | Our hosted player (iframe). On Strict it only works with this exact URL. |
| `expires_at`     | When the link stops working (ISO 8601), or `null` on Open.               |
| `security`       | The level that applied: `open`, `protected`, `strict` or `maximum`.      |
| `delivery`       | `hls` or `original`. See below.                                          |
| `duration_sec`   | Video length in seconds.                                                 |

The token lives in the path (`/hls/t/{token}/…`) because AVPlayer, Chromecast
and native apps cannot send headers. Every playlist and segment under that path
is already signed; you never sign anything yourself.

## Original or HLS {#original-or-hls}

Every video keeps its original file exactly as you uploaded or imported it.
We never transcode the original. You choose a `delivery` when you upload or
import:

- `hls` (default): XonaPlay renders adaptive HLS from the original. Play
  `hls_url`.
- `original`: no transcoding at all. The original is what you play and
  download. Play `original_url`.

While HLS is still rendering, `hls_url` is `null` and `original_url` already
works. `original_url` supports byte-range requests. With `original`, `hls_url`
is always `null`.

XonaPlay guarantees `hls_url` on every device. Which players can play
`original_url` depends on its format: mp4 with H.264 plays almost everywhere,
mkv and HEVC may not. That choice is yours.

Without HLS, `poster_url` and `thumbnails_url` are `null`, unless the owner set
a custom poster.

Videos stored on your own server (BYO) return `original_url: null`.

To move a video uploaded with `original` to HLS, send `PATCH /v1/videos/{id}`
with `"delivery": "hls"`. It works one way only, and `original_url` keeps
working in the meantime.

## Expiry

| Level     | The link works for                                                  |
| --------- | ------------------------------------------------------------------- |
| Open      | Forever                                                             |
| Protected | Video length + 1 hour (2 to 24 hours)                               |
| Strict    | 10 minutes to start, then video length + 30 minutes (1 to 24 hours) |

A paused tab can outlive its link. That is expected: the player recovers.

## Reload rule {#reload}

Every recipe in these docs follows the same four steps:

1. On any fatal player error (a 403 on a segment, a network error, a decode
   error), call `/playback` again — at most once per minute — and resume at the
   same second.
2. If the response has `hls_url`, load it and seek back.
3. If the response is an error, show its `message`. Offer a **Reload** button
   only when the `code` is retryable (see the table below).
4. If the player fails again within 60 seconds, do not call anything: show
   **Reload**, which goes back to step 1.

The decision comes from the JSON of `/playback`, which every HTTP client can
read. Do not rely on HTTP headers from the video host: AVPlayer cannot read
them.

## Errors

Errors use the same envelope as the rest of the API:

```json
{ "status": "error", "code": "KEY_REQUIRED", "message": "This video needs a secret key." }
```

| `code`                         | HTTP | Retry?                                      |
| ------------------------------ | ---- | ------------------------------------------- |
| `NOT_FOUND`                    | 404  | No                                          |
| `KEY_REQUIRED`                 | 401  | No. Call it from your backend with the key. |
| `INVALID_KEY`                  | 401  | No                                          |
| `DOMAIN_NOT_ALLOWED`           | 403  | No                                          |
| `WORKSPACE_UNAVAILABLE`        | 403  | No                                          |
| `ACCESS_DENIED`                | 403  | No                                          |
| `VIDEO_NOT_READY`              | 409  | Yes, later                                  |
| `LIVE_NOT_ACTIVE`              | 409  | Yes, later                                  |
| `RATE_LIMITED`                 | 429  | Yes, after `Retry-After`                    |
| 5xx or a response without JSON | —    | Yes                                         |

The video host (not `/playback`) answers a dead link with a plain 401/403/503
and an `X-Xona-Error` header — `TOKEN_EXPIRED`, `TOKEN_REVOKED`,
`NETWORK_CHANGED`, `MAXIMUM_UNAVAILABLE`… It is there for debugging; your
player just follows the reload rule, and `MAXIMUM_UNAVAILABLE` (503) is
retryable like the rest.

## Live

Live streams use the same contract:

```bash
curl https://api.xonaplay.com/v1/live/{STREAM_ID}/playback
```

`LIVE_NOT_ACTIVE` means the stream is not on air yet. Retry later or show a
countdown.