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