Simplifyd Cloud
HomeHTTP APIVideos

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-data

Post 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 yet

Do 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 yet

The 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

FieldDescription
fileRequired. The video, with a filename. The extension is how we know what container it is, so file.mp4 matters and blob does not work.
titleOptional. 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"
}
FieldWhat it is
playback_urlThe HLS master playlist. Give it to any HLS-capable player.
embed_urlA ready-made player page for this video, for an <iframe>.
poster_urlThe still shown before playback starts.
iframe_snippetThe <iframe> for embed_url, already written.
script_snippetThe alternative: the player mounted in your own DOM, so your page can style and script it.
renditionsWhat 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

StatusMeaning
uploadingThe file has not finished arriving. Only reachable through the parts flow.
queuedStored, waiting its turn to encode.
processingBeing encoded. progress_pct moves.
readyIt plays, and the links above are populated.
failedstatus_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:

  1. POST {BASE}/video/videos with a JSON body — { "title": "...", "filename": "talk.mp4", "size": 1503238553 } — returns the video id and a batch of presigned part URLs.
  2. PUT each part to its URL, 16 MiB at a time, keeping the ETag header each one returns. Ask for more URLs with POST {BASE}/video/videos/{video}/parts as you go.
  3. POST {BASE}/video/videos/{video}/complete with 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 training does 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/videosList 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}/posterPick the poster frame: { "poster_offset_ms": 4200 }.
POST {BASE}/video/videos/{video}/tracksAttach captions: { "language": "en", "label": "English", "content": "<base64 WebVTT>" }.
POST {BASE}/video/videos/{video}/reprocessEncode 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}/analyticsPlays, watch time and completion for one video. from and to are YYYY-MM-DD.
GET {BASE}/video/analyticsThe same, across the whole library.