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.
Link embeds (Open Graph)
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.