# Quickstart

XonaPlay encodes your videos to HLS and gives you a link any HLS player can
play: hls.js, Safari, AVPlayer, ExoPlayer, VLC or your own. There is no SDK.
You make one HTTP call and hand the URL to the player.

## 1. Get a playback URL

```bash
curl https://api.xonaplay.com/v1/videos/{VIDEO_ID}/playback
```

```json
{
  "status": "ok",
  "data": {
    "video_id": "0f8e2c7a-5d1b-4c3e-9a77-2b1f0c9d4e21",
    "short_id": "9KfR2mQx",
    "hls_url": "https://play.xonaplay.com/hls/t/Xk3qL9…/master.m3u8",
    "original_url": "https://…",
    "poster_url": "https://play.xonaplay.com/hls/t/Xk3qL9…/poster",
    "thumbnails_url": "https://play.xonaplay.com/hls/t/Xk3qL9…/thumbnails/thumbnails.vtt",
    "embed_url": "https://xonaplay.com/v/9KfR2mQx",
    "expires_at": "2026-10-09T03:00:00.000Z",
    "security": "protected",
    "delivery": "hls",
    "duration_sec": 3600
  }
}
```

`{VIDEO_ID}` is the video `id` or its `short_id` (the one in
`xonaplay.com/v/{short_id}`).

`original_url` is the file exactly as you uploaded it. Play `hls_url` unless you
know your player supports the format; see [Original or HLS](/playback#original-or-hls).

- **Public video, workspace on Open or Protected:** call it without a key, from
  your site or your app.
- **Private video, or workspace on Strict:** call it from your backend with your
  secret key. See [Authentication](#authentication).

## 2. Play it

```js
import Hls from "hls.js"

const video = document.querySelector("video")
const { data } = await fetch(playbackUrl).then((r) => r.json())

if (video.canPlayType("application/vnd.apple.mpegurl")) {
  video.src = data.hls_url // Safari
} else {
  const hls = new Hls()
  hls.loadSource(data.hls_url)
  hls.attachMedia(video)
}
```

Audio tracks and subtitles come inside the playlist, so your player shows them
without extra work.

## 3. Recover when it fails

Links expire on purpose. When the player hits a fatal error, ask for a new
`hls_url` and resume at the same second. Read [Playback](/playback#reload) for
the exact rule; every recipe already includes it.

## Authentication {#authentication}

Create a secret key in the dashboard: **Settings → API**. You see it once.

```bash
curl https://api.xonaplay.com/v1/videos/{VIDEO_ID}/playback \
  -H "Authorization: Bearer $XONAPLAY_SECRET_KEY"
```

- Keep the key on your server. Never ship it in a web page or a mobile app.
- A key acts on its whole workspace. Revoke it from the dashboard at any time.

## No code? Use the iframe

```html
<iframe src="https://xonaplay.com/v/9KfR2mQx" allow="autoplay; fullscreen" allowfullscreen></iframe>
```

The `embed_url` from `/playback` already carries our player, ads and
analytics, and it refreshes its own link when it expires.

---

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

---

# Security

Each workspace picks a level in **Settings → Security**. A single video can
override it (for example, an Open trailer in a Strict course).

No level stops screen recording. Nothing does — not even DRM. The levels stop
the cheap ways to steal a video: copying a link, scraping playlists, sharing a
URL on a forum.

## At a glance

| Level               | Link expires                          | Bound to              |
| ------------------- | ------------------------------------- | --------------------- |
| Open                | Never                                 | —                     |
| Protected (default) | Video length + 1 h                    | —                     |
| Strict              | 10 min to start, then length + 30 min | The viewer's network  |
| Maximum             | Same as Strict                        | The viewer's exact IP |

### Open

- **Who gets a link:** anyone.
- **Old `/hls/v/…` links:** keep working.
- **Stops:** nothing.

### Protected (default)

- **Who gets a link:** anyone, for public videos; a key or a session for private ones.
- **Segments:** signed per link.
- **Stops:** links pasted elsewhere working forever, and lazy scrapers.
- **Does not stop:** sharing a link while it is valid, yt-dlp, screen recording.
- **Can break:** a pause longer than the link. The player recovers on its own.

### Strict

- **Who gets a link:** only your backend (secret key) or a signed-in user.
- **Bound to:** the network of whoever opens it first (/24 on IPv4, /64 on IPv6). One network change is allowed (Wi‑Fi ↔ 4G).
- **Stops:** passing a link to someone else.
- **Does not stop:** yt-dlp inside the session, a relay, screen recording.
- **Can break:** casting from another network, rotating VPNs, iCloud Private Relay, two network changes. Reload fixes it.

### Maximum

- **Who gets a link:** same as Strict.
- **Segments:** served through our proxy, bound to the viewer's exact IP.
- **Stops:** downloading segments from another machine.
- **Does not stop:** a relay on the viewer's own machine, screen recording.
- **Can break:** any network change (Wi‑Fi ↔ 4G). Reload fixes it.

Maximum is enabled per workspace by the XonaPlay team, because every byte goes
through our servers.

## Allowed domains

On every level, **allowed domains** decide which sites can embed our player
iframe. They also filter keyless `/playback` calls made from a browser (the
`Origin` header). Native apps send no `Origin`, so they are not affected.

## Revoke every link

**Settings → Security → Revoke all links** kills every link already handed out,
on every level except Open. Playlists stop working at once; segments that were
already downloaded keep playing until the player asks for the next one.
Changing the level does the same.

## Bring your own server

Videos stored on your own engine ("only on my servers") are validated by that
engine with the same links and the same rules.

---

# Recipes

Every recipe does three things: **ask** `/playback`, **play** `hls_url`,
**recover** with the [reload rule](/playback#reload). Replace `showError` and
`showReload` with your own UI.

## Your backend (Strict or private videos)

The secret key lives on your server. Your app asks your backend, your backend
asks XonaPlay.

```js
// Node 18+ / Bun / Deno
export async function getPlayback(videoId, viewerId) {
  const url = new URL(`https://api.xonaplay.com/v1/videos/${videoId}/playback`)
  if (viewerId) url.searchParams.set("viewer_id", viewerId)
  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.XONAPLAY_SECRET_KEY}` },
  })
  const body = await res.json()
  if (body.status !== "ok") throw body // { code, message }: pass it to your player as is
  return body.data
}
```

```python
# Python 3 + requests
import os, requests

def get_playback(video_id, viewer_id=None):
    r = requests.get(
        f"https://api.xonaplay.com/v1/videos/{video_id}/playback",
        params={"viewer_id": viewer_id} if viewer_id else None,
        headers={"Authorization": f"Bearer {os.environ['XONAPLAY_SECRET_KEY']}"},
        timeout=10,
    )
    body = r.json()
    if body["status"] != "ok":
        raise RuntimeError(body)  # {"code", "message"}
    return body["data"]
```

```php
// PHP 8
function xonaplay_playback(string $videoId): array {
    $ch = curl_init("https://api.xonaplay.com/v1/videos/{$videoId}/playback");
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('XONAPLAY_SECRET_KEY')],
        CURLOPT_TIMEOUT => 10,
    ]);
    $body = json_decode(curl_exec($ch), true);
    if (($body['status'] ?? '') !== 'ok') throw new RuntimeException(json_encode($body));
    return $body['data'];
}
```

## Web (hls.js and Safari)

```js
import Hls from "hls.js"

// Public video outside Strict: call XonaPlay directly. Otherwise, your backend route.
const playbackUrl = (id) => `https://api.xonaplay.com/v1/videos/${id}/playback`
let hls = null
let lastRetry = 0

async function load(video, id, at = 0) {
  const body = await fetch(playbackUrl(id)).then((r) => r.json())
  if (body.status !== "ok") throw body
  hls?.destroy()
  hls = null
  if (Hls.isSupported()) {
    hls = new Hls({ startPosition: at })
    hls.loadSource(body.data.hls_url)
    hls.attachMedia(video)
    hls.on(Hls.Events.ERROR, (_, e) => e.fatal && recover(video, id))
  } else {
    video.src = body.data.hls_url // Safari: native HLS
    video.addEventListener("loadedmetadata", () => (video.currentTime = at), { once: true })
    video.onerror = () => recover(video, id)
  }
  video.play().catch(() => {})
}

function recover(video, id) {
  const at = video.currentTime
  if (Date.now() - lastRetry < 60_000) return showReload(() => load(video, id, at).catch(showError))
  lastRetry = Date.now()
  load(video, id, at).catch(showError)
}

load(document.querySelector("video"), "9KfR2mQx").catch(showError)
```

`showError(err)` shows `err.message`; offer Reload only for retryable codes.

## iOS (AVPlayer)

```swift
import AVFoundation
import Combine

final class XonaPlayback {
  let player = AVPlayer()
  let videoId: String
  private var lastRetry = Date.distantPast
  private var subs = Set<AnyCancellable>()
  init(videoId: String) { self.videoId = videoId }

  func reload(at time: CMTime = .zero) {
    Task { @MainActor in
      do {
        let p = try await getPlayback(videoId) // your call to /playback (backend or direct)
        load(URL(string: p.hlsUrl)!, at: time)
      } catch { showError(error) }
    }
  }

  private func load(_ url: URL, at time: CMTime) {
    let item = AVPlayerItem(url: url)
    subs = []
    item.publisher(for: \.status).receive(on: DispatchQueue.main).sink { [weak self] status in
      if status == .readyToPlay, time > .zero { self?.player.seek(to: time) }
      if status == .failed { self?.recover() }
    }.store(in: &subs)
    NotificationCenter.default.publisher(for: .AVPlayerItemFailedToPlayToEndTime, object: item)
      .receive(on: DispatchQueue.main)
      .sink { [weak self] _ in self?.recover() } // fails mid-way, e.g. a 403 on a segment
      .store(in: &subs)
    player.replaceCurrentItem(with: item)
    player.play()
  }

  private func recover() {
    subs = []
    let at = player.currentTime()
    if Date().timeIntervalSince(lastRetry) < 60 { return showReload { self.reload(at: at) } }
    lastRetry = Date()
    reload(at: at)
  }
}
```

Decode the JSON with `keyDecodingStrategy = .convertFromSnakeCase` so
`hls_url` becomes `hlsUrl`.

## Android (Media3 / ExoPlayer)

```kotlin
// deps: androidx.media3:media3-exoplayer, media3-exoplayer-hls, media3-ui
class XonaPlayback(context: Context, private val videoId: String, private val scope: CoroutineScope) {
    val player = ExoPlayer.Builder(context).build() // playerView.player = player
    private var lastRetry = -60_000L

    init {
        player.addListener(object : Player.Listener {
            override fun onPlayerError(error: PlaybackException) = recover()
        })
    }

    fun reload(atMs: Long = 0L) {
        scope.launch {
            runCatching { getPlayback(videoId) } // your call to /playback
                .onSuccess { p ->
                    player.setMediaItem(MediaItem.fromUri(p.hlsUrl), atMs)
                    player.prepare()
                    player.play()
                }
                .onFailure { showError(it) }
        }
    }

    private fun recover() {
        val at = player.currentPosition
        val now = SystemClock.elapsedRealtime()
        if (now - lastRetry < 60_000) return showReload { reload(at) }
        lastRetry = now
        reload(at)
    }
}
```

With kotlinx.serialization, map `hls_url` with `@SerialName("hls_url")` or set
`namingStrategy = JsonNamingStrategy.SnakeCase`.

## React Native (react-native-video)

```tsx
import Video, { VideoRef } from "react-native-video"

export function XonaVideo({ videoId }: { videoId: string }) {
  const ref = useRef<VideoRef>(null)
  const [src, setSrc] = useState<string | null>(null)
  const at = useRef(0)
  const lastRetry = useRef(0)

  const reload = useCallback(async () => {
    try {
      const p = await getPlayback(videoId) // your backend or the public endpoint
      setSrc(p.hls_url)
    } catch (e) {
      showError(e)
    }
  }, [videoId])

  useEffect(() => {
    reload()
  }, [reload])

  const onError = () => {
    if (Date.now() - lastRetry.current < 60_000) return showReload(reload)
    lastRetry.current = Date.now()
    reload()
  }

  if (!src) return null
  return (
    <Video
      ref={ref}
      source={{ uri: src }}
      onProgress={(e) => (at.current = e.currentTime)}
      onLoad={() => at.current > 0 && ref.current?.seek(at.current)}
      onError={onError}
      controls
      style={{ width: "100%", aspectRatio: 16 / 9 }}
    />
  )
}
```

## Flutter (video_player)

```dart
Future<void> reload({Duration at = Duration.zero}) async {
  final p = await getPlayback(videoId); // your call to /playback
  final next = VideoPlayerController.networkUrl(Uri.parse(p['hls_url']));
  await next.initialize();
  await next.seekTo(at);
  next.addListener(() {
    if (next.value.hasError) recover();
  });
  await controller?.dispose();
  setState(() => controller = next..play());
}

void recover() {
  final at = controller?.value.position ?? Duration.zero;
  if (DateTime.now().difference(lastRetry).inSeconds < 60) return showReload(() => reload(at: at));
  lastRetry = DateTime.now();
  reload(at: at);
}
```

## Electron

The renderer is a browser: use the web recipe. Keep the secret key in the main
process and expose `getPlayback` to the renderer through IPC.

## Upload a video

Uploads go straight to storage; XonaPlay never proxies your bytes.

```bash
# 1. Open an upload: you get part URLs to PUT to
curl -X POST https://api.xonaplay.com/v1/videos/uploads \
  -H "Authorization: Bearer $XONAPLAY_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title":"Lesson 1","file_name":"lesson-1.mp4","size_bytes":734003200,"content_type":"video/mp4","delivery":"hls"}'
```

The response looks like this:

```json
{
  "status": "ok",
  "data": {
    "video_id": "0f8e2c7a-…",
    "session_id": "6b1d9a4e-…",
    "part_size": 52428800,
    "urls": ["https://…/part-1?X-Amz-…", "https://…/part-2?X-Amz-…"],
    "max_height": 1080
  }
}
```

- `part_size`: split your file into chunks of this many bytes (the last
  chunk is usually smaller).
- `urls`: one pre-signed PUT URL per part, in order — PUT chunk `N` (1-based)
  to `urls[N - 1]`. Any concurrency you like.
- `max_height`: the resolution cap of your plan; the pipeline downsizes
  anything taller than this.

`delivery` is optional in the request: `hls` (default) renders adaptive HLS,
`original` keeps the file untouched. See [Original or HLS](/playback#original-or-hls).

```js
// Node 18+: split the file and PUT every part
import { open } from "node:fs/promises"

async function uploadParts(filePath, { partSize, urls }) {
  const file = await open(filePath, "r")
  const etags = [] // keep these: the complete call needs them as a fallback
  try {
    for (let i = 0; i < urls.length; i++) {
      const { buffer, bytesRead } = await file.read({
        buffer: Buffer.alloc(partSize),
        position: i * partSize,
      })
      const res = await fetch(urls[i], { method: "PUT", body: buffer.subarray(0, bytesRead) })
      etags.push({ part_number: i + 1, etag: res.headers.get("etag") })
    }
  } finally {
    await file.close()
  }
  return etags
}
```

```bash
# 2. PUT each part of the file to its URL (the Node snippet above does this)
# 3. Close it; with delivery "hls", encoding starts right away
curl -X POST https://api.xonaplay.com/v1/videos/uploads/{SESSION_ID}/complete \
  -H "Authorization: Bearer $XONAPLAY_SECRET_KEY"
```

If your storage backend can't list the parts of a multipart upload, send the
`ETag` response header you collected for every PUT instead:

```bash
curl -X POST https://api.xonaplay.com/v1/videos/uploads/{SESSION_ID}/complete \
  -H "Authorization: Bearer $XONAPLAY_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"parts":[{"part_number":1,"etag":"\"9bb58f26…\""},{"part_number":2,"etag":"\"c2e897…\""}]}'
```

Closing the upload returns a `status`: `queued` when `delivery` is `hls`
(rendering starts), or `ready` when it is `original` (playable right away).
With `hls`, you get a `video.ready` [webhook](/reference#webhooks) when
encoding finishes.

## Import from a URL

Give XonaPlay a URL and it downloads the file for you. The bytes never pass
through your server. This suits AI generators that return a link to the clip.

```bash
curl -X POST https://api.xonaplay.com/v1/videos/imports \
  -H "Authorization: Bearer $XONAPLAY_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title":"Generated clip","source_url":"https://files.example.com/clip.mp4","delivery":"original"}'
```

```json
{
  "status": "ok",
  "data": {
    "video_id": "0f8e2c7a-…",
    "import_id": "c3a9f1d2-…",
    "status": "uploading"
  }
}
```

- `source_url`: a public `https` URL. Redirects are followed.
- `file_name` (optional): the name of the original file.
- `delivery` (optional): `hls` (default) or `original`, as in uploads.

XonaPlay downloads the file and stores it untouched. Wait for the
`video.ready` [webhook](/reference#webhooks) (or `video.failed`) before you
play it. Plan limits apply (`402 PLAN_LIMIT`). Transient errors are retried, up
to 3 attempts.

**For AI-generated clips:** call the import as soon as the generator returns
its URL, while that link still works. Most generators return an mp4: with
`delivery: "original"` it is playable as soon as the download finishes.

## Download the original

Fetch the original file exactly as it was uploaded. It needs a secret key or a
session; it never works anonymously.

```bash
curl https://api.xonaplay.com/v1/videos/{VIDEO_ID}/download \
  -H "Authorization: Bearer $XONAPLAY_SECRET_KEY"
```

```json
{
  "status": "ok",
  "data": {
    "url": "https://…",
    "expires_at": "2026-10-10T01:00:00.000Z",
    "file_name": "lesson-1.mp4",
    "size_bytes": 734003200
  }
}
```

The `url` is valid for one hour and serves the file with its `file_name`.

- `409 VIDEO_NOT_READY`: the upload is still running. Retry later.
- `404 ORIGINAL_NOT_AVAILABLE`: the original was deleted.
- `422 NOT_SUPPORTED_FOR_BYO`: the video lives on your own server.

## Verify a webhook

```js
import { createHmac, timingSafeEqual } from "node:crypto"

export function verifyXonaplay(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")))
  const want = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest()
  const got = Buffer.from(parts.v1 ?? "", "hex")
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300
  return fresh && got.length === want.length && timingSafeEqual(got, want)
}
```

Use the raw request body, before any JSON parsing.

---

# Reference

Base URL: `https://api.xonaplay.com`. The endpoint list below is generated
from our OpenAPI file, [`/openapi.json`](/openapi.json).

## Authentication

| Credential | How                             | Where                                              |
| ---------- | ------------------------------- | -------------------------------------------------- |
| Secret key | `Authorization: Bearer xp_sk_…` | Every endpoint on this page                        |
| None       | —                               | `/playback` of public content on Open or Protected |

A key acts on its whole workspace. Create, list and revoke keys in the
dashboard under **Settings → API**. A revoked key answers `401 INVALID_KEY`.

## Conventions

- JSON in and out, always **snake_case**.
- Success: `{ "status": "ok", "data": … }`. Lists add
  `"meta": { "total": 120, "page": 1, "per_page": 20, "last_page": 6 }`.
- Error: `{ "status": "error", "code": "NOT_FOUND", "message": "…" }`. Codes
  are uppercase and stable; messages are for humans and may change.
- Validation errors are `422` with `errors: [{ "field": "title", "message": "…" }]`.
- Ids: every `{id}` accepts the UUID `id` or the `short_id`.
- Timestamps: ISO 8601 in UTC.
- Pagination: `?page=1&per_page=20` (max 100).

## Video and live objects {#video-object}

`GET /v1/videos/{id}` and `GET /v1/live/{id}` return the same object our own
dashboard uses — nothing is stripped for the public API.

- `links.player` is the public share page (the one you embed in an iframe).
  It is not a video file.
- `links.hls` only plays on Open; on every other level it is protected and
  answers `403 TOKEN_REQUIRED` without a token. To play in your own player,
  always call [`/playback`](/playback) instead.
- `custody`, `engine`, `compute_engine` and `storage_profile` describe where
  XonaPlay stores and renders the video (our storage or yours, which engine
  did the work). Safe to ignore for most integrations.
- `playback_security` is the per-video override of the workspace default:
  `inherit`, `open`, `protected`, `strict` or `maximum`.
- `delivery` is `hls` (default) or `original`: whether XonaPlay renders HLS from
  the untouched original. See [Original or HLS](/playback#original-or-hls).

## Rate limits

| Caller                    | Per minute   |
| ------------------------- | ------------ |
| Secret key                | 600 requests |
| Session cookie            | 300 requests |
| `/playback` without a key | 60 per IP    |

Over the limit you get `429 RATE_LIMITED` with a `Retry-After` header (in
seconds) telling you how long to wait before the next request. The 600/min
key bucket only applies to a _valid_ secret key; an invalid or revoked one
falls back to the lower anonymous-IP limit.

## Webhooks {#webhooks}

Register an HTTPS endpoint in **Settings → API → Webhooks** (or with
`POST /v1/webhooks`). We POST:

```json
{
  "id": "evt_5c1f0e7a9b2d4c3e",
  "type": "video.ready",
  "created_at": "2026-10-09T01:30:00.000Z",
  "data": {
    "video": {
      "id": "0f8e2c7a-…",
      "short_id": "9KfR2mQx",
      "title": "Lesson 1",
      "status": "ready",
      "duration_sec": 3600
    }
  }
}
```

| Event          | When                                       |
| -------------- | ------------------------------------------ |
| `video.ready`  | Encoding finished; the video can be played |
| `video.failed` | Encoding failed for good                   |
| `live.started` | A live stream went on air                  |
| `live.ended`   | A live stream ended                        |

`live.started` and `live.ended` carry a different key: `data.live` (a live
stream object — `id`, `short_id`, `name`, `status`, …), not `data.video`:

```json
{
  "id": "evt_8a2d4c3e9b2d4c3e",
  "type": "live.started",
  "created_at": "2026-10-09T01:32:00.000Z",
  "data": {
    "live": {
      "id": "3b7f0c1a-…",
      "short_id": "7qLp3Zxa",
      "name": "Weekly class",
      "status": "live"
    }
  }
}
```

- Signed with `X-Xona-Signature: t=<unix seconds>,v1=<hex>` where `v1` is
  HMAC-SHA256 of `"<t>.<raw body>"` with your endpoint secret (`whsec_…`,
  shown once). See the [verification recipe](/recipes#verify-a-webhook).
- Answer any 2xx within 10 seconds. Anything else is retried after 1 min,
  5 min, 30 min, 2 h and 6 h; then the delivery is marked failed.
- Deliveries can arrive more than once or out of order: use `id` to dedupe and
  re-read the video if order matters.

## Videos

List, read, update and delete videos, and get a signed playback link.

### `GET /v1/videos` {#get-v1-videos}

List videos

Videos of the authenticated workspace, merged from every engine (platform custody and BYO), newest first. Paginated.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `page` | query | string | No |  |
| `per_page` | query | string | No |  |
| `q` | query | string | No |  |
| `status` | query | string | No |  |

**Responses**

- `200` — Paginated list of videos.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).

```json
{
  "status": "",
  "data": [
    {
      "id": "",
      "short_id": "",
      "title": "",
      "slug": "",
      "description": "",
      "status": "",
      "visibility": "",
      "playback_security": "",
      "delivery": "",
      "source_type": "",
      "duration_sec": "",
      "width": "",
      "height": "",
      "size_original_bytes": "",
      "size_processed_bytes": "",
      "qualities": [
        ""
      ],
      "custody": "",
      "engine": "",
      "compute_engine": "",
      "storage_profile": "",
      "links": {
        "player": "",
        "hls": "",
        "poster": ""
      },
      "metadata": "",
      "created_at": "",
      "updated_at": ""
    }
  ],
  "meta": {
    "total": "",
    "page": "",
    "per_page": "",
    "last_page": ""
  }
}
```

### `GET /v1/videos/{video_id}` {#get-v1-videos-video-id-}

Get a video

Accepts either the `id` (UUID) or the `short_id`.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `video_id` | path | string | Yes |  |

**Responses**

- `200` — The video.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No video with that id or short_id (`NOT_FOUND`).

```json
{
  "status": "",
  "data": {
    "id": "",
    "short_id": "",
    "title": "",
    "slug": "",
    "description": "",
    "status": "",
    "visibility": "",
    "playback_security": "",
    "delivery": "",
    "source_type": "",
    "duration_sec": "",
    "width": "",
    "height": "",
    "size_original_bytes": "",
    "size_processed_bytes": "",
    "qualities": [
      ""
    ],
    "custody": "",
    "engine": "",
    "compute_engine": "",
    "storage_profile": "",
    "links": {
      "player": "",
      "hls": "",
      "poster": ""
    },
    "metadata": "",
    "created_at": "",
    "updated_at": ""
  }
}
```

### `PATCH /v1/videos/{video_id}` {#patch-v1-videos-video-id-}

Update a video

Partial update. `playback_security` overrides the workspace default for this video only; it is rejected on BYO-engine videos (`NOT_SUPPORTED_FOR_BYO`) and `maximum` requires the staff opt-in for the workspace (`MAXIMUM_NOT_ALLOWED`).

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `video_id` | path | string | Yes |  |

**Request body**

- `title`: string
- `slug`: string
- `visibility`: object
- `playback_security`: object
- `delivery`: string
- `metadata`: object

**Responses**

- `200` — The updated video.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No video with that id or short_id (`NOT_FOUND`).
- `422` — Validation failed, `playback_security=maximum` without the workspace opt-in (`MAXIMUM_NOT_ALLOWED`), or an override on a BYO video (`NOT_SUPPORTED_FOR_BYO`).

```json
{
  "status": "",
  "data": {
    "id": "",
    "short_id": "",
    "title": "",
    "slug": "",
    "description": "",
    "status": "",
    "visibility": "",
    "playback_security": "",
    "delivery": "",
    "source_type": "",
    "duration_sec": "",
    "width": "",
    "height": "",
    "size_original_bytes": "",
    "size_processed_bytes": "",
    "qualities": [
      ""
    ],
    "custody": "",
    "engine": "",
    "compute_engine": "",
    "storage_profile": "",
    "links": {
      "player": "",
      "hls": "",
      "poster": ""
    },
    "metadata": "",
    "created_at": "",
    "updated_at": ""
  }
}
```

### `DELETE /v1/videos/{video_id}` {#delete-v1-videos-video-id-}

Delete a video

`only_original=true` keeps the processed renditions and deletes only the source file.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `video_id` | path | string | Yes |  |
| `only_original` | query | string | No |  |

**Responses**

- `200` — Deleted.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No video with that id or short_id (`NOT_FOUND`).

```json
{
  "status": "",
  "data": {
    "deleted": true,
    "only_original": true
  }
}
```

### `GET /v1/videos/{video_id}/download` {#get-v1-videos-video-id-download}

Download the original file

Short-lived signed link (1 hour) to the original exactly as it was uploaded or imported — never transcoded. Requires a secret key or a session.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `video_id` | path | string | Yes |  |

**Responses**

- `200` — Download link.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No video with that id or short_id (`NOT_FOUND`), or its original was deleted (`ORIGINAL_NOT_AVAILABLE`).
- `409` — The original is still uploading (`VIDEO_NOT_READY`).
- `422` — The video lives on your own server (`NOT_SUPPORTED_FOR_BYO`).

```json
{
  "status": "",
  "data": {
    "url": "",
    "expires_at": "",
    "file_name": "",
    "size_bytes": ""
  }
}
```

### `GET /v1/videos/{video_id}/tracks` {#get-v1-videos-video-id-tracks}

List audio and subtitle tracks

Tracks that came with the original file (`origin: source`) plus the ones added through the API (`origin: manual`). `supported` is false when tracks cannot be added to this video, with the reason in `unsupported_reason`.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `video_id` | path | string | Yes |  |

**Responses**

- `200` — Tracks.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No video with that id or short_id (`NOT_FOUND`).

```json
{
  "status": "",
  "data": {
    "supported": true,
    "unsupported_reason": "",
    "tracks": [
      {
        "id": "",
        "kind": "",
        "origin": "",
        "lang": "",
        "label": "",
        "status": "",
        "error": "",
        "created_at": ""
      }
    ]
  }
}
```

### `POST /v1/videos/{video_id}/tracks/audio` {#post-v1-videos-video-id-tracks-audio}

Add an audio track

Step 1 of 2. Creates the track (`uploading`) and returns a pre-signed `PUT` for the audio file (mp3, m4a, aac, wav, flac, ogg or opus; up to 1 GiB). Upload the file, then call `POST /videos/{video_id}/tracks/{track_id}/complete`.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `video_id` | path | string | Yes |  |

**Request body**

- `lang`: string (required)
- `label`: string (required)
- `file_name`: string (required)
- `size_bytes`: object (required)
- `content_type`: string (required)

**Responses**

- `200` — Track and upload URL.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No video with that id or short_id (`NOT_FOUND`).
- `409` — The video does not accept tracks yet (`TRACKS_NOT_SUPPORTED`: not HLS, still processing, or on your own server), or the per-video limit was reached (`TRACK_LIMIT_REACHED`).
- `422` — Invalid `lang`/`label`, unsupported file type or size (`UNSUPPORTED_AUDIO_FORMAT`, `AUDIO_TOO_LARGE`).

```json
{
  "status": "",
  "data": {
    "track": {
      "id": "",
      "kind": "",
      "origin": "",
      "lang": "",
      "label": "",
      "status": "",
      "error": "",
      "created_at": ""
    },
    "upload": {
      "url": "",
      "method": "",
      "headers": "",
      "expires_at": ""
    }
  }
}
```

### `POST /v1/videos/{video_id}/tracks/subtitles` {#post-v1-videos-video-id-tracks-subtitles}

Add a subtitle track

Send the text of an `.srt` or `.vtt` file (max 2 MB) in `content`. SRT is converted to WebVTT. The track is ready immediately.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `video_id` | path | string | Yes |  |

**Request body**

- `lang`: string (required)
- `label`: string (required)
- `content`: string (required)

**Responses**

- `200` — The new track.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No video with that id or short_id (`NOT_FOUND`).
- `409` — The video does not accept tracks yet (`TRACKS_NOT_SUPPORTED`: not HLS, still processing, or on your own server), or the per-video limit was reached (`TRACK_LIMIT_REACHED`).
- `413` — The subtitle file is larger than 2 MB (`SUBTITLE_TOO_LARGE`).
- `422` — Invalid `lang`, `label` or subtitle content (`INVALID_LANG`, `INVALID_SUBTITLE`).

```json
{
  "status": "",
  "data": {
    "track": {
      "id": "",
      "kind": "",
      "origin": "",
      "lang": "",
      "label": "",
      "status": "",
      "error": "",
      "created_at": ""
    }
  }
}
```

### `DELETE /v1/videos/{video_id}/tracks/{track_id}` {#delete-v1-videos-video-id-tracks-track-id-}

Delete a track you added

Only `origin: manual` tracks can be deleted.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `video_id` | path | string | Yes |  |
| `track_id` | path | string | Yes |  |

**Responses**

- `200` — Deleted.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No video or track with that id (`NOT_FOUND`).
- `409` — The video does not accept tracks yet (`TRACKS_NOT_SUPPORTED`: not HLS, still processing, or on your own server), or the per-video limit was reached (`TRACK_LIMIT_REACHED`).

```json
{
  "status": "",
  "data": {
    "deleted": true
  }
}
```

### `POST /v1/videos/{video_id}/tracks/{track_id}/complete` {#post-v1-videos-video-id-tracks-track-id-complete}

Complete an audio track upload

Step 2 of 2. Verifies the uploaded file and starts processing (`processing` → `ready` or `failed`). Safe to call twice.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `video_id` | path | string | Yes |  |
| `track_id` | path | string | Yes |  |

**Responses**

- `200` — The track.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No video or track with that id (`NOT_FOUND`).
- `409` — The file is missing or its size does not match (`UPLOAD_MISSING`, `UPLOAD_SIZE_MISMATCH`), or the video does not accept tracks.
- `503` — No processing server is available right now (`NO_ENGINE_AVAILABLE`).

```json
{
  "status": "",
  "data": {
    "track": {
      "id": "",
      "kind": "",
      "origin": "",
      "lang": "",
      "label": "",
      "status": "",
      "error": "",
      "created_at": ""
    }
  }
}
```

## Uploads

Open a direct upload session, PUT the parts to storage, then confirm.

### `POST /v1/videos/imports` {#post-v1-videos-imports}

Import a video from a URL

XonaPlay downloads the file from `source_url` server-to-server (redirects allowed, public HTTPS only) and stores it untouched as the original — your server never handles the bytes. With `delivery: "original"` the video is ready as soon as the download finishes; with `"hls"` (default) rendering starts then. You get `video.ready` or `video.failed`.

**Request body**

- `title`: string (required)
- `source_url`: string (required)
- `file_name`: string
- `delivery`: object
- `storage_profile_id`: string
- `compute_engine_id`: string

**Responses**

- `200` — Import queued.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `402` — Plan limit reached (`PLAN_LIMIT`).
- `422` — Invalid or not allowed `source_url` (`VALIDATION_FAILED`).

```json
{
  "status": "",
  "data": {
    "video_id": "",
    "import_id": "",
    "status": ""
  }
}
```

### `POST /v1/videos/uploads` {#post-v1-videos-uploads}

Create an upload session

Starts a multipart direct upload straight to storage: creates the video (status `uploading`) and returns one pre-signed PUT URL per part.

**Request body**

- `title`: string (required)
- `file_name`: string (required)
- `size_bytes`: number (required)
- `content_type`: string
- `storage_profile_id`: string
- `compute_engine_id`: string
- `delivery`: object

**Responses**

- `200` — Upload session created.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `422` — Validation failed (e.g. missing `size_bytes`).

```json
{
  "status": "",
  "data": {
    "video_id": "",
    "session_id": "",
    "part_size": "",
    "urls": [
      ""
    ],
    "max_height": ""
  }
}
```

### `DELETE /v1/videos/uploads/{session_id}` {#delete-v1-videos-uploads-session-id-}

Abort an upload session

Cancels the multipart upload and deletes the pending video.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `session_id` | path | string | Yes |  |

**Responses**

- `200` — Aborted.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No upload session with that id (`NOT_FOUND`).

```json
{
  "status": "",
  "data": {
    "aborted": true
  }
}
```

### `POST /v1/videos/uploads/{session_id}/complete` {#post-v1-videos-uploads-session-id-complete}

Complete an upload session

Closes the multipart upload (completes it in storage), verifies the total size against what was declared and the plan limits, and queues the video for processing.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `session_id` | path | string | Yes |  |

**Request body**

- `parts`: array<object>
  - `part_number`: number (required)
  - `etag`: string (required)

**Responses**

- `200` — The upload was completed and the video queued.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No upload session with that id (`NOT_FOUND`).
- `409` — The session is already closed, or the uploaded size exceeds what was declared or the plan storage limit.

```json
{
  "status": "",
  "data": {
    "video_id": "",
    "status": ""
  }
}
```

## Live

List live streams and get a signed playback link for one that is on air.

### `GET /v1/live` {#get-v1-live}

List live streams

Live streams of the authenticated workspace, merged from every engine (platform custody and BYO), newest first. Paginated.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `page` | query | string | No |  |
| `per_page` | query | string | No |  |
| `q` | query | string | No |  |

**Responses**

- `200` — Paginated list of live streams.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).

```json
{
  "status": "",
  "data": [
    {
      "id": "",
      "short_id": "",
      "name": "",
      "slug": "",
      "status": "",
      "visibility": "",
      "playback_security": "",
      "stream_key": "",
      "ingest": {
        "primary": "",
        "backup": ""
      },
      "dvr_enabled": true,
      "dvr_window_minutes": "",
      "replay_enabled": true,
      "replay_unlisted": true,
      "viewers": "",
      "total_views": "",
      "custody": "",
      "engine": "",
      "storage_profile": "",
      "links": {
        "player": "",
        "hls": "",
        "poster": ""
      },
      "last_live_at": "",
      "created_at": "",
      "updated_at": ""
    }
  ],
  "meta": {
    "total": "",
    "page": "",
    "per_page": "",
    "last_page": ""
  }
}
```

### `GET /v1/live/{stream_id}` {#get-v1-live-stream-id-}

Get a live stream

Accepts either the `id` (UUID) or the `short_id`.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `stream_id` | path | string | Yes |  |

**Responses**

- `200` — The live stream.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No live stream with that id or short_id (`NOT_FOUND`).

```json
{
  "status": "",
  "data": {
    "id": "",
    "short_id": "",
    "name": "",
    "slug": "",
    "status": "",
    "visibility": "",
    "playback_security": "",
    "stream_key": "",
    "ingest": {
      "primary": "",
      "backup": ""
    },
    "dvr_enabled": true,
    "dvr_window_minutes": "",
    "replay_enabled": true,
    "replay_unlisted": true,
    "viewers": "",
    "total_views": "",
    "custody": "",
    "engine": "",
    "storage_profile": "",
    "links": {
      "player": "",
      "hls": "",
      "poster": ""
    },
    "last_live_at": "",
    "created_at": "",
    "updated_at": ""
  }
}
```

## Playback

Signed HLS/embed links for a video or live stream — the only way to play content.

### `GET /v1/live/{stream_id}/playback` {#get-v1-live-stream-id-playback}

Get signed playback links for a live stream

The ONLY way to play a live stream: returns a signed `hls_url`/`embed_url` pair whose shape depends on the workspace playback security level. No credential required for public `open`/`protected` content; otherwise a secret key or session is required. On a fatal player error, call this again (see the "Reload" rule in the guide) instead of reusing the old URL.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `stream_id` | path | string | Yes |  |
| `viewer_id` | query | string | No |  |

**Responses**

- `200` — Signed playback links.
- `401` — A secret key or session is required for this content (`KEY_REQUIRED`).
- `403` — Domain not allowed (`DOMAIN_NOT_ALLOWED`), workspace unavailable (`WORKSPACE_UNAVAILABLE`), or viewer banned (`ACCESS_DENIED`).
- `404` — No live stream with that id or short_id (`NOT_FOUND`).
- `409` — The stream is not live right now (`LIVE_NOT_ACTIVE`); retry later.
- `429` — Too many requests; retry after `Retry-After` (`RATE_LIMITED`).

```json
{
  "status": "",
  "data": {
    "stream_id": "",
    "short_id": "",
    "hls_url": "",
    "poster_url": "",
    "embed_url": "",
    "expires_at": "",
    "security": "",
    "status": ""
  }
}
```

### `GET /v1/videos/{video_id}/playback` {#get-v1-videos-video-id-playback}

Get signed playback links for a video

The ONLY way to play a video: returns a signed `hls_url`/`embed_url` pair whose shape depends on the workspace/video playback security level (`open`/`protected`/`strict`/`maximum`). No credential required for public `open`/`protected` content; otherwise a secret key or session is required. On a fatal player error, call this again (see the "Reload" rule in the guide) instead of reusing the old URL.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `video_id` | path | string | Yes |  |
| `viewer_id` | query | string | No |  |

**Responses**

- `200` — Signed playback links.
- `401` — A secret key or session is required for this content (`KEY_REQUIRED`).
- `403` — Domain not allowed (`DOMAIN_NOT_ALLOWED`), workspace unavailable (`WORKSPACE_UNAVAILABLE`), or viewer banned (`ACCESS_DENIED`).
- `404` — No video with that id or short_id (`NOT_FOUND`).
- `409` — The video is still processing (`VIDEO_NOT_READY`); retry later.
- `429` — Too many requests; retry after `Retry-After` (`RATE_LIMITED`).

```json
{
  "status": "",
  "data": {
    "video_id": "",
    "short_id": "",
    "hls_url": "",
    "original_url": "",
    "poster_url": "",
    "thumbnails_url": "",
    "embed_url": "",
    "expires_at": "",
    "security": "",
    "delivery": "",
    "duration_sec": ""
  }
}
```

## Webhooks

Register endpoints for video and live events, and inspect deliveries.

### `GET /v1/webhooks` {#get-v1-webhooks}

List webhook endpoints

Webhook endpoints registered for the workspace (secrets never come back in full).

**Responses**

- `200` — Webhook endpoints.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).

```json
{
  "status": "",
  "data": [
    {
      "id": "",
      "url": "",
      "description": "",
      "secret_last4": "",
      "events": [
        ""
      ],
      "enabled": true,
      "disabled_by_staff": true,
      "last_success_at": "",
      "last_failure_at": "",
      "created_at": "",
      "updated_at": ""
    }
  ]
}
```

### `POST /v1/webhooks` {#post-v1-webhooks}

Create a webhook endpoint

Registers a new endpoint. The signing secret (`whsec_…`) is returned ONCE, in this response — store it, it cannot be retrieved again.

**Request body**

- `url`: string (required)
- `description`: string
- `events`: array<object> (required)

**Responses**

- `200` — Endpoint created, with its signing secret.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `409` — Workspace already has the maximum number of endpoints (`ENDPOINT_LIMIT_REACHED`).
- `422` — Validation failed (bad `url` or `events`).

```json
{
  "status": "",
  "data": {
    "endpoint": {
      "id": "",
      "url": "",
      "description": "",
      "secret_last4": "",
      "events": [
        ""
      ],
      "enabled": true,
      "disabled_by_staff": true,
      "last_success_at": "",
      "last_failure_at": "",
      "created_at": "",
      "updated_at": ""
    },
    "secret": ""
  }
}
```

### `PATCH /v1/webhooks/{id}` {#patch-v1-webhooks-id-}

Update a webhook endpoint

Partial update. An endpoint disabled by staff cannot be re-enabled by the workspace.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | string | Yes |  |

**Request body**

- `url`: string
- `description`: string
- `events`: array<object>
- `enabled`: boolean

**Responses**

- `200` — The updated endpoint.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `403` — This endpoint was disabled by staff and cannot be re-enabled (`DISABLED_BY_STAFF`).
- `404` — No webhook endpoint with that id (`NOT_FOUND`).
- `422` — Validation failed (bad `url` or `events`).

```json
{
  "status": "",
  "data": {
    "id": "",
    "url": "",
    "description": "",
    "secret_last4": "",
    "events": [
      ""
    ],
    "enabled": true,
    "disabled_by_staff": true,
    "last_success_at": "",
    "last_failure_at": "",
    "created_at": "",
    "updated_at": ""
  }
}
```

### `DELETE /v1/webhooks/{id}` {#delete-v1-webhooks-id-}

Delete a webhook endpoint

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | string | Yes |  |

**Responses**

- `200` — Deleted.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No webhook endpoint with that id (`NOT_FOUND`).

```json
{
  "status": "",
  "data": {
    "deleted": true
  }
}
```

### `GET /v1/webhooks/{id}/deliveries` {#get-v1-webhooks-id-deliveries}

List delivery attempts

Delivery history for one endpoint, newest first. Paginated.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | string | Yes |  |
| `page` | query | string | No |  |
| `per_page` | query | string | No |  |

**Responses**

- `200` — Paginated deliveries.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No webhook endpoint with that id (`NOT_FOUND`).

```json
{
  "status": "",
  "data": [
    {
      "id": "",
      "event": "",
      "status": "",
      "attempts": "",
      "last_status_code": "",
      "last_error": "",
      "created_at": "",
      "delivered_at": ""
    }
  ],
  "meta": {
    "total": "",
    "page": "",
    "per_page": "",
    "last_page": ""
  }
}
```

### `POST /v1/webhooks/{id}/test` {#post-v1-webhooks-id-test}

Send a test event

Delivers a `webhook.test` event to the endpoint right away, so you can check your receiver.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | string | Yes |  |

**Responses**

- `200` — The test delivery.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No webhook endpoint with that id (`NOT_FOUND`).

```json
{
  "status": "",
  "data": {
    "id": "",
    "event": "",
    "status": "",
    "attempts": "",
    "last_status_code": "",
    "last_error": "",
    "created_at": "",
    "delivered_at": ""
  }
}
```

---

# For AI

Everything here exists as plain text, so an AI assistant can read it without a
browser.

| File                               | What it has                                              |
| ---------------------------------- | -------------------------------------------------------- |
| [`/llms.txt`](/llms.txt)           | Index of every page, one line each                       |
| [`/llms-full.txt`](/llms-full.txt) | All guides and the reference in one file                 |
| `/{page}.md`                       | Any page as Markdown (also with `Accept: text/markdown`) |
| [`/openapi.json`](/openapi.json)   | OpenAPI 3 of the public API                              |

**Download for your AI** gives you one `.md` with all of it plus the rules
below. Signed in, it also includes your API base URL, your workspace and a real
video id from your account — never a secret.

## Rules for the assistant

1. The secret key (`xp_sk_…`) lives on the server. Never put it in a web page,
   a mobile app or a repository.
2. Call `GET /v1/videos/{id}/playback` when the player opens, never earlier.
3. Use `hls_url` as is. Never build, cache or rewrite video URLs.
4. To play, use `hls_url`. `original_url` is the untouched file: use it only if
   your player supports its format.
5. Never download or re-host videos yourself. To get the file, call
   `GET /v1/videos/{id}/download`. To bring in a video from a URL, call
   `POST /v1/videos/imports`.
6. On a fatal player error, call `/playback` again (at most once per minute) and
   resume at the same second.
7. If `/playback` returns `status: "error"`, show its `message`. Offer Reload
   only for retryable codes: `VIDEO_NOT_READY`, `LIVE_NOT_ACTIVE`,
   `RATE_LIMITED`, 5xx. The video host itself (not `/playback`) can also
   answer `MAXIMUM_UNAVAILABLE` (503, plain text, `X-Xona-Error` header) when
   serving the manifest or a segment — it is retryable too, same reload rule.
8. All JSON is snake_case: `hls_url`, `expires_at`, `per_page`.
9. Every response is `{ "status": "ok", "data": … }` or
   `{ "status": "error", "code": …, "message": … }`. Lists add
   `meta: { total, page, per_page, last_page }`.
10. `{id}` accepts the UUID `id` or the `short_id`.
11. Pass `viewer_id` with your own user id so a leaked link can be traced.
12. Verify webhooks with `X-Xona-Signature` over the raw body, and reject
    timestamps older than five minutes.

## Suggested prompt

> Read the attached XonaPlay docs. Integrate video playback in my app using
> `/v1/videos/{id}/playback` from my backend with the secret key in the
> `XONAPLAY_SECRET_KEY` environment variable. Follow the reload rule and the
> rules for the assistant exactly. Do not add an SDK.

---

# Changelog

## 2026-10-10

- `delivery: "original"` on uploads and imports: the original is kept without
  transcoding and is played or downloaded as it is. `hls` stays the default.
- `original_url` and `delivery` in `GET /v1/videos/{id}/playback`; `delivery` on
  video objects.
- `GET /v1/videos/{id}/download`: a one-hour signed link to the original, with
  its file name.
- `POST /v1/videos/imports`: XonaPlay downloads a video from a public `https`
  URL.
- `PATCH /v1/videos/{id}` accepts `"delivery": "hls"` to render HLS for a video
  uploaded as `original` (one way only).
- `POST /v1/videos/uploads/{session_id}/complete` returns `ready` for
  `delivery: "original"` and `queued` for `hls`.

## 2026-10-09 — v1

First public version.

- `GET /v1/videos/{id}/playback` and `GET /v1/live/{id}/playback`: signed HLS
  links for any player.
- Four security levels per workspace (Open, Protected, Strict, Maximum) and per
  video overrides.
- Secret keys (`xp_sk_…`) and webhooks (`video.ready`, `video.failed`,
  `live.started`, `live.ended`).
- Direct uploads with `POST /v1/videos/uploads`.
- All JSON is snake_case.