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.
// 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 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 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)
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)
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)
// 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)
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)
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.
# 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:
{
"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 chunkN(1-based) tourls[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.
// 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
}# 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:
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.
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"}'{
"status": "ok",
"data": {
"video_id": "0f8e2c7a-…",
"import_id": "c3a9f1d2-…",
"status": "uploading"
}
}source_url: a publichttpsURL. Redirects are followed.file_name(optional): the name of the original file.delivery(optional):hls(default) ororiginal, 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.
curl https://api.xonaplay.com/v1/videos/{VIDEO_ID}/download \
-H "Authorization: Bearer $XONAPLAY_SECRET_KEY"{
"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
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.