Recetas
Cada receta hace tres cosas: pedir /playback, reproducir hls_url,
recuperarse con la regla de recargar. Reemplazá
showError y showReload por tu propia UI.
Tu backend (Strict o videos privados)
La secret key vive en tu servidor. Tu app le pregunta a tu backend, tu backend le pregunta a 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 }: pásaselo al player tal cual
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 y Safari)
import Hls from "hls.js"
// Video público fuera de Strict: llamá a XonaPlay directo. Si no, tu ruta de backend.
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: HLS nativo
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) muestra err.message; ofrecé Recargar solo para los códigos
reintentables.
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) // tu llamado a /playback (backend o directo)
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() } // falla a mitad de camino, p. ej. un 403 en un segmento
.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)
}
}Decodificá el JSON con keyDecodingStrategy = .convertFromSnakeCase así
hls_url se convierte en 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) } // tu llamado a /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)
}
}Con kotlinx.serialization, mapeá hls_url con @SerialName("hls_url") o
fijá 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) // tu backend o el endpoint público
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); // tu llamado a /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
El renderer es un navegador: usá la receta web. Guardá la secret key en el
proceso principal y exponé getPlayback al renderer vía IPC.
Subir un video
Las subidas van directo al storage; XonaPlay nunca proxea tus bytes.
# 1. Abrí una subida: te dan URLs de parte para hacer PUT
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"}'La respuesta es así:
{
"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: partí tu archivo en chunks de esta cantidad de bytes (el último chunk suele ser más chico).urls: una URL de PUT pre-firmada por parte, en orden — hacé PUT del chunkN(empezando en 1) aurls[N - 1]. Con la concurrencia que quieras.max_height: el tope de resolución de tu plan; el pipeline recorta cualquier cosa más alta que esto.
En el request, delivery es opcional: hls (default) renderiza HLS adaptativo;
original deja el archivo sin tocar. Ver Original o HLS.
// Node 18+: partir el archivo y hacer PUT de cada parte
import { open } from "node:fs/promises"
async function uploadParts(filePath, { partSize, urls }) {
const file = await open(filePath, "r")
const etags = [] // guardalos: el llamado a complete los necesita como respaldo
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. Hacé PUT de cada parte del archivo a su URL (el snippet de Node de arriba lo hace)
# 3. Cerrala; con delivery "hls", la codificación arranca enseguida
curl -X POST https://api.xonaplay.com/v1/videos/uploads/{SESSION_ID}/complete \
-H "Authorization: Bearer $XONAPLAY_SECRET_KEY"Si tu storage no puede listar las partes de una subida multiparte, mandá en
su lugar el header ETag de respuesta que juntaste en cada PUT:
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…\""}]}'Cerrar la subida devuelve un status: queued si delivery es hls (empieza
el renderizado), o ready si es original (reproducible al toque). Con hls,
te llega un webhook video.ready cuando termina la
codificación.
Importar desde una URL
Le pasás una URL a XonaPlay y ella descarga el archivo por vos. Los bytes nunca pasan por tu servidor. Sirve para generadores de IA que devuelven un link al clip.
curl -X POST https://api.xonaplay.com/v1/videos/imports \
-H "Authorization: Bearer $XONAPLAY_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"title":"Clip generado","source_url":"https://files.example.com/clip.mp4","delivery":"original"}'{
"status": "ok",
"data": {
"video_id": "0f8e2c7a-…",
"import_id": "c3a9f1d2-…",
"status": "uploading"
}
}source_url: una URL públicahttps. Se siguen los redirects.file_name(opcional): el nombre del archivo original.delivery(opcional):hls(default) uoriginal, igual que en las subidas.
XonaPlay descarga el archivo y lo guarda sin tocar. Esperá el webhook
video.ready (o video.failed) antes de reproducirlo.
Aplican los límites de tu plan (402 PLAN_LIMIT). Los errores transitorios se
reintentan, hasta 3 intentos.
Clips generados por IA: llamá a la importación apenas el generador te
devuelva la URL, mientras el link todavía funciona. La mayoría de los
generadores devuelve un mp4: con delivery: "original", queda reproducible
apenas termina la descarga.
Descargar el original
Obtené el archivo original tal cual se subió. Requiere secret key o sesión; nunca funciona de forma anónima.
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
}
}El url vale una hora y entrega el archivo con su file_name.
409 VIDEO_NOT_READY: la subida todavía está en curso. Reintentá más tarde.404 ORIGINAL_NOT_AVAILABLE: el original fue borrado.422 NOT_SUPPORTED_FOR_BYO: el video vive en tu propio servidor.
Verificar un 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)
}Usá el body crudo del request, antes de cualquier parseo de JSON.