Get started · about 10 minutes
Quickstart
From zero to a protected video playing on your own page: create a key, call the API, upload by URL, wait for encoding, and embed it for a signed-in viewer.
On this page
Before you start#
- A Wolvy account with an active plan. Uploads answer
422 no_active_planwithout one. - A server to run code on. The API refuses to be called from a browser: it sends no CORS headers, because an API key in a browser is a leaked key.
- A video file at a public https URL. Wolvy downloads it for you — there is no file-upload endpoint.
- If your account restricts embeds to certain domains (Settings → Protection → Allowed domains), the site you will embed on must be on that list.
1 · Create an API key#
- Open the dashboardGo to Settings → API and create a key. Name it after where it will run — Production backend, Staging.
- Pick scopesFor this guide:
account:read,videos:writeandplayback:sign. The default set is read-only plus signing, so tickvideos:writeyourself. - Copy it onceThe full key (
wv_live_+ 32 characters) is shown only at creation. Wolvy stores a hash and cannot show it again.
# .env — never commit this file
WOLVY_API_KEY=wv_live_… 2 · Make your first call#
GET /v1/account is the natural first request: it proves the key works and shows its scopes and rate limits.
curl "https://api.wolvy.net/v1/account" \
-H "Authorization: Bearer $WOLVY_API_KEY" const res = await fetch("https://api.wolvy.net/v1/account", {
headers: { Authorization: `Bearer ${process.env.WOLVY_API_KEY}` },
});
const account = await res.json();
if (!res.ok) throw new Error(`${res.status} ${account.error.code}`); import os
import requests
res = requests.get(
"https://api.wolvy.net/v1/account",
headers={"Authorization": f"Bearer {os.environ['WOLVY_API_KEY']}"},
timeout=30,
)
account = res.json()
if not res.ok:
raise RuntimeError(f"{res.status_code} {account['error']['code']}") <?php
$ch = curl_init('https://api.wolvy.net/v1/account');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('WOLVY_API_KEY')],
]);
$account = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
throw new RuntimeException("$status {$account['error']['code']}");
} {
"object": "account",
"id": 42,
"name": "Acme Academy",
"status": "active",
"plan": { "name": "DRM Pro", "type": "fixed", "protection": "drm", "end_date": "2027-03-02T00:00:00+00:00" },
"api_key": { "id": 17, "prefix": "wv_live_8kQ2", "last_four": "x7Pa", "scopes": ["account:read", "videos:read", "videos:write", "playback:sign"] },
"rate_limits": { "per_minute": 120, "per_day": 10000, "requests_today": 1 }
}
Note id — that is your account id, and it appears (base64-encoded) in every embed URL.
A 401 invalid_api_key means the key was mistyped or revoked; 403 insufficient_scope names the scope the key is missing.
3 · Upload a video by URL#
Give Wolvy a public https link to the file. The request returns straight away with 202 Accepted; downloading and encoding happen in the background.
curl -X POST "https://api.wolvy.net/v1/videos" \
-H "Authorization: Bearer $WOLVY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 5b6f1c2e-9a0d-4f3e-8c71-2d4b6a9e0f13" \
-d '{
"source_url": "https://media.example.com/uploads/onboarding.mp4",
"title": "Onboarding"
}' import { randomUUID } from "node:crypto";
const res = await fetch("https://api.wolvy.net/v1/videos", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WOLVY_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": randomUUID(), // reuse the same key when you retry
},
body: JSON.stringify({
source_url: "https://media.example.com/uploads/onboarding.mp4",
title: "Onboarding",
}),
});
const video = await res.json();
if (!res.ok) throw new Error(`${res.status} ${video.error.code}`); import os
import uuid
import requests
res = requests.post(
"https://api.wolvy.net/v1/videos",
headers={
"Authorization": f"Bearer {os.environ['WOLVY_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()), # reuse the same key when you retry
},
json={
"source_url": "https://media.example.com/uploads/onboarding.mp4",
"title": "Onboarding",
},
timeout=30,
)
video = res.json()
if not res.ok:
raise RuntimeError(f"{res.status_code} {video['error']['code']}") <?php
$ch = curl_init('https://api.wolvy.net/v1/videos');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('WOLVY_API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: ' . bin2hex(random_bytes(16)), // reuse the same key when you retry
],
CURLOPT_POSTFIELDS => json_encode([
'source_url' => 'https://media.example.com/uploads/onboarding.mp4',
'title' => 'Onboarding',
]),
]);
$video = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
throw new RuntimeException("$status {$video['error']['code']}");
} {
"id": "a1b2c3d4e5f60718293a",
"object": "video",
"title": "Onboarding",
"description": "Week one.",
"status": "queued",
"protection": "drm",
"duration": null,
"size_bytes": 0,
"folder_id": null,
"embed_url": "https://embed.wolvy.stream/a1b2c3d4e5f60718293a/NDI=",
"direct_play_url": null,
"hls_url": null,
"poster_url": null,
"animated_poster_url": null,
"original_url": "https://media.example.com/uploads/onboarding.mp4",
"captions": [],
"chapters": [],
"moments": [],
"created_at": "2026-09-15T09:30:00+00:00",
"updated_at": "2026-09-15T09:30:00+00:00"
} Save the returned id. There is no encryption or resolutions field — Multi-DRM or ClearKey comes from your plan, and sending either is a 400 unknown_parameter.
4 · Wait until it plays#
The video moves through queued → processing → playable → ready. playable means the first resolution is done and
viewers can already watch while the rest encode. Poll the lightweight status endpoint with backoff:
const API = "https://api.wolvy.net/v1";
const headers = { Authorization: `Bearer ${process.env.WOLVY_API_KEY}` };
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Run this in a background job, not inside a web request.
export async function waitUntilPlayable(videoId, { timeoutMs = 60 * 60 * 1000 } = {}) {
const deadline = Date.now() + timeoutMs;
let delay = 10_000;
while (Date.now() < deadline) {
const res = await fetch(`${API}/videos/${encodeURIComponent(videoId)}/status`, { headers });
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.error.code}`);
if (body.status === "playable" || body.status === "ready") return body;
if (body.status === "failed") throw new Error(`Encoding failed for ${videoId}`);
await sleep(delay);
delay = Math.min(delay * 1.5, 60_000); // 10 s, 15 s, 22 s … up to 60 s
}
throw new Error("Timed out waiting for the video");
} import os
import time
import requests
API = "https://api.wolvy.net/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['WOLVY_API_KEY']}"}
def wait_until_playable(video_id, timeout=3600):
"""Run this in a background job, not inside a web request."""
deadline, delay = time.monotonic() + timeout, 10
while time.monotonic() < deadline:
res = requests.get(f"{API}/videos/{video_id}/status", headers=HEADERS, timeout=30)
body = res.json()
if not res.ok:
raise RuntimeError(f"{res.status_code} {body['error']['code']}")
if body["status"] in ("playable", "ready"):
return body
if body["status"] == "failed":
raise RuntimeError(f"Encoding failed for {video_id}")
time.sleep(delay)
delay = min(delay * 1.5, 60)
raise TimeoutError("Timed out waiting for the video") <?php
// Run this from a queue worker or cron, not inside a page request.
function wolvy_wait_until_playable(string $videoId, int $timeout = 3600): array
{
$deadline = time() + $timeout;
$delay = 10;
while (time() < $deadline) {
$ch = curl_init('https://api.wolvy.net/v1/videos/' . rawurlencode($videoId) . '/status');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('WOLVY_API_KEY')],
]);
$body = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
throw new RuntimeException("$status {$body['error']['code']}");
}
if (in_array($body['status'], ['playable', 'ready'], true)) {
return $body;
}
if ($body['status'] === 'failed') {
throw new RuntimeException("Encoding failed for $videoId");
}
sleep($delay);
$delay = min((int) ($delay * 1.5), 60);
}
throw new RuntimeException('Timed out waiting for the video');
} 5 · Put it on a page#
When a signed-in viewer opens the page, ask Wolvy for a playback token for that viewer and render the returned
embed_url in an iframe. The token carries the viewer’s id, so the watermark and your session logs name them — and they cannot swap in someone else’s.
// Express. requireLogin is your own auth middleware.
app.get("/lessons/:videoId", requireLogin, async (req, res) => {
const videoId = encodeURIComponent(req.params.videoId);
const r = await fetch(`https://api.wolvy.net/v1/videos/${videoId}/playback-tokens`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WOLVY_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ viewer_id: String(req.user.id), ttl_seconds: 7200 }),
});
const token = await r.json();
if (!r.ok) return res.status(502).send("This video is unavailable right now.");
res.set("Cache-Control", "private, no-store"); // the page carries this viewer's token
res.send(`<!doctype html>
<title>Lesson</title>
<div style="aspect-ratio: 16 / 9; max-width: 960px">
<iframe src="${token.embed_url}" title="Lesson video"
allow="autoplay; fullscreen; encrypted-media; picture-in-picture" allowfullscreen
referrerpolicy="strict-origin-when-cross-origin"
style="width: 100%; height: 100%; border: 0"></iframe>
</div>`);
}); # Flask + Flask-Login
import os
from urllib.parse import quote
import requests
from flask import abort, make_response, render_template
from flask_login import current_user, login_required
@app.get("/lessons/<video_id>")
@login_required
def lesson(video_id):
r = requests.post(
f"https://api.wolvy.net/v1/videos/{quote(video_id)}/playback-tokens",
headers={"Authorization": f"Bearer {os.environ['WOLVY_API_KEY']}"},
json={"viewer_id": str(current_user.id), "ttl_seconds": 7200},
timeout=10,
)
if not r.ok:
abort(502)
# lesson.html: <iframe src="{{ embed_url }}" allow="autoplay; fullscreen; encrypted-media; picture-in-picture" ...>
resp = make_response(render_template("lesson.html", embed_url=r.json()["embed_url"]))
resp.headers["Cache-Control"] = "private, no-store"
return resp <?php
// lesson.php — after your own login check has loaded $currentUser.
$videoId = 'a1b2c3d4e5f60718293a';
$ch = curl_init('https://api.wolvy.net/v1/videos/' . rawurlencode($videoId) . '/playback-tokens');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('WOLVY_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode(['viewer_id' => (string) $currentUser->id, 'ttl_seconds' => 7200]),
]);
$token = json_decode(curl_exec($ch), true);
header('Cache-Control: private, no-store');
?>
<div style="aspect-ratio: 16 / 9; max-width: 960px">
<iframe src="<?= htmlspecialchars($token['embed_url'], ENT_QUOTES) ?>" title="Lesson video"
allow="autoplay; fullscreen; encrypted-media; picture-in-picture" allowfullscreen
referrerpolicy="strict-origin-when-cross-origin"
style="width: 100%; height: 100%; border: 0"></iframe>
</div> - Every attribute on that iframe matters.
encrypted-medialets DRM start inside the frame; thereferrerpolicykeeps the domain check working. Why each one is there. - Mark the page private. It contains one viewer’s token; a shared cache would hand it to everybody.
- Busy page? Sign tokens on your own server instead — no request per page view. Sign it yourself.
Next steps#
Sign your viewers
The token format, six languages, test vectors and an interactive lab.
Needs your codeWebhooks
Receive video.ready and verify every delivery.
Embed the player
Domain rules, responsive sizing, App-Only mode.
ReferenceAPI reference
Every endpoint, with a request builder.
Something wrong or unclear on this page? Email [email protected] — include the page name.