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