Playing a video

Transcoding gives you one URL. This page is what to do with it.

The URL you need

Once an asset reaches COMPLETED, fetch it and take video.hls_manifest_url. That single URL is the whole thing — an HLS master playlist listing every quality we produced.

GET /assets/{'{public_id}'}/
{
  "asset": {
    "status": "COMPLETED",
    "video": {
      "hls_manifest_url": "https://…/hls-f2975d/master-5799955.498679052.m3u8"
    },
    "renditions": [ { "quality": "P480", "playlist_url": "…" } ]
  }
}
You do not need the per-rendition URLs. A player reads the ladder out of the master playlist itself — the renditions array is there for inspection, not for playback.

In a browser

Browsers do not understand HLS natively, with the exception of Safari and iOS. A player library bridges the gap by fetching the playlist and feeding segments to a normal <video> element.

html
<video id="player" controls playsinline style="width:100%"></video>

<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
<script>
  const video = document.getElementById('player');
  const src = 'https://…/master-5799955.498679052.m3u8';

  if (video.canPlayType('application/vnd.apple.mpegurl')) {
    video.src = src;              // Safari and iOS play HLS themselves
  } else if (window.Hls && Hls.isSupported()) {
    const hls = new Hls();
    hls.loadSource(src);
    hls.attachMedia(video);
  }
</script>

Any HLS-capable player works — hls.js, Shaka Player, Video.js. StreamOS’s own dashboard uses Shaka, because it also handles DASH.

On mobile and TV

What to use

KeyTypeSampleDescription
iOS / tvOSoptionalnativeAVPlayerGive it the manifest URL directly. HLS is Apple’s format.
AndroidoptionalnativeExoPlayer / Media3HlsMediaSource with the manifest URL.
React Nativeoptionallibraryreact-native-videoPass the manifest URL as the source uri.
Anything elseoptional—ffplay / VLCUseful for checking a URL works before blaming your code.

Quality switching

The master playlist lists every rung with its bandwidth. Players switch automatically as the connection changes — you do not have to choose, and in most cases should not.

A ladder can advertise two rungs that point at the same file — for example a 720p entry backed by the 480p encode when the source was not large enough to justify one. If you build your own quality menu, collapse entries sharing a URL, or viewers will pick a resolution that changes nothing.

Access and DRM

A manifest URL is public. It is long and unguessable, but anyone holding it can play the video — there is no token, no expiry and no sign-in.

What that means in practice

Treat the URL as the secret. Do not put it in a public page source if the content is paid; fetch it from your own backend when a viewer is entitled to it, so the check lives with you rather than with the URL.

Videos uploaded with drm: true are the exception — the manifest itself is not what protects them. They produce DASH output instead of HLS (hls_manifest_url is null; use renditions[].dash_url), and every playback additionally needs a decryption licence, fetched fresh each time a viewer starts watching. The rest of this section is that flow.

Playing DRM content

The real licence server and its credentials never reach you or a viewer’s browser. You ask our API for a short-lived, single-purpose session_id; the player is then configured to fetch the actual licence through our own proxy, using that id instead. This is deliberate — a real licence credential, once captured off the wire, could otherwise be replayed directly against the licence server from anywhere, indefinitely, with nothing on our side able to tell.

Step 1 — Mint a session

POST/api/public/v1/assets/{public_id}/drm-token/

Body

KeyTypeSampleDescription
user_idrequiredstringviewer-42Which of your viewers this session is for. A key has no session for us to read one from, so you state it — sending the same id for every viewer defeats the point of a per-viewer licence.
devicerequiredstringWebOne of Android, iOS, Web, Safari — exactly one of these four, rejected otherwise. iOS and Safari issue a FairPlay session; Android and Web issue Widevine. Also how your DRM usage is broken down by device on the usage screen, so name what the viewer is actually on rather than defaulting to one value.
curl
curl -X POST https://api.streamos.in/api/public/v1/assets/oI1j0SfJK3v/drm-token/ \
  -H 'Authorization: Bearer sk_live_…' \
  -H 'Content-Type: application/json' \
  -d '{"user_id": "viewer-42", "device": "Web"}'
200 OK
{
  "success": true,
  "data": {
    "drm": {
      "session_id": "kx93F2ivQeM8bYh1nQ4z2VvJTz8oP1s6",
      "device": "Web"
    }
  }
}
Sessions last about an hour — long enough to cover key rotation on most videos — but request a fresh one every time playback starts rather than caching one across page loads. Nothing about the id changes based on device other than which key system it was issued for.

Step 2 — Point your player at our proxy, not the licence server

A session_idis not a licence — it is what unlocks one. Configure your DRM-capable player (Shaka Player, ExoPlayer, AVFoundation…) with these two URLs built from it, and the player fetches the actual licence itself the moment it needs to decrypt:

Key system servers

KeyTypeSampleDescription
com.widevine.alphaoptionallicence server URL/api/drm/license/{session_id}/Widevine — Chrome, Android, most non-Apple players.
com.apple.fps / com.apple.fps.1_0optionallicence server URL/api/drm/license/{session_id}/FairPlay — Safari and iOS. The same URL shape as Widevine; the proxy tells the two apart from the session itself.
Shaka Player
player.configure({
  drm: {
    servers: {
      'com.widevine.alpha': `https://api.streamos.in/api/drm/license/${session_id}/`,
    },
  },
});
await player.load(dashManifestUrl);
FairPlay needs one extra step before player.load(): fetch its server certificate from our proxy and base64-decode it into advanced.serverCertificate. Skip it and every licence request fails with an opaque player error rather than a clear one.
GET/api/drm/certificate/{session_id}/

FairPlay only. Returns the certificate as base64 text — decode it into bytes and pass it as advanced.serverCertificate for both com.apple.fps and com.apple.fps.1_0, before the licence server is ever called.

What a licence request actually returns

You never call this directly — the player does, using the URL from step 2. Included so a failure here means something concrete rather than a generic player error:

POST/api/drm/license/{session_id}/

Response

KeyTypeSampleDescription
200optionalsuccessapplication/octet-streamThe raw licence, handed straight to the player. Counted as one licence request against your plan.
404 DRM_SESSION_NOT_FOUNDoptionalerror—The session_id expired or never existed. Mint a new one from step 1 — a session does not live as long as a long-form video might play.
502 DRM_LICENSE_FAILEDoptionalerror—The licence server refused the request. Not retryable with the same challenge.
Both proxy routes are unauthenticated by design — the unguessable session_id is the credential, the same trust model the real licence token always used. No API key or session cookie travels with these calls.

If it will not play

Common causes

KeyTypeSampleDescription
hls_manifest_url is nulloptionalexpectedstatus = TRANSCODINGStill processing, or the asset is DRM. Check status before blaming the player.
Nothing loads, console shows CORSoptionalnetworkAccess-Control-Allow-OriginThe CDN reflects the requesting origin. If it fails, the URL is wrong or the file has been removed.
Works in Safari, not ChromeoptionallibraryHls.isSupported()Safari plays HLS natively; everywhere else needs the player library actually loaded.
Certificate erroroptionalnetworkERR_CERT_DATE_INVALIDA browser will not load media from a host with an invalid certificate, and cannot be clicked through for a subresource. Report the URL to us.