VCP 1 Documentation›API Reference
Videos & Sources
The video detail endpoint, the artwork model, playable sources, and the expiry contract that keeps short-lived signed URLs working.
Video detail
GET/videos/{id}
Returns one video's full metadata plus its playable sources and subtitle tracks. The app calls this at play time — not during import.
# Full detail (metadata + sources + subtitles). A user token, when present,
# typically unlocks privileged renditions:
curl "https://api.example.com/vcp/v1/videos/vid_88213" \
-H "VCP-Service-Token: vcps_a81f42..." \
-H "VCP-User-Token: vcpu_5f19ab..."
# Cheap source refresh right before playback:
curl "https://api.example.com/vcp/v1/videos/vid_88213/sources" \
-H "VCP-Service-Token: vcps_a81f42..." \
-H "VCP-User-Token: vcpu_5f19ab..."import requests
from datetime import datetime, timedelta, timezone
API = "https://api.example.com/vcp/v1"
HEADERS = {"VCP-Service-Token": "vcps_a81f42..."}
video = requests.get(f"{API}/videos/vid_88213", headers=HEADERS).json()
source = video["sources"][0]
def is_expiring(src):
if "expires_at" not in src:
return False # unsigned/permanent source
expires = datetime.fromisoformat(src["expires_at"].replace("Z", "+00:00"))
return expires < datetime.now(timezone.utc) + timedelta(seconds=60)
# Before starting playback: refresh if absent, past, or within 60 s
if is_expiring(source):
fresh = requests.get(f"{API}/videos/vid_88213/sources", headers=HEADERS)
fresh.raise_for_status()
source = fresh.json()["sources"][0]
print("play", source["url"])require "rest-client"
require "json"
require "time"
API = "https://api.example.com/vcp/v1"
HEADERS = { "VCP-Service-Token" => "vcps_a81f42..." }
video = JSON.parse(RestClient.get("#{API}/videos/vid_88213", HEADERS).body)
source = video["sources"].first
# Before starting playback: refresh if absent, past, or within 60 s
expiring = source["expires_at"] &&
Time.parse(source["expires_at"]) < Time.now.utc + 60
if expiring
fresh = JSON.parse(RestClient.get("#{API}/videos/vid_88213/sources", HEADERS).body)
source = fresh["sources"].first
end
puts "play #{source["url"]}"const API = 'https://api.example.com/vcp/v1'
const HEADERS = { 'VCP-Service-Token': 'vcps_a81f42...' }
async function getJson(path) {
const resp = await fetch(API + path, { headers: HEADERS })
if (!resp.ok) throw new Error('VCP request failed: ' + resp.status)
return resp.json()
}
const video = await getJson('/videos/vid_88213')
let source = video.sources[0]
// Before starting playback: refresh if absent, past, or within 60 s
const is_expiring = source.expires_at &&
new Date(source.expires_at).getTime() < Date.now() + 60_000
if (is_expiring) {
const fresh = await getJson('/videos/vid_88213/sources')
source = fresh.sources[0]
}
console.log('play', source.url){
"id": "vid_88213",
"title": "Episode 1 - Arrival",
"description": "The crew makes landfall.",
"duration": 1440,
"thumbnail_url": "https://cdn.example.com/thumbs/88213.jpg",
"poster_url": "https://cdn.example.com/posters/88213.jpg",
"cover_url": "https://cdn.example.com/covers/88213.jpg",
"release_date": "2024-07-04",
"tags": ["drama", "sci-fi"],
"updated_at": "2026-06-28T08:11:00Z",
"sources": [
{
"kind": "hls",
"url": "https://cdn.example.com/hls/88213/master.m3u8?sig=...",
"height": 1080,
"bandwidth": 4800000,
"codec": "h264",
"expires_at": "2026-07-16T10:30:00Z"
},
{
"kind": "mp4",
"url": "https://cdn.example.com/mp4/88213-720.mp4?sig=...",
"height": 720,
"bandwidth": 2400000,
"codec": "h264",
"expires_at": "2026-07-16T10:30:00Z"
}
],
"subtitles": [
{
"language": "en",
"label": "English",
"kind": "vtt",
"url": "https://cdn.example.com/subs/88213.en.vtt"
}
]
}Artwork
A video carries up to three images, on both summaries and detail. Onlythumbnail_url is required; clients fall back to it when the others are missing:
| Field | Required | Shape / use |
|---|---|---|
thumbnail_url | ✅ | Landscape ~16:9, small. Preview image: lists, grids, seek preview. |
cover_url | — | Portrait ~2:3. Card image: library tiles, detail page art. |
poster_url | — | Wide banner ≥ 16:9, large. Hero/featured placements. |
Sources
kindishls,dash, ormp4. Clients must supporthlsandmp4;dashis optional.- An HLS/DASH master source describes its own variants;
heightandbandwidthon it describe the top rendition, for UI badges. - Which sources appear depends on the credentials presented — a valid
VCP-User-Tokentypically unlocks privileged renditions. - Media URLs are fetched by the device, never the gateway. If you pin signed URLs to an IP, pin to
VCP-Client-IP— see headers. - DRM is out of scope for v1: a source with a
drmfield present is skipped by v1 clients.
Refreshing sources
GET/videos/{id}/sources
Source URLs are expected to be short-lived signed URLs that expire on a timescale of minutes to hours. This required endpoint returns fresh sources (and subtitle tracks, whose URLs may equally be signed) without the cost of the full detail:
{
"sources": [ ...same shape as the video detail... ],
"subtitles": [ ...same shape as the video detail... ]
}The expiry contract
| Party | Obligation |
|---|---|
| Provider | Every signed source carries expires_at. Sources must remain valid for at least 15 minutes from issue, and should remain valid for at least the video's duration. Unsigned/permanent sources omit expires_at. |
| Client | Source URLs are playback-time data, never library data — never persisted during import/sync, only fetched when playback is about to start. |
| Client | Before starting playback, if expires_at is absent, in the past, or within 60 seconds, call /sources first. |
| Client | On an HTTP 401/403 from the media CDN mid-playback (a source that expired while playing), call /sources once, swap in the fresh URL, and resume at the saved position. If the refetched source also fails, surface the error. |
| Client | At most one automatic /sources refresh per playback attempt — no retry loops against a provider that is actually denying access. |