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.
Upload a video
POST {BASE}/video/videos
Content-Type: multipart/form-dataPost the file as a form field named file. One request: when it returns, the video exists and encoding has been queued behind it.
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');The third argument is the filename, and it is not optional — without it there is no extension, and the extension is how we know what container the file is.
<?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 200 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. The file is streamed to storage as it arrives rather than held in memory first, so fields sent after it are never seen. FormData sends parts in the order you append them, so appending title first is enough. If you cannot control the order, pass it in the query string instead: 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 200 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 200.0 MiB limit for uploading in one request; register the upload instead and send it in parts"
}That limit is about the request, not the video: the same file uploads fine in parts, up to 8 GiB. The reason for the line is that a request this API is carrying occupies a server for as long as the transfer takes, and a slow client with a gigabyte file would hold one for a very long time.
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));
}
}The shape is the same in any language: read the video, look at status, sleep, repeat. Nothing is lost by not waiting. The video id is yours from the moment the upload returns, so you can store it, show a placeholder, and fill the player in when a later request finds it ready.
Put it on a page
The simplest thing that works is the snippet the API already wrote for you:
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
Over 200 MiB, upload in parts. The bytes go from your machine straight to storage rather than through the API, which is what makes an 8 GiB file possible and an interrupted upload resumable.
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.
Writing that yourself is real work, and you mostly do not have to:
- 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.
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. |
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. |