Playback — web, React Native, and link embeds

When a video is available, its outputs object carries everything:

{
  "hls_url": "/videos/<id>/master.m3u8",
  "dash_manifest_url": "/videos/<id>/manifest.mpd",
  "web_mp4_url": "/videos/<id>/web.mp4",
  "web_mp4_width": 1280,
  "web_mp4_height": 720,
  "thumbnail_url": "/videos/<id>/thumbnail_1.jpg",
  "thumbnail_urls": [
    "/videos/<id>/thumbnail_1.jpg",
    "/videos/<id>/thumbnail_2.jpg",
    "/videos/<id>/thumbnail_3.jpg"
  ],
  "og_thumbnail_url": "/videos/<id>/og_thumbnail.jpg"
}

Videos are private by default. A private video's URLs return 401 playback_key_required unless the request carries a playback key you minted for that viewer — see Private videos below, which you probably need to read before anything else on this page works.

A video you mark public behaves the way this service originally worked: the UUID is the capability, URLs are served with Range support and long-lived immutable caching, and you can put a CDN in front without any configuration.

Which one a video is comes back on the video object as visibility.

Choosing a poster

thumbnail_urls holds three posters sampled at roughly 10%, 50% and 90% of the video, in playback order. Pick one, or show all three and let whoever uploaded the video choose — a single frame is a coin flip, and the first seconds are often a fade-in or a title card.

thumbnail_url is always the first entry, so a client that only wants one poster can keep reading it and ignore the list entirely.

thumbnail_urls is never empty for an available video. Videos processed before poster sets existed report a one-entry list holding their original poster, so you can read the list unconditionally.

Web (hls.js)

<video id="player" controls poster="BASE + thumbnail_url"></video>
<script src="hls.js"></script>
<script>
  const video = document.getElementById('player');
  const src = BASE + outputs.hls_url;
  if (video.canPlayType('application/vnd.apple.mpegurl')) {
    video.src = src;               // Safari: native HLS
  } else if (Hls.isSupported()) {
    // Bound the look-ahead: hls.js's default forward buffer is
    // max(maxBufferLength, maxBufferSize / bitrate) with maxBufferSize at
    // 60 MB — so any video under ~60 MB downloads ENTIRELY at start,
    // billing you (and your viewers) for seconds nobody may watch.
    // ~15 s ahead keeps fetches tracking playback.
    const hls = new Hls({ maxBufferLength: 15, maxBufferSize: 8_000_000 });
    hls.loadSource(src);
    hls.attachMedia(video);
  } else {
    video.src = BASE + outputs.web_mp4_url;  // last-resort progressive
  }
</script>

The snippet above is the public-video path. For a private video the Safari native branch and the web_mp4_url fallback both stop working, and the poster needs fetching — see Private videos.

If your player is on a different origin than Energixer, add that origin to your domain's cors_origins config — manifests and segments then carry CORS headers for it.

React Native

Use react-native-video with the HLS URL — both iOS (AVPlayer) and Android (ExoPlayer) handle HLS natively:

<Video
  source={{ uri: BASE + outputs.hls_url }}
  poster={BASE + outputs.thumbnail_url}
  controls
  resizeMode="contain"
/>

Stick to HLS on mobile — leave DASH off unless a web player specifically wants it. (This mirrors hard-won production experience: mobile HLS is the well-trodden path.)

For a private video add headers to source — unlike a browser, React Native can put the playback key on every segment request itself.

The tags go on your page — the one whose URL people paste. Energixer serves no HTML per video, so there is nothing here to point a scraper at directly; what it gives you is the artifacts to fill the tags with.

The image card — the baseline

og_thumbnail_url is a 1200×630 JPEG with a play-button overlay, sized for social link cards:

<meta property="og:image" content="BASE + og_thumbnail_url" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />

Ship this on every page, including ones that also get the video tags below — scrapers that cannot or will not play a video fall back to it.

It is not exempt from visibility. A crawler fetches og:image itself, with no credential and no JavaScript, so on a private video this URL answers it 401 exactly as a manifest would and the card renders with no picture. For private videos, either mark them public or have your backend fetch the card once with a playback key and re-serve it from your own origin, which is the only way to keep a private video's card working — the crawler is fetching from you at that point, and your own page is already the thing being shared.

The video card — public videos only

web_mp4_url is a progressive H.264/AAC MP4 (720p band, -movflags +faststart, so the moov atom is up front where a scraper needs it). That makes it the file og:video wants — a crawler's inline player wants one seekable file, not an adaptive manifest, so never point og:video at hls_url.

<meta property="og:type" content="video.other" />
<meta property="og:video" content="BASE + web_mp4_url" />
<meta property="og:video:secure_url" content="BASE + web_mp4_url" />
<meta property="og:video:type" content="video/mp4" />
<meta property="og:video:width" content="web_mp4_width" />
<meta property="og:video:height" content="web_mp4_height" />
<!-- keep the image card too: it is the fallback and the poster frame -->
<meta property="og:image" content="BASE + og_thumbnail_url" />

Three things to get right, each of which silently produces a broken or missing card rather than an error:

1. The video must be public. A crawler issues the request itself, so it cannot carry a playback key, and a private video answers it 401 playback_key_required — and note from the section above that this takes the image card down with it, so you get no card at all rather than a degraded one. There is no way around this: the key is a header and always will be (why). If you want video cards, flip those videos with PATCH /v1/videos/<id>/visibility and understand what you are choosing — a public video's URL is its own capability.

2. Use web_mp4_width/web_mp4_height, not width/height. The pair inside outputs describes web.mp4. The width/height on the video object describe the source, and for anything above 720p they differ — a 4K upload reports 3840×2160 at the top level while the file your card plays is 1280×720. Reporting the source dimensions makes players letterbox or crop.

The same trap applies to length: outputs.web_mp4_duration_seconds is this file's own, and duration_seconds is the video's. They differ when you have attached an outro — a branded tail card appended to web.mp4 and to nothing else, which is a thing worth having precisely because this file is the one a share card plays.

3. Read web_mp4_url; never construct it. It is null when the domain has outputs.mp4_fallback off, or when h264_720 is not in its renditions (an AV1-only ladder produces no web.mp4 at all). No MP4, no video card — emit the image card alone. For the same reason, don't derive the dimensions from the rung name: the ladder's short-side scale never upscales, so a 320×240 source yields a 320×240 file that is still called h264_720. Both dimension fields are also null on videos that completed before they existed, so treat missing as "emit the image card only" rather than substituting a guess.

On S3-backed domains the URL 302s to storage; crawlers follow it, and the tag still holds your URL rather than a presigned one, so nothing expires.

X/Twitter player cards are not this. twitter:player needs an HTTPS iframe you host, plus allowlisting by X — an MP4 URL is not accepted. That iframe would be your page, not an Energixer one; summary_large_image from the image card above is what works without it.

Private videos

Every video is private unless you say otherwise. To play one, mint a key for the end user who is watching:

POST /v1/playback-keys
Authorization: Bearer ek_<your domain key>

{ "viewer_ref": "user_1234", "ttl_seconds": 3600 }
{
  "playback_key": "eyJhbGciOi...",
  "viewer_ref": "user_1234",
  "expires_at": "2026-08-03T12:00:00.000Z",
  "video_ids": null
}

Mint on your server, never in the browser. The call needs your domain key, and your domain key is your tenant — it must never ship client-side. Hand the minted key down to the page the way you would any short-lived session token.

The key unlocks every private video in your domain until it expires (default 1 h, maximum 24 h). Pass video_ids (up to 20) to narrow it to specific videos when a viewer should only reach one title.

Sending it

The key goes in the Authorization header. That is the only accepted transport — not a query parameter, not a cookie.

const hls = new Hls({
  maxBufferLength: 15,
  maxBufferSize: 8_000_000,
  // Runs for every manifest AND every segment request.
  xhrSetup: (xhr) =>
    xhr.setRequestHeader('Authorization', 'Bearer ' + playbackKey),
});
hls.loadSource(BASE + outputs.hls_url);
hls.attachMedia(video);

What header-only transport costs you

A browser only sends headers on requests your page makes. It issues these itself, so they cannot carry the key:

Doesn't work for a private video Do this instead
<img src=thumbnail_url> fetch() it with the header, URL.createObjectURL(blob)
<track src=transcript_url> same — fetch to a blob URL, then set src
<video src=web_mp4_url> use HLS through hls.js; progressive playback of a private video is not available in a browser
og:video link-embed cards (the crawler is not your page either) mark those videos public, or ship the og:image card alone — see Link embeds
Safari's native HLS (no MSE — notably iOS) no workaround in v1; hls.js requires MSE

So: private videos play in a browser through hls.js/MSE, and not otherwise. If you need iOS Safari native playback or plain <img> posters today, mark those videos public. React Native is unaffected — react-native-video accepts source.headers, so it needs none of the workarounds above:

<Video
  source={{
    uri: BASE + outputs.hls_url,
    headers: { Authorization: `Bearer ${playbackKey}` },
  }}
  controls
/>

Making a video public

PATCH /v1/videos/<id>/visibility
{ "visibility": "public" }

Or flip the whole domain's default for future uploads with PATCH /v1/domain/config { "default_visibility": "public" }. Changing the default never touches videos that already exist.

One caveat worth knowing before you rely on the reverse flip: going public → private does not retract copies already cached downstream. A public response is cacheable for up to 24 h, so shared caches and CDNs may keep serving it until that expires. Create videos private (the default) rather than publishing and retracting.

Cutting off access

Keys are stateless, so there is no per-key delete. Two levers, both taking effect within about 5 seconds:

POST /v1/playback-keys/revoke        { "viewer_ref": "user_1234" }
POST /v1/playback-keys/revoke-all

revoke kills every key issued to that viewer before now — re-minting for the same viewer immediately afterwards works, which is what makes it usable for "log this person out". revoke-all bumps a domain-wide epoch and invalidates everything outstanding; reach for it on a suspected leak, or when the per-viewer denylist fills up (1000 entries per domain, drained automatically after 24 h).

Between the 24 h TTL ceiling and these two levers, the honest guarantee is: a leaked key is valid for at most its TTL, and you can kill it within ~5 s at viewer or domain granularity. There is no instant per-key revocation.

What we deliberately don't ship

No player SDK — hls.js and react-native-video are better than anything we would build. No signed URLs: the capability is a header, which is why none of the URLs above ever change and you can keep storing them verbatim. And no DRM, per-viewer watermarking, or concurrent-stream limits.