Videos
Upload a video from your own code, read its playback links, and put it on a page.
Video libraries are currently in Beta. There are limits on how much you can store, and features and pricing may change.
Create the library first — you do that in the console, on your project canvas. Everything below works against a library that already exists.
Throughout this page:
BASE = https://api.cloud.simplifyd.com/v1/workspaces/{workspace}/projects/{project}/envs/{env}/svcs/{library}{library} is the id of the video library service. See how resources are addressed for where the four ids come from.
List videos
GET {BASE}/video/videos?limit=50&offset=0Returns the videos in a library, newest first.
Query parameters
| Field | Description |
|---|---|
limit | Page size from 1 to 200. Defaults to 50. Values outside this range fall back to 50. |
offset | Number of results to skip. Defaults to 0. |
curl --fail-with-body "$BASE/video/videos?limit=50&offset=0" \
-H "Authorization: Bearer $SIMPLIFYD_API_TOKEN"const response = await fetch(`${BASE}/video/videos?limit=50&offset=0`, {
headers: {
Authorization: `Bearer ${process.env.SIMPLIFYD_API_TOKEN}`,
},
});
const result = await response.json();
if (!response.ok) throw new Error(result.message ?? response.statusText);Response
200 OK.
{
"videos": [
{
"slug": "0192e7a0-55c1-7b3e-8f2d-4a1b6c9d0e15",
"title": "Opening the account",
"status": "ready",
"duration_ms": 92500,
"created_at": "2026-01-15T12:00:00Z"
}
],
"total": 51,
"limit": 50,
"offset": 0
}The video object is trimmed in this example. For the next page, add limit to offset and stop when the next offset reaches total. Use Read a video for renditions, tracks and processing details.
Upload a video
POST {BASE}/video/videos
Content-Type: multipart/form-dataPost the file as a form field named file. The response contains the video ID. Processing is queued after the upload completes.
const form = new FormData();
form.append('title', 'Opening the account');
form.append('file', videoFile);
const response = await fetch(`${BASE}/video/videos`, {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.SIMPLIFYD_API_TOKEN}` },
body: form,
});
if (!response.ok) {
const { message } = await response.json();
throw new Error(message);
}
const video = await response.json();
// video.slug — the id everything else is addressed by
// video.status — "queued": the file is stored, encoding has not started yetDo not set Content-Type yourself. FormData sets it, including the boundary the body is actually framed with.
Reading a file from disk in Node rather than taking one from a browser:
import { openAsBlob } from 'node:fs';
form.append('file', await openAsBlob('./talk.mp4'), 'talk.mp4');Include the filename as the third argument. Its extension is required to identify the video container format.
<?php
$form = [
// Ordered deliberately: cURL sends the fields in the order of this array,
// and a title after the file is never read. See the note below.
'title' => 'Opening the account',
'file' => new CURLFile('/path/to/talk.mp4', 'video/mp4', 'talk.mp4'),
];
$ch = curl_init("{$base}/video/videos");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $form,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('SIMPLIFYD_API_TOKEN')],
CURLOPT_RETURNTRANSFER => true,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$video = json_decode($body, true);
if ($status !== 201) {
throw new RuntimeException($video['message'] ?? 'upload failed');
}
// $video['slug'] — the id everything else is addressed by
// $video['status'] — "queued": the file is stored, encoding has not started yetThe third argument to CURLFile is the filename sent in the form. Left out, it defaults to the basename of the local path — fine for a file you named yourself, wrong for one that arrived in $_FILES, where the path is a temp name such as /tmp/phpA1B2C3 with no extension on it. Pass $_FILES['video']['name'] there, so the container we read is the one the user actually uploaded.
package main
import (
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
)
// UploadVideo posts a file to a video library and returns the video it created.
//
// The form is written into a pipe rather than a buffer, so the file streams from
// disk to the network instead of being held in memory a second time.
func UploadVideo(base, token, path, title string) (map[string]any, error) {
pr, pw := io.Pipe()
form := multipart.NewWriter(pw)
go func() {
// Closed exactly once, with whatever went wrong, and nil on success —
// which ends the body cleanly. Do not also `defer pw.Close()`: it would
// overwrite that error with EOF, and a file that failed halfway would
// be uploaded as though it were the whole thing.
pw.CloseWithError(func() error {
// Written before the file, deliberately — see the note below.
if err := form.WriteField("title", title); err != nil {
return err
}
part, err := form.CreateFormFile("file", filepath.Base(path))
if err != nil {
return err
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
if _, err := io.Copy(part, f); err != nil {
return err
}
// The trailing boundary, written only once the file is all there.
// Writing it after a failed copy would frame a truncated body as a
// complete one.
return form.Close()
}())
}()
req, err := http.NewRequest(http.MethodPost, base+"/video/videos", pr)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", form.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var video map[string]any
if err := json.NewDecoder(resp.Body).Decode(&video); err != nil {
return nil, err
}
if resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("upload failed: %v", video["message"])
}
return video, nil
}In Go you rarely need to write this. The SDK does the same thing in a line, and its UploadFile takes the parts path instead — so it has no 300 MiB limit, retries a failed part rather than the file, and resumes an interrupted upload:
client := cloud.NewClient(cloud.WithToken(os.Getenv("SIMPLIFYD_API_TOKEN")))
videos := client.Workspace(ws).Project(proj).Env(env).Services().Video(library)
video, err := videos.Upload(ctx, "talk.mp4", "Opening the account", nil)curl -X POST "$BASE/video/videos" \
-H "Authorization: Bearer $SIMPLIFYD_API_TOKEN" \
-F "title=Opening the account" \
-F "file=@talk.mp4"-F sends the fields in the order you write them, so keep title ahead of file.
Form fields
| Field | Description |
|---|---|
file | Required. The video, with a filename. The extension is how we know what container it is, so file.mp4 matters and blob does not work. |
title | Optional. Defaults to the filename without its extension. Append it before the file — see below. |
Accepted files: .mp4, .mov, .mkv, .webm, .m4v, and .avi, .mpg, .mpeg, .ts.
Field order matters. Append title before file in the multipart body. Fields after file are ignored. If you cannot control the field order, pass the title in the query string: POST {BASE}/video/videos?title=Opening%20the%20account.
Response
201 Created
{
"slug": "0192f3c1-8a44-7c31-9d10-6b2e4f9a1c77",
"title": "Opening the account",
"status": "queued",
"source_bytes": 41603238,
"has_source": true,
"created_at": "2026-09-04T09:12:44Z",
"progress_pct": 0
}There are no playback links yet. They appear when the encode finishes — see Read a video.
Size limit
A single request is capped at 300 MiB. Over that you get 413 and a message pointing at the other path:
{
"type": "VALIDATION_ERROR",
"message": "that file is 1.4 GiB, over the 300.0 MiB limit for uploading in one request; register the upload instead and send it in parts"
}The 300 MiB limit applies to a single-request upload. Use multipart uploads for files up to 8 GiB.
Read a video
GET {BASE}/video/videos/{video}const response = await fetch(`${BASE}/video/videos/${videoId}`, {
headers: { Authorization: `Bearer ${process.env.SIMPLIFYD_API_TOKEN}` },
});
const video = await response.json();
return Response.json({
videoId: video.slug,
playbackUrl: video.playback_url,
thumbnailUrl: video.poster_url,
embedCode: video.iframe_snippet,
});<?php
$ch = curl_init("{$base}/video/videos/{$videoId}");
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('SIMPLIFYD_API_TOKEN')],
CURLOPT_RETURNTRANSFER => true,
]);
$video = json_decode(curl_exec($ch), true);
curl_close($ch);
echo json_encode([
'videoId' => $video['slug'],
'playbackUrl' => $video['playback_url'] ?? null,
'thumbnailUrl' => $video['poster_url'] ?? null,
'embedCode' => $video['iframe_snippet'] ?? null,
]);The playback fields are absent until the video is ready, so read them with ?? rather than assuming they are there.
// Video is the subset worth naming. Add fields as you need them; the response
// carries more.
type Video struct {
Slug string `json:"slug"`
Title string `json:"title"`
Status string `json:"status"`
StatusMessage string `json:"status_message"`
DurationMS int64 `json:"duration_ms"`
ProgressPct int `json:"progress_pct"`
// Empty until Status is "ready".
PlaybackURL string `json:"playback_url"`
EmbedURL string `json:"embed_url"`
PosterURL string `json:"poster_url"`
IframeSnippet string `json:"iframe_snippet"`
}
func GetVideo(base, token, videoID string) (*Video, error) {
req, err := http.NewRequest(http.MethodGet, base+"/video/videos/"+videoID, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("reading video %s: %s", videoID, resp.Status)
}
var video Video
if err := json.NewDecoder(resp.Body).Decode(&video); err != nil {
return nil, err
}
return &video, nil
}With the SDK this is videos.GetVideo(ctx, videoID), and the struct is already written.
Response
200 OK
{
"slug": "0192f3c1-8a44-7c31-9d10-6b2e4f9a1c77",
"title": "Opening the account",
"status": "ready",
"duration_ms": 724000,
"width": 1920,
"height": 1080,
"source_bytes": 41603238,
"hls_bytes": 30021440,
"playback_url": "https://<library>.video.simplifyd.app/v/0192f3c1.../master.m3u8",
"embed_url": "https://<library>.video.simplifyd.app/e/0192f3c1...",
"poster_url": "https://<library>.video.simplifyd.app/v/0192f3c1.../poster.jpg",
"iframe_snippet": "<iframe src=\"...\" ...></iframe>",
"renditions": [
{ "name": "audio", "height": 0, "video_kbps": 0, "audio_kbps": 48, "megabytes_per_hour": 22 },
{ "name": "360p", "height": 360, "video_kbps": 600, "audio_kbps": 64, "megabytes_per_hour": 299 },
{ "name": "720p", "height": 720, "video_kbps": 2000, "audio_kbps": 96, "megabytes_per_hour": 944 }
],
"progress_pct": 100,
"created_at": "2026-09-04T09:12:44Z"
}| Field | What it is |
|---|---|
playback_url | The HLS master playlist. Give it to any HLS-capable player. |
embed_url | A ready-made player page for this video, for an <iframe>. |
poster_url | The still shown before playback starts. |
iframe_snippet | The <iframe> for embed_url, already written. |
script_snippet | The alternative: the player mounted in your own DOM, so your page can style and script it. |
renditions | What was actually encoded. megabytes_per_hour is what an hour at that rung costs a viewer, which is usually the number that matters more than the pixel height. |
playback_url, embed_url and the snippets appear only once status is ready.
Status
| Status | Meaning |
|---|---|
uploading | The file has not finished arriving. Only reachable through the parts flow. |
queued | Stored, waiting its turn to encode. |
processing | Being encoded. progress_pct moves. |
ready | It plays, and the links above are populated. |
failed | status_message says why, in words you can show a user. |
Waiting for it to be ready
There is no webhook yet, so poll. Encoding takes roughly the length of the video, and a few seconds either side of that for short clips:
async function waitUntilReady(videoId: string) {
for (;;) {
const response = await fetch(`${BASE}/video/videos/${videoId}`, {
headers: { Authorization: `Bearer ${process.env.SIMPLIFYD_API_TOKEN}` },
});
const video = await response.json();
if (video.status === 'ready') return video;
if (video.status === 'failed') throw new Error(video.status_message);
await new Promise((r) => setTimeout(r, 5000));
}
}Poll the video status until it is ready or failed. You can store the video ID as soon as the upload returns and display a placeholder while processing continues.
Put it on a page
Use the embed snippet returned by the API:
const { iframe_snippet } = await getVideo(videoId);
// <iframe src="https://.../e/0192f3c1..." width="640" height="360" ...></iframe>The iframe isolates the player from your page's CSS and survives a strict Content-Security-Policy. If you would rather the player live in your own markup — stylable, scriptable, sized by your layout — use script_snippet instead. Or ignore both and hand playback_url to a player you already use.
Large files
For files over 300 MiB, use multipart uploads. This method supports files up to 8 GiB and can resume interrupted transfers.
Three calls:
POST {BASE}/video/videoswith a JSON body —{ "title": "...", "filename": "talk.mp4", "size": 1503238553 }— returns the video id and a batch of presigned part URLs.PUTeach part to its URL, 16 MiB at a time, keeping theETagheader each one returns. Ask for more URLs withPOST {BASE}/video/videos/{video}/partsas you go.POST {BASE}/video/videos/{video}/completewith the part numbers and ETags.
If a connection drops, GET {BASE}/video/videos/{video}/parts reports what storage already holds so you send only what is missing.
The CLI and Go SDK handle multipart uploads, retries, and resuming transfers:
- From a terminal or a script:
edge video upload talk.mp4 --library trainingdoes all three steps, with retries, concurrency and--resume. See CLI videos. - From Go: the SDK's
Services().Video(id).UploadFile(ctx, path, opts)is the same thing as a function call.
Download the original
GET {BASE}/video/videos/{video}/originalReturns a link to the file exactly as it was uploaded — not one of the converted versions — and when that link stops working:
{
"url": "https://…",
"expires_at": "2026-10-14T12:15:00Z"
}The download link expires after 15 minutes. Request a new link for each download. Downloads started before expiry can continue after the link expires. The downloaded file is named after the video's title.
This needs Keep originals turned on for the library. A library without it deletes each original once the video is converted, so there is nothing to download, and the request returns a 400 saying so. Turning it on keeps the originals of videos uploaded from then on; it cannot bring back ones already deleted. A video still uploading also returns a 400 until its upload completes.
has_source on a video tells you in advance whether its original is there to download.
const response = await fetch(`${BASE}/video/videos/${videoId}/original`, {
headers: { Authorization: `Bearer ${process.env.SIMPLIFYD_API_TOKEN}` },
});
if (!response.ok) throw new Error(`could not get the original: ${response.status}`);
const { url } = await response.json();
const original = await fetch(url); // no token: the link carries its own permissionurl=$(curl -s "$BASE/video/videos/$VIDEO_ID/original" \
-H "Authorization: Bearer $SIMPLIFYD_API_TOKEN" | jq -r .url)
curl -L -o original.mp4 "$url"A service you run on Simplifyd Cloud can make the same call — for example a worker that pulls each new upload to transcribe it. See calling the API from your services.
Choose where your videos play
PUT {BASE}/videoBy default a library's videos can be embedded on any site. Give it a list of domains and they play only there: every other site, and anyone opening a player link directly, sees a short message instead of the video. The console can always preview them.
| Field | Description |
|---|---|
allowed_embed_domains | The domains that may embed the library's videos. Each one also covers its subdomains, so example.com includes www.example.com and learn.example.com. An empty list [] lifts the restriction; leaving the field out keeps the list as it is. Up to 25. You can paste URLs: the scheme, path and port are dropped. localhost is allowed, for testing on your own machine. |
max_height | Required. The library's highest quality: 240, 360, 480, 720 or 1080. Send the current value back if you are not changing it. |
keep_original | Required. Whether originals are kept. Send the current value back: leaving it out means false, which stops keeping originals for new uploads. |
const headers = {
Authorization: `Bearer ${process.env.SIMPLIFYD_API_TOKEN}`,
'Content-Type': 'application/json',
};
// Read the current settings first, so the others are sent back unchanged.
const library = await (await fetch(`${BASE}/video`, { headers })).json();
const response = await fetch(`${BASE}/video`, {
method: 'PUT',
headers,
body: JSON.stringify({
max_height: library.max_height,
keep_original: library.keep_original,
allowed_embed_domains: ['example.com', 'learn.example.org'],
}),
});
if (!response.ok) {
const { message } = await response.json();
throw new Error(message); // e.g. "\"example\" is not a domain"
}curl -X PUT "$BASE/video" \
-H "Authorization: Bearer $SIMPLIFYD_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"max_height": 720, "keep_original": true, "allowed_embed_domains": ["example.com"]}'The response is the library, with allowed_embed_domains as stored: lowercased, de-duplicated and sorted. The new list takes effect within a minute.
| Status | When |
|---|---|
200 | Saved. |
400 | An entry is not a domain (the message names it), there are more than 25, max_height is not an allowed value, or the service is not a video library. |
How it is enforced, so you know what it does and does not stop:
- The player page needs a
Refererfrom an allowed site, and browsers are also told not to show it inside any other site's page. A site that sends no referrer at all (Referrer-Policy: no-referrer) cannot embed a restricted library. - Playlists and video data are refused when they are requested from another site. Requests that name no site at all are allowed, because the iPhone's built-in player sends none and refusing it would break playback on every iPhone. So the list stops other sites embedding your videos. It does not stop someone who downloads the playlist with a script.
Data centres
Connections from cloud and hosting providers (AWS, Google Cloud, Azure, DigitalOcean, Hetzner and the like) cannot play your videos, whatever the list above says. That is where scripts and bots run, not people. Mobile networks, home broadband, Opera Mini, iCloud Private Relay and Cloudflare WARP are not affected.
allow_hosting_networks on the library says whether this applies to it. You cannot change it through the API. If your real audience reaches you through a cloud proxy or VPN, contact support and we can allow it for your library.
Traffic that is not counted
Analytics count people. Plays that are clearly not people are left out of every figure — views, viewers, watch time, completion, the carrier split, the monthly report — and totalled separately:
| Field | What it is |
|---|---|
totals.invalid_views, daily[].invalid_views | Plays that were not counted. |
totals.invalid_watch_seconds, daily[].invalid_watch_seconds | Their watch time. |
A play is not counted when it comes from a data centre, from a browser that says it is automated (a crawler, a headless browser, an HTTP library), or when the same viewer has already started that video 20 times that day. The first 20 plays still count, so a classroom of identical computers sharing one connection is counted as it should be.
Library analytics (GET {BASE}/video/analytics) also return traffic_alerts: unusual traffic in the last week, newest first. Each one is also emailed to the workspace when it happens.
kind | Raised when |
|---|---|
spike | A day's watch time is at least 3× the library's average day over the week before, and at least 5 hours. |
concentration | One viewer has at least 10% of a day's watch time, and at least 3 hours. video_title says which video. |
filtered | At least 25% of a day's watch time, and at least 2 hours, was not counted. |
{
"kind": "concentration",
"detected_at": "2026-10-28T08:00:12Z",
"watch_seconds": 297720,
"subject_watch_seconds": 73944,
"share_pct": 24.8,
"message": "A single viewer accounted for 21 hours of 83 hours watched in a day (25%), all on “Simplification of Indices”. That is usually a script or a device left looping, not a person.",
"video_slug": "019c…",
"video_title": "Simplification of Indices"
}watch_seconds counts everything in the day, filtered or not. Each kind is raised at most once a day per library. The same alerts appear in video_traffic on GET /v1/workspaces/{workspace}/alerts.
Other operations
GET {BASE}/video/videos | List the library's videos, newest first. limit and offset for paging. |
PATCH {BASE}/video/videos/{video} | Change title and description. |
PUT {BASE}/video/videos/{video}/poster | Pick the poster frame: { "poster_offset_ms": 4200 }. |
POST {BASE}/video/videos/{video}/tracks | Attach captions: { "language": "en", "label": "English", "content": "<base64 WebVTT>" }. |
POST {BASE}/video/videos/{video}/reprocess | Encode again from the original, if the library keeps originals. |
GET {BASE}/video/videos/{video}/original | A short-lived link to the file as it was uploaded. See Download the original. |
DELETE {BASE}/video/videos/{video} | Remove the video and everything encoded from it. |
GET {BASE}/video/videos/{video}/analytics | Plays, watch time and completion for one video. from and to are YYYY-MM-DD. |
GET {BASE}/video/analytics | The same, across the whole library. |