Receitas
Cada receita faz três coisas: pedir /playback, reproduzir hls_url,
recuperar-se com a regra de recarregar. Substitua
showError e showReload pela sua própria UI.
Seu backend (Strict ou vídeos privados)
A secret key vive no seu servidor. Seu app pergunta ao seu backend, seu backend pergunta à 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 }: repasse ao player como está
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 e Safari)
import Hls from "hls.js"
// Vídeo público fora do Strict: chame a XonaPlay direto. Senão, a rota do seu 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) mostra err.message; ofereça Recarregar só para os códigos
reexecutáveis.
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) // sua chamada a /playback (backend ou direta)
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() } // falha no meio do caminho, ex. um 403 em um 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)
}
}Decodifique o JSON com keyDecodingStrategy = .convertFromSnakeCase para que
hls_url vire 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) } // sua chamada 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)
}
}Com kotlinx.serialization, mapeie hls_url com @SerialName("hls_url") ou
defina 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) // seu backend ou o 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); // sua chamada 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
O renderer é um navegador: use a receita web. Mantenha a secret key no
processo principal e exponha getPlayback ao renderer via IPC.
Enviar um vídeo
Os envios vão direto para o armazenamento; a XonaPlay nunca faz proxy dos seus bytes.
# 1. Abra um envio: você recebe URLs de parte para fazer 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"}'A resposta é assim:
{
"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: divida seu arquivo em pedaços desse tamanho em bytes (o último pedaço costuma ser menor).urls: uma URL de PUT pré-assinada por parte, em ordem — faça PUT do pedaçoN(começando em 1) emurls[N - 1]. Com a concorrência que quiser.max_height: o teto de resolução do seu plano; o pipeline reduz qualquer coisa maior que isso.
No request, delivery é opcional: hls (padrão) renderiza HLS adaptativo;
original mantém o arquivo intacto. Veja Original ou HLS.
// Node 18+: dividir o arquivo e fazer PUT de cada parte
import { open } from "node:fs/promises"
async function uploadParts(filePath, { partSize, urls }) {
const file = await open(filePath, "r")
const etags = [] // guarde-os: a chamada de complete os usa 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. Faça PUT de cada parte do arquivo na sua URL (o snippet de Node acima faz isso)
# 3. Feche o envio; com delivery "hls", a codificação começa na hora
curl -X POST https://api.xonaplay.com/v1/videos/uploads/{SESSION_ID}/complete \
-H "Authorization: Bearer $XONAPLAY_SECRET_KEY"Se o seu armazenamento não conseguir listar as partes de um envio
multiparte, envie em vez disso o header ETag de resposta que você
coletou em 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…\""}]}'Fechar o envio retorna um status: queued quando delivery é hls (a
renderização começa), ou ready quando é original (reproduzível na hora). Com
hls, chega um webhook video.ready quando a codificação
termina.
Importar de uma URL
Você passa uma URL e a XonaPlay baixa o arquivo. Os bytes nunca passam pelo seu servidor. É ideal para geradores de IA que devolvem um link do clipe.
curl -X POST https://api.xonaplay.com/v1/videos/imports \
-H "Authorization: Bearer $XONAPLAY_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"title":"Clipe gerado","source_url":"https://files.example.com/clip.mp4","delivery":"original"}'{
"status": "ok",
"data": {
"video_id": "0f8e2c7a-…",
"import_id": "c3a9f1d2-…",
"status": "uploading"
}
}source_url: uma URL públicahttps. Redirecionamentos são seguidos.file_name(opcional): o nome do arquivo original.delivery(opcional):hls(padrão) ouoriginal, como nos envios.
A XonaPlay baixa o arquivo e o guarda intacto. Espere o webhook
video.ready (ou video.failed) antes de reproduzir.
Os limites do seu plano se aplicam (402 PLAN_LIMIT). Erros transitórios são
repetidos, em até 3 tentativas.
Clipes gerados por IA: chame a importação assim que o gerador devolver a URL,
enquanto o link ainda funciona. A maioria dos geradores devolve um mp4: com
delivery: "original", ele fica reproduzível assim que o download termina.
Baixar o original
Baixe o arquivo original exatamente como foi enviado. Exige secret key ou sessão; 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
}
}O url vale por uma hora e entrega o arquivo com o seu file_name.
409 VIDEO_NOT_READY: o envio ainda está em andamento. Tente de novo mais tarde.404 ORIGINAL_NOT_AVAILABLE: o original foi excluído.422 NOT_SUPPORTED_FOR_BYO: o vídeo está no seu próprio servidor.
Verificar um 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 o body bruto da requisição, antes de qualquer parsing de JSON.