Simplifyd CloudSimplifyd 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.


List videos

GET {BASE}/video/videos?limit=50&offset=0

Returns the videos in a library, newest first.

Query parameters

FieldDescription
limitPage size from 1 to 200. Defaults to 50. Values outside this range fall back to 50.
offsetNumber 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-data

Post 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 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');

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

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. 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"
}
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));
  }
}

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:

  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.

The CLI and Go SDK handle multipart uploads, retries, and resuming transfers:

  • 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.

Download the original

GET {BASE}/video/videos/{video}/original

Returns 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 permission
url=$(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}/video

By 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.

FieldDescription
allowed_embed_domainsThe 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_heightRequired. The library's highest quality: 240, 360, 480, 720 or 1080. Send the current value back if you are not changing it.
keep_originalRequired. 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.

StatusWhen
200Saved.
400An 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 Referer from 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:

FieldWhat it is
totals.invalid_views, daily[].invalid_viewsPlays that were not counted.
totals.invalid_watch_seconds, daily[].invalid_watch_secondsTheir 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.

kindRaised when
spikeA day's watch time is at least 3× the library's average day over the week before, and at least 5 hours.
concentrationOne viewer has at least 10% of a day's watch time, and at least 3 hours. video_title says which video.
filteredAt 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/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.
GET {BASE}/video/videos/{video}/originalA 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}/analyticsPlays, watch time and completion for one video. from and to are YYYY-MM-DD.
GET {BASE}/video/analyticsThe same, across the whole library.