# 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 ``` 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() 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(null) const [src, setSrc] = useState(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 (