Recipes

Every recipe does three things: ask /playback, play hls_url, recover with the reload rule. 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.

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