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. visibility cannot be set or changed (validation_error, fields: [["body","visibility"]]), on /libraries or 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 with maxObjectBytes.
  • 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.
  • processing says 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 processing uses its organization's settings, and follows them when they change. A library created with processing has 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 videoDefaults in the organization settings: ladder, keepOriginal, download and thumbnails, each optional. A setting left out, or null, 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 1080 and 2160 as 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. usesOrganizationProcessing is true when the library follows its organization, and processingOrigins names, for each setting, where its value comes from: library, organization, parent or default.

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 as PlycdnOperation.GetVideo.
  • POST sign with { "bucketId": <libraryId>, "videoId": <videoId> }: a playback link, authorized as PlycdnOperation.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>;