Skip to content

Video

Files holds anything you can upload, and for a photo or a PDF that is the whole story. Video is different. A raw .mp4 served whole cannot seek properly, cannot adapt when the connection weakens, and stalls anyone who is not on good wifi — and a worker cannot carry a gigabyte anyway.

Video is the answer to that. It is what a course platform, a coaching app, a video testimonial, a portfolio or a feed is built out of.

It is not Calls. That one is people talking to each other right now, and nothing is kept.

One import. Uploading, waiting for the encode and playing back are three lines:

import { uploadVideo, awaitVideoReady, listVideos, getVideo, deleteVideo, VideoError } from "video";
const made = await uploadVideo(file, { name: file.name });
const video = await awaitVideoReady(made.id);
if (video.ready) player.src = video.playback.iframe;
Call What you get
uploadVideo(file, {name, signal}) the new video, once the bytes are in Cloudflare
awaitVideoReady(id, {every, timeout}) the video the moment it is playable — check .ready
listVideos({limit, cursor}) {items, cursor} — this caller’s uploads, newest first
getVideo(id) the one video, or null once it is gone
deleteVideo(id) {deleted: true}
VideoError what all of them throw: .status, .budget, .guard

Every video is the same flat shape, from all of them:

{ "id": "", "name": "Lesson one", "ready": true, "duration": 612,
"thumbnail": "https://…/thumbnails/thumbnail.jpg",
"playback": { "iframe": "https://…/iframe", "hls": "https://…/manifest/video.m3u8" } }

playback is null until ready is true. Nothing else wraps anything.

Never hand-roll a fetch() to /api/_video. The module is the supported way in — a hand-written call has to get the upload body, the paging and the direct-to-Cloudflare hop right, and each one of those is a build that fails in the browser rather than at compile time.

uploadVideo() asks for a one-time URL, then the browser sends the file straight to Cloudflare. Your app never touches it, and a fresh URL is taken for every upload.

const input = document.querySelector("input[type=file]");
const made = await uploadVideo(input.files[0], { name: input.files[0].name });

It is one request for the bytes, with no progress events — show a plain “uploading” state rather than a percentage. Pass { signal } from an AbortController if the person needs a way to cancel.

It is not playable the moment the upload finishes

Section titled “It is not playable the moment the upload finishes”

Encoding takes seconds for a short clip and minutes for a long one. Until it finishes, ready is false and there is nothing to play. awaitVideoReady() does the waiting:

const video = await awaitVideoReady(made.id);
if (!video.ready) show("still processing");

It answers the moment the video turns playable, and answers the still-encoding video if its timeout runs out first — so always check .ready on what comes back. Never drop someone on a broken player, never write your own polling loop, and never block the page waiting.

A ready video hands you playback.iframe. Put it in an iframe and you are done — quality switching, captions, scrubbing and fullscreen all come with it.

<iframe src={video.playback.iframe} allow="autoplay; fullscreen; picture-in-picture"
allowfullscreen title={video.name}></iframe>

Never build that address yourself. It carries a hostname specific to your account, so a hand-written one is always wrong.

thumbnail is a real image URL — use it as the poster in a grid rather than mounting a player per row, which is slow. playback.hls is there if you need a custom player.

Each upload belongs to the signed-in user who asked for it, and listVideos() returns only theirs — the same shape as a private key in Files. Every door needs a signed-in caller, so sign-in has to be on.

A playback URL is unguessable, but it is not secret. Treat it like an unlisted link, and do not put anything behind it that a signed-out stranger must never see.

Every call throws a VideoError carrying .status and the server’s own message.

try {
await uploadVideo(file);
} catch (err) {
show(err.message);
}

401 sign in first, 402 see below, 404 gone, 429 slow down, 503 Video is off. err.guard === true means the bot check is uncleared.

Two different things answer 402, and saying the wrong one misleads whoever is reading the screen. Tell them apart by .budget — present means the monthly budget is spent, absent means Cloudflare Stream is not bought on your account yet. err.message already says which, so show it rather than assuming either.

Cloudflare bills you for every minute stored and every minute watched, so each upload spends one against a monthly budget you set in the Video tab. Past it, uploads throw a VideoError with .status 402 and the numbers on .budget:

{ "used": 100, "monthly": 100 }

That is not retryable and it is not a bug — say plainly that uploads are paused. The count resets on the 1st (UTC).

These are what the module calls. The app’s server.mjs is a lone module with no module graph, so it cannot import video — if the server ever needs one of these it calls the same path through env.PLATFORM.fetch(), which carries the caller’s session.

Method & path Body Returns
POST /api/_video/upload {"name?":"Lesson one"} 201 {"id","uploadUrl"} — the browser POSTs the file to uploadUrl itself
GET /api/_video/:id {"id","name","ready","duration","thumbnail","playback":{"iframe","hls"}}
GET /api/_video {"items":[{"id","name","ready","duration","thumbnail","playback"}],"cursor"}
DELETE /api/_video/:id {"deleted":true}
  • A video may be up to 30 GB and 2 hours long.
  • Uploads are throttled to 30 a minute per caller.
  • The budget and the list of who uploaded what live in private tables in the shared project database. Video does not turn on Data’s record API.
  • It runs on — and bills — your own Cloudflare account, like every other capability. Stream has no free tier, so your account needs it active before the first upload.