plycdn Video — libraries, encoding, the player and signed webhooks
Audience: developers who put video in front of their users through plycdn: courses, lectures, training, recorded events. A video library is a private storage bucket made for video. You upload a file once; plycdn encodes it into adaptive streaming at several qualities, makes a poster and a scrubbing strip, and serves it through signed links that only your backend can issue. Your users watch in a keyboard-operable, accessible player, and your backend hears about each video through signed webhooks.
Everything in the Storage guide about buckets (regions, names, delivery settings, signing keys, usage and the audit log) applies to libraries as well; this guide covers what is different. Library and bucket names share one namespace; a library is always private, so a deleted library's name is held for 90 days after its purge, as a private bucket's is, and then free. The wire contract is in the API reference ("Video libraries", "Videos", "Playback links", "Webhooks"), and installing the packages, the player and your authorizer are in the Integration guide ("Video").
Video has its own package on each platform (version 1.4.0), which builds on the storage ones
(Plycdn.AspNetCore.Video depends on Plycdn.AspNetCore.Storage; the npm packages carry both entry points):
dotnet add package Plycdn.AspNetCore.Video --version 1.4.0 --source YOUR_PACKAGE_FEED_OR_FOLDER
npm install ./vendor/plycdn-angular-1.4.0.tgz # @plycdn/angular/video, for an Angular frontend
npm install ./vendor/plycdn-react-1.4.0.tgz # @plycdn/react/video, for a React frontend
Register it with services.AddPlycdnVideo(builder.Configuration), which also registers storage. Step-by-step
setup for each platform: Storage & video with ASP.NET Core,
with Angular and with React.
As with storage, everything is called from your backend except sending the bytes of an upload and
watching, which happen in the browser. The browser never holds your API key, and a viewer's player
never calls plycdn: it plays from plycdn.net links that your backend signs.
Which key. As with buckets, creating, changing (PATCH) or deleting a library, and creating,
changing or deleting a webhook endpoint or rotating its secret, need an admin API key (a standard
key is refused with admin_key_required, 403). Everything else here works with a standard key:
adding, reading, changing and deleting videos, signing playback, listing endpoints and their
deliveries, sending a test event and redelivering.
1Video libraries
A library is created in one region, like any bucket, and is always private:
POST /api/content/v1/libraries
{ "name": "acme-lectures", "region": "mumbai",
"processing": { "ladder": [240, 360, 480, 720, 1080, 1440], "download": true } }
Leave processing out and the library uses your organization's video settings (Video settings for your
organization).
The answer (202) is the Bucket shape from the Storage guide with kind: "video" and
visibility: "private", plus processing, processingOrigins, usesOrganizationProcessing and videoCount. The library is creating while its
storage and delivery are provisioned and active when ready, exactly as a bucket is; its hostname is
{name}.plycdn.net (acme-lectures.plycdn.net).
What makes a library different from a files bucket:
- Always private. Every file is reached through a signed link.
visibilitycannot be set or changed (validation_error,fields: [["body","visibility"]]), on/librariesor on/buckets. - Video only. Uploads are limited to
video/*, and an upload never replaces a file. Those two rules cannot be widened. You can still cap the largest file withmaxObjectBytes. - Four-hour links. Playback links last 4 hours by default (
delivery.linkTtlSeconds: 14400) rather than a files bucket's 15 minutes. The player renews them long before they run out, so a lecture keeps playing for as long as someone watches (Playing). Every other delivery setting (allowed domains, referrer blocking, countries, IP binding, rate limit) works as in the Storage guide, Delivering. processingsays how new videos are encoded:
| Setting | Default | Meaning |
|---|---|---|
ladder |
[240, 360, 480, 720, 1080] |
The qualities made, by the video's short side: any of 240, 360, 480, 720, 1080, 1440, 2160, each once. Never above the source: a 720p recording gets 240 to 720 even when the ladder goes to 2160. A source whose short side is below 240 gets one quality at its own size. Portrait video keeps its orientation |
keepOriginal |
true |
Keep the file you uploaded after encoding, for your own records or a later re-encode. It is never served |
download |
false |
Also make an MP4 viewers can download (from the highest quality at or below 720p) |
thumbnails |
true |
Make the scrubbing strip the player shows over the seek bar |
A changed processing applies to videos encoded afterwards; videos already encoded keep what they
have until you encode them again. GET /libraries lists your libraries (never your files buckets), and GET /libraries/{libraryId}
reads one; a files bucket's id there answers library_not_found.
Changing a library. PATCH /libraries/{libraryId} takes delivery, processing, analytics and
maxObjectBytes. As everywhere in storage, a field you leave out is unchanged. processing merges:
{"processing": {"keepOriginal": false}} changes that one setting, and the library then has settings of
its own. Every processing setting always has a value, so null on one of its fields is refused with
validation_error (fields naming it); send the value you want. {"processing": null} is different:
it makes the library use your organization's video settings again. On delivery settings null removes a setting as it does on a
bucket (Storage guide, Delivering, "Changing settings"), with one difference: "linkTtlSeconds": null returns a
library's links to 4 hours, the video default.
Delivery analytics. A library carries the same analytics setting as a bucket (switch it on, up to three
breakdowns, a value ceiling) and the same Analytics tab in the dashboard; see Storage guide, Traffic
breakdown. In .NET it is UpdateLibraryRequest.Analytics (and ClearAnalyticsCeiling). Playback links carry no tags (a request for one is refused with 422 validation_error), so break a library's
traffic down by path or a query parameter.
Video settings for your organization
A library has its own settings or uses its organization's:
- A library created without
processinguses its organization's settings, and follows them when they change. A library created withprocessinghas its own four settings; a setting you leave out takes the organization's value as it is when you save. Libraries made before organization settings existed keep the settings they have. - Set your organization's defaults with
videoDefaultsin the organization settings:ladder,keepOriginal,downloadandthumbnails, each optional. A setting left out, ornull, is not set there. - A sub-organization uses its parent's video settings unless it sets its own. Its own can be higher or
lower: a parent's setting is a default, never a limit. A parent that makes every video at up to 720p
can have one sub-organization make
1080and2160as well. - When a video is encoded, we use, in this order: the library's own settings, the library's organization, that organization's parent, then plycdn's defaults (the table above).
- Every library answer says which it is.
usesOrganizationProcessingistruewhen the library follows its organization, andprocessingOriginsnames, for each setting, where its value comes from:library,organization,parentordefault.
Changing a library's or an organization's settings affects videos encoded afterwards. A video already
encoded keeps its qualities until you encode it again. A video says what it was encoded with in its
encoding (Statuses).
Everything else is the bucket's. A library is a bucket, and these stay on the /buckets/{id}
routes with the library's id: retry (a library whose provisioning failed), restore (within 7 days
of deleting it), signing-profile and signing-key/rotate (Signing playback yourself), and listing its objects.
DELETE /libraries/{libraryId} is the bucket's soft delete: nothing plays from that moment, and its
videos are deleted with it. POST /buckets/{libraryId}/restore brings the library and its settings back
within 7 days, but not its videos.
Plan limits. Video is a plan feature: without it, creating a library or a video answers
not_included_in_plan, and GET /plan says what your plan includes. Libraries count towards your
plan's bucket limit. Your plan also sets the longest video it accepts (6 hours unless your plan says
otherwise); a longer one fails with video_too_long (Statuses).
A library taken down by plycdn ("suspended": true, see Takedowns in the Storage guide) plays nothing: creating
videos in it and signing playback links answer 403 bucket_suspended, while listing, reading,
changing its settings and deleting still work.
// Server: your backend, with the API key (PlycdnVideoApi, PlycdnStorageApi and PlycdnVideoSigner are injected)
var library = await plycdn.CreateLibraryAsync(organizationId, new CreateLibraryRequest("acme-lectures", "mumbai")
{
Processing = new LibraryProcessing(Ladder: new[] { 240, 360, 480, 720, 1080, 1440 },
KeepOriginal: true, Download: true, Thumbnails: true),
});
// library.Kind == "video", library.State == "creating"; library.Processing, library.VideoCount
var libraries = await plycdn.ListLibrariesAsync(organizationId);
// Browser: a library's id is the bucketId the upload component takes. Libraries themselves are
// created and changed from your server only.
// <plycdn-upload [bucketId]="libraryId" prefix="sources/" accept="video/*"></plycdn-upload>
const libraryId = course.libraryId; // stored by your backend when it created the library
2Adding a video
There are two ways, and both end the same way: the video is queued, then encoded.
A. Your server creates the video, the browser sends the bytes. Best when your application already has a record for the lesson or recording: you get the video's id before a byte is sent.
POST /api/content/v1/libraries/{libraryId}/videos
{ "title": "Week 1: Introduction", "size": 734003200, "contentType": "video/mp4",
"fileName": "week-1.mp4", "metadata": { "course": "physics-101" } }
The answer (201) is the video ("status": "uploading") with an upload: an ordinary upload session
(Storage guide, Uploading) whose videoId is this video and whose key is sources/{videoId}.mp4 (the extension
comes from fileName, or else from contentType). title is 1 to 200 characters; metadata is up to
50 keys of strings, numbers or booleans and at most 8 KiB as JSON, returned as you sent them; sha256 is optional, as on any
upload. The call requires an Idempotency-Key header, which makes it safe to repeat (the .NET package
sends one for you), as do POST /libraries and POST /webhooks.
Hand upload to the browser; the browser sends the parts to their URLs and your backend completes the
session, exactly as for any upload. Completing it moves the video to queued.
var created = await plycdn.CreateVideoAsync(organizationId, libraryId,
new CreateVideoRequest(Title: "Week 1: Introduction", Size: fileSize, ContentType: "video/mp4",
Sha256: null, FileName: "week-1.mp4",
Metadata: new Dictionary<string, object> { ["course"] = "physics-101" }));
await lessons.SetVideoAsync(lessonId, created.Id); // your own record
return Ok(created.Upload); // to the browser: created.Upload.VideoId == created.Id
The browser packages' uploader carries on with a session it is given. Give it a session source whose "start" answers with the one your server created; everything else (parts, retries, pause, resume after a reload, renewing, completing through your backend) is the uploader's usual work:
// Angular (@plycdn/angular/storage); React is the same with createPlycdnClient(...) in place of client.asSessionApi()
import { PlycdnStorageClient, PlycdnUploader } from '@plycdn/angular/storage';
const session = await firstValueFrom(this.http.post<UploadSession>(`/api/lessons/${lessonId}/video`, { size: file.size }));
const api = this.client.asSessionApi();
const uploader = new PlycdnUploader({ ...api, startUpload: async () => session });
const stored = await uploader.upload(file, { bucketId: session.bucketId, key: session.key }).done;
If the upload is abandoned (aborted, or never completed within its 7 days), the video becomes failed
with upload_abandoned.
B. The browser uploads into the library, and the video is created for you. Best for a simple
"add a video" page: upload under sources/ in the library with the ordinary upload component, and
completing the upload creates the video, titled after the file name (sources/week-2 intro.mp4 becomes
"week-2 intro"). The completion answer (POST /uploads/{uploadId}/complete, which your backend relays)
carries the new video's videoId; the video's sourceKey is the uploaded key, and its uploadId the
upload's id.
// Angular
// <plycdn-upload [bucketId]="libraryId" prefix="sources/" accept="video/*" (stored)="added($event)"></plycdn-upload>
// React
const upload = useUpload();
await upload.start(file, { bucketId: libraryId, key: `sources/${file.name}` });
// Server: find the video an upload created (or wait for the video.ready webhook, described under Webhooks)
var session = await storage.GetUploadAsync(organizationId, uploadId); // PlycdnStorageApi; session.VideoId once completed
var video = await plycdn.GetVideoAsync(organizationId, session.VideoId!.Value);
A library accepts uploads only directly under sources/ (sources/week-1.mp4; not week-1.mp4,
not sources/2026/week-1.mp4). Any other key is refused with validation_error,
fields: [["body","key"]], when the upload is started. Everything else about uploads (part sizes,
resuming, video/* only, the largest file) is as in the Storage guide, Uploading.
Reading, changing and deleting. GET /libraries/{libraryId}/videos lists a library's videos,
newest first, a page at a time (status to filter, cursor, limit 1 to 200, default 50).
GET /videos/{videoId} reads one; your browser code can read one too, through your backend (Playing).
PATCH /videos/{videoId} changes title and metadata (a new metadata replaces the old; leave a
field out to keep it). DELETE /videos/{videoId} deletes a video at once and for good: every
quality, image, download and the kept original are removed, and so is its source file under
sources/; nothing plays from that moment. Unlike a library, a deleted video has no restore window.
While the video's upload is being completed, DELETE answers 409 upload_not_open marked
"retryable": true: ask again shortly.
3Statuses
{ "id": "…", "libraryId": "…", "title": "Week 1: Introduction", "status": "ready", "errorCode": null,
"durationSeconds": 612.48, "width": 1920, "height": 1080, "hasAudio": true,
"renditions": [ { "height": 240, "width": 426, "bandwidth": 524000 },
{ "height": 1080, "width": 1920, "bandwidth": 5478000 } ],
"captions": [], "poster": true, "thumbnails": true, "download": false,
"encoding": { "ladder": [240, 360, 480, 720, 1080], "keepOriginal": true, "download": false, "thumbnails": true,
"origins": { "ladder": "parent", "keepOriginal": "default", "download": "organization", "thumbnails": "default" } },
"sourceKey": "sources/week-1.mp4", "originalKept": true, "storedBytes": 734003200,
"encodingMinutes": 10.208, "disabled": false, "metadata": { "course": "physics-101" },
"uploadId": "…", "createdAt": "…", "updatedAt": "…", "playableAt": "…", "readyAt": "…" }
status |
Meaning |
|---|---|
uploading |
Created by your server (Adding a video, A); the upload has not completed yet |
queued |
The file is in; waiting for an encoder |
encoding |
Being encoded |
playable |
The lowest quality is ready and plays already; the others are still being made. Share the video now if you like: viewers get the full set of qualities as soon as it is ready |
ready |
Every quality, the poster, the scrubbing strip and the download (when asked for) are ready |
failed |
It will not play. errorCode says why |
A video moves forward only: uploading → queued → encoding → playable → ready, or failed at
any point; encoding a ready video again starts it from queued once more. renditions lists the
qualities made (bandwidth in bits per second); poster, thumbnails and download say which of
those files exist; storedBytes is everything the video holds in your library (qualities, images,
download and the kept original).
encoding is what the video was encoded with: the four settings, and where each came from (library,
organization, parent or default). It is null until encoding starts and for videos encoded before
it was recorded. The qualities in renditions can be fewer than encoding.ladder: a quality above the
video's own size is never made.
Encoding a video again
POST /api/content/v1/videos/{videoId}/reencode # 202
Encodes a ready video again with the settings that apply now, for example after you raised your
organization's qualities. It needs an admin key and the original kept (keepOriginal); without it the
answer is 409 original_not_kept. A video that is not ready, or that plycdn has taken down, answers
409 video_not_ready. The answer is the video, now queued. It keeps playing from its current qualities
until the first new one is made, and then goes queued, encoding, playable, ready again, with the
same webhook events. Encoding it again is charged encoding minutes like the first encode, as its own usage entry for that run.
errorCode |
What went wrong | What to do |
|---|---|---|
video_unreadable |
The file could not be read as video: damaged, cut short, of zero length, not really the format its name says, or not a single video file (accepted: MP4, MOV, MKV, WebM, AVI, MPEG-TS, MPEG-PS, FLV, WMV/ASF, Ogg, MXF; playlists, image sequences and GIFs are not) | Check that it plays on your own computer, export it as MP4 if it is another kind of file, then upload it again |
video_no_video_stream |
The file has no picture: an audio recording, or an audio file named .mp4 |
Upload a video; for audio only, use a files bucket |
video_too_long |
Longer than your plan allows | Trim it, or ask us about a plan with longer videos |
video_resolution_unsupported |
Its picture is smaller than 16 pixels or larger than 8192 pixels on a side | Export it at an ordinary size |
video_source_missing |
The uploaded file was deleted before encoding finished | Upload it again |
upload_abandoned |
The upload your server created (Adding a video, A) was aborted or expired before it completed | Create the video again |
encoding_failed |
Encoding failed on our side after every retry; nothing about your file is known to be wrong | Upload it again; contact us if it repeats |
Encoding minutes. Encoding is counted in minutes of source video: a 10-minute 12-second
recording is 10.2 minutes whatever ladder it gets (the exact figure is the source's duration in
seconds ÷ 60, to six decimal places, and it is the video's encodingMinutes). A video is counted
when it becomes ready, and again each time you encode it again; a failed video is never counted. Encoding minutes appear in GET /usage and, per library, in usage by resource (Storage guide, Usage), each against the library that holds
the video. A video that becomes ready after its month was closed is counted as late usage on the
next statement, as described under "Usage by resource" in the API reference. What a video holds counts as
storage of its library, like any other file.
A video taken down by plycdn shows "disabled": true and does not play (see Takedowns in the Storage guide),
exactly like a file taken down: signing a playback link answers 404 video_not_found, and a link signed
before answers 404 (its files are moved out of anywhere a link can reach). A library taken down whole
answers like a bucket: 403 bucket_suspended. The audit log records video.disabled (reason in
detail.reason) and video.reinstated if we reverse it.
var page = await plycdn.ListVideosAsync(organizationId, libraryId, status: "failed");
foreach (var failed in page.Items)
logger.LogWarning("{Title} failed: {Code}", failed.Title, failed.ErrorCode);
// React: polls every 15 s while the video is uploading, queued, encoding or playable
const { data: video } = useVideo(videoId);
if (video?.status === 'failed') showMessage(video.errorCode);
// Angular: this.client.getVideo(videoId).subscribe(video => ...)
4Playing
The packages include a player for each framework. Give it the library and the video:
<!-- Angular (@plycdn/angular/video, PlycdnVideoModule) -->
<plycdn-player [libraryId]="lesson.libraryId" [videoId]="lesson.videoId" (statusChange)="onStatus($event)"></plycdn-player>
// React (@plycdn/react/video, inside <PlycdnProvider>)
<PlycdnPlayer libraryId={lesson.libraryId} videoId={lesson.videoId} onStatus={setStatus} />
Both show the poster, then play the best quality the viewer's connection allows (with a quality menu,
"Auto" first), playback speed from 0.5× to 2×, captions, full screen, the scrubbing strip over the
seek bar, and a download button when the video has a download. They take ttlSeconds (how long each
playback link lasts; the library's link lifetime when left out) and resume (default on).
What the player needs from your backend. Two routes, served by PlycdnVideoController and
PlycdnStorageController (Integration guide, "Video"), which ask your IPlycdnStorageAuthorizer first:
GET videos/{videoId}: the video record (title, status, captions, download), authorized asPlycdnOperation.GetVideo.POST signwith{ "bucketId": <libraryId>, "videoId": <videoId> }: a playback link, authorized asPlycdnOperation.WatchVideo.
The playback link your backend returns:
{ "url": "https://acme-lectures.plycdn.net/v/<videoId>/master.m3u8?token=HS256-…&expires=1790000000&token_path=%2Fv%2F<videoId>%2F",
"nativeUrl": null, "expiresAt": "…",
"posterUrl": "https://acme-lectures.plycdn.net/v/<videoId>/poster.jpg?token=…",
"thumbnailsUrl": "https://acme-lectures.plycdn.net/v/<videoId>/thumbnails.vtt?token=…",
"downloadUrl": null }
One link opens one video: every quality, segment and image under that video, and nothing of any
other. A video can be signed once it is playable; before that /sign answers video_not_ready.
Link renewal and key rotation. The player asks your backend for a fresh link at 80 % of the link's
lifetime (measured from when it arrived, never from the viewer's clock) and carries on without a pause
or a reload: a four-hour lecture plays through any number of renewals. If the delivery network refuses
a link (after you rotated the library's signing key, for example) the player asks for a new one at
once and carries on from the same moment. A stream whose address cannot be reached (the viewer's
network, for example) is treated the same way, at most every 10 seconds. If three renewals in a row fail
or are refused, it stops and says so (playback_link_failed) with a Try again button. A browser that can play neither form of
the stream shows playback_unsupported. Your own page never has to handle either.
Resume. The player remembers where each viewer stopped, in the browser's own storage, and starts
there next time (not in the first 5 seconds or the last 10, and nothing breaks where storage is
blocked). Pass [resume]="false" / resume={false} to always start at the beginning.
A video that is still playable. The player plays the qualities that exist, checks the video every
15 seconds, and when it becomes ready reloads at the same moment so the viewer gets every quality.
Captions. A video's caption tracks appear in captions once captions are added to it, and the
player lists them in its captions menu. Until then the captions button is shown disabled, labelled
"Captions (none available)".
Keyboard. The player's controls are buttons you reach with Tab and operate with Enter or Space. While the player (or one of its controls) has focus:
| Key | Does |
|---|---|
Space or k |
Play or pause |
← / → |
Back or forward 5 seconds |
j / l |
Back or forward 10 seconds |
↑ / ↓ |
Volume up or down 10 % |
m |
Mute or unmute |
c |
Next caption track, then off |
f |
Full screen |
Home / End |
Start or end |
0 to 9 |
Jump to 0 % to 90 % of the video |
< / > |
Slower or faster |
These are ignored while Ctrl, Cmd (Meta) or Alt is held, and while a text field, text area, list or editable element has focus, so they never get in the way of typing. On a focused button, Space and Enter press that button.
Accessibility. Every control has a label; the seek bar is a slider that reads its position in words ("1 minute 5 seconds of 10 minutes"); menus say whether they are open; changes (playing, paused, volume, speed, quality, captions, resuming) are announced politely to screen readers; focus is always visible; and animation is switched off for viewers who ask for reduced motion.
Your own player. usePlayback (React) and PlycdnPlaybackService (Angular) give you signed,
self-renewing links to use with any player: src (the stream), nativeSrc (for browsers that play the
stream natively; see Signing playback yourself), poster, thumbnails, download, expiresAt, loading, error and
refresh(). src is undefined while a link is loading and shortly before it expires, and changes
when it is renewed. With a streaming player library of your own (the example is generic; use your library's way of changing requests), pass the link's query on every request, and call
refresh() when a request is refused with 403:
import { StreamPlayer } from './stream-player'; // your own player library
import { usePlayback, tokenQuery, withToken } from '@plycdn/react/video';
function MyPlayer({ libraryId, videoId }: { libraryId: string; videoId: string }) {
const { src, poster, refresh } = usePlayback(libraryId, videoId);
const ref = useRef<HTMLVideoElement>(null);
const token = useRef('');
if (src) token.current = tokenQuery(src); // the newest link's token
const started = useRef<StreamPlayer>();
useEffect(() => {
if (!src || started.current || !ref.current) return;
const origin = new URL(src).origin;
const player = new StreamPlayer({ rewriteRequest: (url) => withToken(url, token.current, origin) });
player.onError((e) => { if (e.status === 403) refresh(); });
player.load(src);
player.attach(ref.current);
started.current = player;
return () => { player.destroy(); started.current = undefined; };
}, [src === undefined]); // start once; later links only change the token
return <video ref={ref} poster={poster} controls />;
}
A browser that plays the stream natively takes nativeSrc directly as a <video src>. When it
changes, set the new src and restore the position:
const { nativeSrc } = usePlayback(libraryId, videoId);
// <video src={nativeSrc} onLoadedMetadata={e => { e.currentTarget.currentTime = savedPosition; }} />
nativeSrc is undefined wherever plycdn does not offer the native form (Signing playback yourself); use the stream with a
player library there.
5Signing playback yourself
PlycdnVideoSigner.SignVideoAsync (Plycdn.AspNetCore.Video) signs playback links in your own process, from the library's cached
signing profile, with no request to plycdn per link:
var link = await signer.SignVideoAsync(organizationId, libraryId, videoId, ttl: TimeSpan.FromHours(4));
// link.Url, link.NativeUrl, link.ExpiresAt, link.PosterUrl, link.ThumbnailsUrl, link.DownloadUrl
Signed locally, PosterUrl, ThumbnailsUrl and DownloadUrl are always filled in: the signer does
not know which of those files a video has, so check the video's poster, thumbnails and download
before showing them. Signing locally also does not check that the video exists or can play yet; ask
POST /sign (which does, and answers video_not_ready or video_not_found) when that matters.
A library's signing profile (GET /buckets/{libraryId}/signing-profile) carries two fields files
buckets do not use:
{ "bucketId": "…", "scheme": "pl1", "keyId": "k2", "key": "…", "hostname": "acme-lectures.plycdn.net",
"hostnames": ["acme-lectures.plycdn.net"],
"defaultTtlSeconds": 14400, "ipBinding": false, "directoryScheme": "pl1d", "pathTokens": false }
directoryScheme is pl1d for a library with plycdn links and ps3 for a library made before them (null on a files bucket); both are described below. pathTokens says
whether plycdn offers the path form of a link, which browsers that play the stream natively need;
only when it is true does a playback link carry a nativeUrl. Where it is false, nativeUrl is
null and the player uses its built-in engine in every browser that supports it, which is every
current browser, Safari included.
The ps3 scheme. A directory token covers every file under one video's directory:
token_path = "/v/" + videoId + "/" # always ends with a slash
expires = now + ttl # Unix seconds
ip_bytes = b"" # no IP binding
| the 4 bytes of the IPv4 address # IPv4 binding
message = utf8(token_path) + utf8(str(expires)) + ip_bytes + utf8("token_path=" + token_path)
token = "HS256-" + ("1-" if ip else "") + base64url_nopad( HMAC-SHA256(key = utf8(key), msg = message) )
query form: https://{host}{token_path}{file}?token={token}&expires={expires}&token_path={percent_encode(token_path)}
path form: https://{host}/_t/{token}/{expires}{token_path}{file}
file is master.m3u8 for the stream, poster.jpg, thumbnails.vtt or download.mp4; every other
file of the video is reached from those, with the same token. percent_encode encodes every reserved
character, so / becomes %2F. IP binding works as in the Storage guide ("Signing links yourself"):
bind the viewer's IPv4 address. key is the library's signing key from the profile.
The pl1d scheme (libraries with plycdn links) is ps3 with the prefix "PL1-" in place of "HS256-", signed
with the library's key from the profile. The query form and the path form are the same. A pl1d link keeps working
whatever changes on our side. The published vectors (plycdn-signing-vectors-pl1.json,
directory) include both forms. Worked example with the test key pl1-test-key-0123456789abcdefghijklmnopqrstu (a test
vector, never a real key), video 0b6f1c3e-2d4a-4f5b-9c8d-7e6f5a4b3c2d, expires = 1790000000, no IP binding:
token_path: /v/0b6f1c3e-2d4a-4f5b-9c8d-7e6f5a4b3c2d/
message: /v/0b6f1c3e-2d4a-4f5b-9c8d-7e6f5a4b3c2d/1790000000token_path=/v/0b6f1c3e-2d4a-4f5b-9c8d-7e6f5a4b3c2d/
url: https://acme-lectures.plycdn.net/v/0b6f1c3e-2d4a-4f5b-9c8d-7e6f5a4b3c2d/master.m3u8?token=PL1-pzD4t8UtapvUKLEA9E028eu8984nNniHCEwKgM3ZIjo&expires=1790000000&token_path=%2Fv%2F0b6f1c3e-2d4a-4f5b-9c8d-7e6f5a4b3c2d%2F
On a library that has ps3, sign with HS256- as shown next. A library whose CORS allows credentials keeps ps3
for the reason given under CORS in the Storage guide.
Worked example: the message for the published test key SecurityKey (a test vector, never a real
key), video 2f1c1a52-8a38-4b8e-9d7e-7a0e2f3c9b11, expires = 1598024587, no IP binding:
token_path: /v/2f1c1a52-8a38-4b8e-9d7e-7a0e2f3c9b11/
message: /v/2f1c1a52-8a38-4b8e-9d7e-7a0e2f3c9b11/1598024587token_path=/v/2f1c1a52-8a38-4b8e-9d7e-7a0e2f3c9b11/
url: https://acme-lectures.plycdn.net/v/2f1c1a52-8a38-4b8e-9d7e-7a0e2f3c9b11/master.m3u8?token=HS256-fzus3wjZCDu0oHovggTxjnMNr4a9_i3oaAbp_qCqlaI&expires=1598024587&token_path=%2Fv%2F2f1c1a52-8a38-4b8e-9d7e-7a0e2f3c9b11%2F
A token never depends on the host. Test your own signer against this worked example. The .NET package's signer produces exactly this token, including for IP-bound links.
// TypeScript on your server (Node): the signing key never reaches a browser
import { createHmac } from 'node:crypto';
export function signVideo(key: string, host: string, videoId: string, file: string, expires: number): string {
const tokenPath = `/v/${videoId}/`;
const mac = createHmac('sha256', key).update(`${tokenPath}${expires}token_path=${tokenPath}`).digest('base64url');
return `https://${host}${tokenPath}${file}?token=HS256-${mac}&expires=${expires}&token_path=${encodeURIComponent(tokenPath)}`;
}
A signed playback link must be used as issued: the player adds the same token, expires and
token_path to each file it fetches under the video's directory, and changes nothing else.
6Content Security Policy
The player reads the stream's playlists and segments with the browser's own requests, so a strict Content Security Policy needs:
connect-src https://*.plycdn.net; /* playlists, segments, the scrubbing map */
media-src blob: https://*.plycdn.net; /* the stream, and the native form */
img-src https://*.plycdn.net; /* poster and scrubbing images */
worker-src blob:; /* the player's stream decoder runs in a worker */
plus, for uploads, Storage's connect-src https://{label}.upload.plycdn.com (for example
https://in-mum.upload.plycdn.com). A library's delivery answers these cross-origin reads for you (it
adds the CORS headers to m3u8, m4s, mp4, vtt and jpg files), so there is nothing to set in the
library's cors settings, for the plycdn player or for a player of your own. You can narrow https://*.plycdn.net to your libraries' own
hostnames (https://acme-lectures.plycdn.net).
app.Use(async (context, next) =>
{
context.Response.Headers["Content-Security-Policy"] =
"default-src 'self'; connect-src 'self' https://acme-lectures.plycdn.net https://in-mum.upload.plycdn.com; " +
"media-src blob: https://acme-lectures.plycdn.net; img-src 'self' https://acme-lectures.plycdn.net; worker-src blob:";
await next();
});
// A Node server in front of your frontend
res.setHeader('Content-Security-Policy', [
"default-src 'self'",
"connect-src 'self' https://acme-lectures.plycdn.net https://in-mum.upload.plycdn.com",
'media-src blob: https://acme-lectures.plycdn.net',
"img-src 'self' https://acme-lectures.plycdn.net",
'worker-src blob:',
].join('; '));
7Webhooks
A webhook tells your backend when a video changes, so you never have to poll: mark a lesson
available when its video is playable or ready, alert someone when one failed.
Creating an endpoint. Your endpoint must be https:// and publicly reachable:
POST /api/content/v1/webhooks
{ "url": "https://api.customer.example/plycdn", "events": ["video.ready", "video.failed"], "description": "LMS" }
The answer (201) includes a secret (pwhs_…). It is shown once: store it with your other
secrets now; plycdn keeps it only in sealed form and never shows it again. An organization can have up
to 10 endpoints (webhook_limit_reached), each subscribed to any of the events below. Each
sub-organization has its own endpoints and hears only about its own videos. PATCH /webhooks/{webhookId}
changes url, events, description (null removes it) and active (false stops deliveries
without deleting the endpoint); DELETE /webhooks/{webhookId} deletes it. An address that is not
https:// is validation_error; one that is not printable ASCII (use the xn-- form of an
international name), not on port 443, carries credentials, or resolves to a private, local or otherwise
non-public address is webhook_url_invalid. A paused endpoint (active: false) is sent nothing: a
delivery due while it is paused ends failed with no lastError, and redelivering one answers it
unchanged until you resume the endpoint.
| Event | Sent when |
|---|---|
video.playable |
The video's lowest quality is ready and it plays |
video.ready |
Every quality is ready |
video.failed |
The video failed; data.video.errorCode says why |
webhook.ping |
Only when you ping the endpoint (below); never subscribed to |
Each is sent once per video, when the video enters that status.
The request. A POST to your URL with a compact JSON body:
{"id":"8b9c…","type":"video.ready","createdAt":"2026-09-26T10:15:00+00:00","organizationId":"…","data":{"video":{"id":"…","libraryId":"…","title":"Week 1: Introduction","status":"ready","errorCode":null,"…":"…"}}}
data.video is the Video shape (Statuses), as it was at that moment. The headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
plycdn-webhooks/1 |
Plycdn-Event |
The event, as in type |
Plycdn-Delivery |
This delivery's id: the same on every retry of it |
Plycdn-Webhook |
The endpoint's id |
Plycdn-Signature |
t=<unix seconds>,v1=<hex signature>, with a second v1 during a secret rotation |
Verifying the signature. Always verify before acting on a webhook: anyone can send a request to
your URL. In ASP.NET Core, PlycdnWebhookVerifier.ReadAsync reads the body, verifies it and parses it:
app.MapPost("/plycdn", async (HttpRequest request, IOptions<MyPlycdnSecrets> secrets, Lessons lessons) =>
{
PlycdnWebhookEvent evt;
try { evt = await PlycdnWebhookVerifier.ReadAsync(request, secrets.Value.WebhookSecrets); }
catch (PlycdnWebhookSignatureException) { return Results.Unauthorized(); }
if (!await lessons.FirstTimeAsync(request.Headers["Plycdn-Delivery"].ToString()))
return Results.Ok(); // already handled: a retry of a delivery we took
if (evt.Type == "video.ready" && evt.AsVideo() is { } video)
await lessons.PublishAsync(video.Id);
return Results.Ok();
});
PlycdnWebhookVerifier.Verify(body, signatureHeader, secrets) does the same over bytes you already
hold. In words: take t and each v1 from Plycdn-Signature; compute HMAC-SHA256 with the secret as
the key over the bytes of t, a ., then the exact body bytes as received (not re-serialized
JSON); the request is genuine when that, in lower-case hex, equals any v1 (compare in constant time)
and t is within 5 minutes of your clock. In Node:
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyPlycdn(body: Buffer, header: string | undefined, secrets: string[], toleranceSeconds = 300): boolean {
const parts = (header ?? '').split(',').map(p => p.trim().split('=', 2));
const t = Number(parts.find(([k]) => k === 't')?.[1]);
const given = parts.filter(([k, v]) => k === 'v1' && /^[0-9a-f]{64}$/.test(v ?? '')).map(([, v]) => Buffer.from(v, 'hex'));
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds || !given.length) return false;
return secrets.some(secret => {
const expected = createHmac('sha256', secret).update(`${t}.`).update(body).digest();
return given.some(v => timingSafeEqual(v, expected));
});
}
(Read the body raw, for example express.raw({ type: 'application/json' }), before any JSON parser
touches it.)
Answer quickly, and expect repeats. Answer any 2xx within 10 seconds, then do the work
afterwards (a queue, a background job); a slower answer is a failed attempt. A delivery can arrive
more than once (your answer was lost, or you asked for a redelivery), so deduplicate by
Plycdn-Delivery as above. Events can arrive out of order: rely on data.video.status, not on the
order of arrival.
Retries. A delivery that is not answered with a 2xx is tried again after 1 minute, 5 minutes,
15 minutes, 30 minutes, 1, 2, 4, 8 and 12 hours, then 24 hours twice: 12 attempts over about three days,
after which it is failed. Answering 410 Gone stops retrying that delivery at once (it is
failed). Redirects are never followed. An endpoint is never switched off because its deliveries
fail: fix it, then redeliver what it missed.
Rotating the secret. POST /webhooks/{webhookId}/secret/rotate answers a new secret (shown once) and
previousSecretExpiresAt. For the next 24 hours every delivery carries two signatures, one with
each secret, so you can deploy the new secret without missing anything; configure both on your side
during the change (ReadAsync accepts any of the secrets you give it). After 24 hours only the new
secret signs.
Ping. POST /webhooks/{webhookId}/ping sends a real, signed webhook.ping to the endpoint
("data": { "webhookId": "…" }), whatever it is subscribed to: use it to check your verification end
to end. It answers the delivery (202), which you can follow in the log.
Limit. Pings, redeliveries and secret rotations together are limited to 60 an hour for your
account (across its organizations; refused calls count). Past that they answer 429 rate_limited with
Retry-After in seconds, and nothing is sent or changed.
The delivery log. GET /webhooks/{webhookId}/deliveries lists deliveries, newest first (state =
pending, succeeded or failed; cursor; limit 1 to 200, default 50), each with its attempts, the
last answer's status and time, and the payload that was sent. The log keeps the last 90 days: a
finished delivery (succeeded or failed) is removed 90 days after it was created, so redeliver or
record what you need before then. The dashboard shows the same under Developers → Webhooks. POST /webhook-deliveries/{deliveryId}/redeliver sends a finished delivery once more, with the same
Plycdn-Delivery and body (a delivery still pending is already on its way, and is left as it is; so is
one whose endpoint is paused).
A failed attempt's lastError says what happened:
lastError |
What happened | What to do |
|---|---|---|
timeout |
No answer within 10 seconds | Answer first, then do the work |
connection_failed |
The connection was refused or dropped | Check that the endpoint is up and reachable from the internet |
tls_failed |
The TLS handshake failed | Serve a valid certificate for the endpoint's name, from a public certificate authority |
dns_failed |
The name did not resolve | Check the URL and your DNS |
address_forbidden |
The name resolved to a private, local or otherwise non-public address | Publish the endpoint on a public address |
redirect_not_followed |
The endpoint answered with a redirect (lastStatus says which) |
Use the final URL as the endpoint |
http_error |
The endpoint answered, but not with a 2xx (lastStatus has the status) |
Check your endpoint's logs for that time |
var hook = await plycdn.CreateWebhookAsync(organizationId,
new CreateWebhookRequest(new Uri("https://api.customer.example/plycdn"), new[] { "video.ready", "video.failed" }, "LMS"));
await secrets.SaveAsync("plycdn-webhook", hook.Secret); // shown once
await plycdn.PingWebhookAsync(organizationId, hook.Id);
var failed = await plycdn.ListWebhookDeliveriesAsync(organizationId, hook.Id, state: "failed");
foreach (var delivery in failed.Items)
await plycdn.RedeliverWebhookAsync(organizationId, delivery.Id);
// Express, with verifyPlycdn from above
app.post('/plycdn', express.raw({ type: 'application/json' }), async (req, res) => {
if (!verifyPlycdn(req.body, req.get('Plycdn-Signature'), [process.env.PLYCDN_WEBHOOK_SECRET!])) return res.sendStatus(401);
res.sendStatus(204); // answer first
const event = JSON.parse(req.body.toString('utf8'));
if (await firstTime(req.get('Plycdn-Delivery')!)) await queue.add(event);
});
8Errors
| Code | Status | What to do |
|---|---|---|
library_not_found |
404 | Unknown library id, a files bucket's id, or another organization's library |
video_not_found |
404 | Unknown video id, a deleted video, another organization's, or (on /sign) a video from another library or one taken down |
video_not_ready |
409 | /sign for a video that is not playable or ready yet. Wait for the video.playable webhook, or check status |
bucket_suspended |
403 | plycdn has taken the library down: no new videos and no playback links until it is reinstated (Takedowns) |
not_included_in_plan |
403 | Video is not part of your plan |
plan_limit_reached |
403 | Your plan's bucket limit is reached; libraries count towards it |
original_not_kept |
409 | Encoding a video again, when its library did not keep the original. Videos made while keepOriginal was false cannot be encoded again |
validation_error |
422 | fields names what is wrong: for example [["body","key"]] for a library upload outside sources/, [["body","processing","ladder",0]] for a quality that does not exist, [["body","visibility"]] for a library made public, [["body","url"]] for a webhook address that is not https:// |
webhook_not_found |
404 | Unknown or deleted endpoint, or another organization's |
webhook_delivery_not_found |
404 | Unknown delivery, or another organization's |
webhook_url_invalid |
400 | The endpoint's URL is not printable ASCII, not on port 443, carries credentials, or resolves to a private, local or otherwise non-public address. Publish it on a public address |
webhook_limit_reached |
409 | You have 10 endpoints already. Delete one, or subscribe one endpoint to more events |
forbidden |
403 | Your IPlycdnStorageAuthorizer refused the browser's request (GetVideo or WatchVideo) |
playback_unsupported |
— | Shown by the player, never answered by plycdn: this browser can play the stream in neither form. Suggest a current browser |
playback_link_failed |
— | Shown by the player: three renewals in a row failed or were refused, or the stream's address could not be reached. Check that your backend's sign route answers and the viewer's connection, then Try again |
A video's errorCode values are under Statuses and a webhook delivery's lastError values under Webhooks. Everything
else (bucket_not_found, bucket_not_active, upload codes) is as in the Storage guide, Errors.
try { return Ok(await signer.SignVideoAsync(organizationId, libraryId, videoId)); }
catch (PlycdnException e) when (e.Code is "video_not_ready") { return Conflict(new { code = e.Code }); }
const { error } = usePlayback(libraryId, videoId);
if (error?.code === 'video_not_ready') return <p>This video is still being prepared.</p>;