Playing video in your application, step by step
This page takes you from an empty account to a video playing in your own application, for the people you choose: a library to hold the videos, an upload, a wait until the video plays, the routes your backend needs, and the player on your page, or a player of your own or a phone app. Every field and rule is in the reference: Video, Integration and API reference.
You need: a plan that includes video, an admin API key for your server (Developers → API keys), your plycdn organization id, and a backend running one of plycdn's server packages (ASP.NET Core, Node.js or Python). For the player on your page, Angular or React.
1How it works
- You make a video library: a private home for videos that plycdn prepares for streaming.
- You upload a video. It is
queued, thenencoding, thenplayable(the lowest quality plays already) andready(every quality, the poster and the scrubbing previews are made). - Your page shows the player. The player asks your backend for a short-lived playback link; your backend decides who may watch and gets the link from plycdn.
- The video streams to the viewer straight from plycdn. The browser never holds your key.
2Create a library
In the dashboard: Video streaming → Video libraries → New library. Choose a name (it is the library's address,
acme-lectures.plycdn.net, and is unique across plycdn), a region and how it prepares videos. Wait until the library
shows as active.
From your code (admin key; the answer is 202, and the library is creating until it is active):
ASP.NET Core
var library = await videos.CreateLibraryAsync(orgId, new CreateLibraryRequest("acme-lectures", "mumbai"));
Node.js
const library = await video.createLibrary(orgId, { name: 'acme-lectures', region: 'mumbai' });
Python
library = video.create_library(org_id, {"name": "acme-lectures", "region": "mumbai"})
Keep library.id: it is the libraryId of every call below (Video streaming → Video libraries also shows it with
Copy; see Find your IDs). Region codes come from your package's region listing;
treat them as data. Leave processing out and the library uses your organization's video settings (see
Choose the qualities).
3Upload a video
Pick the way that fits. All of them end the same way: the video is queued and then encoded.
In the dashboard: open the library → Videos → Upload videos, or drag files onto the page. Each file becomes a video, titled after its file name.
From your page (the browser sends the bytes). Upload into the library's sources/ folder with the upload component;
completing the upload creates the video. Your backend must allow StartUpload and ContinueUpload for the uploader
(see Add the routes your backend needs).
React
import { PlycdnUpload } from '@plycdn/react/storage';
<PlycdnUpload bucketId={libraryId} prefix="sources/" accept="video/*" onStored={(stored) => console.log(stored)} />
Angular
<plycdn-upload [bucketId]="libraryId" prefix="sources/" accept="video/*" (stored)="added($event)"></plycdn-upload>
The upload resumes after a dropped connection or a reload. A library takes video/* only, and only under sources/.
From your server (the server holds the file). PlycdnVideoUploader creates the video and uploads its file, resumed
and retried:
ASP.NET Core
var video = await uploader.UploadVideoAsync(orgId, libraryId, "/data/week-1.mp4", title: "Week 1: Introduction");
Node.js
const uploads = new PlycdnVideoUploader(new PlycdnVideoApi(storage), new PlycdnUploader(storage));
const video = await uploads.uploadVideo(orgId, libraryId, '/data/week-1.mp4', { title: 'Week 1: Introduction' });
Python
video = uploads.upload_video(org_id, library_id, "/data/week-1.mp4", title="Week 1: Introduction")
Have your own record for the lesson already? Create the video first with CreateVideoAsync, createVideo or
create_video (it takes a title, the size, the contentType and your own metadata), keep its id in your record,
and hand the upload in the answer to the browser. Adding a video shows both ways.
4Wait until it plays
A video can be played once it is playable; before that your backend's sign route answers video_not_ready.
| Status | Meaning |
|---|---|
uploading |
Created by your server; the upload hasn't completed |
queued |
The file is in; waiting for an encoder |
encoding |
Being encoded |
playable |
The lowest quality plays already; the others are still being made |
ready |
Every quality, the poster and the scrubbing previews are ready |
failed |
It will not play; errorCode says why (Statuses) |
In the dashboard: the library's Videos tab shows each video's status; open one to preview it.
In your page: the player shows a playable video straight away, checks every 15 seconds and reloads at the same
moment when it is ready. To show your own "still being prepared" message, read the video: React useVideo(videoId) polls
while the video is uploading, queued, encoding or playable; Angular client.getVideo(videoId) reads it once.
On your server: don't poll; let plycdn tell you. Under Developers → Webhooks → Add endpoint, subscribe an https://
address to video.playable, video.ready and video.failed, store the secret it shows once, and verify every request.
Webhooks, step by step walks through it. For a one-off check, read the video:
const v = await video.getVideo(orgId, videoId); // v.status, v.errorCode
5Add the routes your backend needs
The player and the upload component call your backend, never plycdn. Your backend answers with plycdn's routes and asks your rule who may do what. Everything is refused until you say yes.
ASP.NET Core
builder.Services.AddPlycdnVideo(builder.Configuration); // storage and video
builder.Services.AddScoped<IPlycdnStorageAuthorizer, CourseVideoAuthorizer>();
// ...
app.UseAuthentication(); app.UseAuthorization(); app.MapControllers();
public sealed class CourseVideoAuthorizer : IPlycdnStorageAuthorizer
{
public Task<bool> AuthorizeAsync(ClaimsPrincipal user, PlycdnOperation operation, Guid bucketId,
string? key, CancellationToken ct) =>
Task.FromResult(operation switch
{
PlycdnOperation.GetVideo or PlycdnOperation.WatchVideo => user.IsInRole("learner"), // key is the video id
_ => user.IsInRole("instructor"), // uploads
});
}
Node.js
app.set('trust proxy', 1);
app.use('/api/plycdn/v1', createPlycdnRouter({
storage,
signer: new PlycdnLinkSigner(storage),
video: new PlycdnVideoApi(storage),
resolveOrganization: (req) => req.user?.plycdnOrganizationId ?? null,
authorize: (req, operation, bucketId, key) => {
const watching = operation === PlycdnOperation.GetVideo || operation === PlycdnOperation.WatchVideo;
return watching ? req.user?.roles.includes('learner') === true : req.user?.roles.includes('instructor') === true;
},
}));
app.use(express.json());
Python
def authorize(request, operation, bucket_id, key):
roles = request.state.user.roles
if operation in (PlycdnOperation.GetVideo, PlycdnOperation.WatchVideo):
return "learner" in roles
return "instructor" in roles
app.include_router(plycdn_router(
storage=storage, signer=AsyncPlycdnLinkSigner(storage), video=AsyncPlycdnVideoApi(storage),
resolve_organization=resolve_organization, authorize=authorize,
), prefix="/api/plycdn/v1")
What your rule is asked:
GetVideo: reading a video's title, status and captions.bucketIdis the library;keyis the video id.WatchVideo: a playback link for a video. SamebucketIdandkey.StartUpload,ContinueUpload: only if the browser uploads.
The organization always comes from the signed-in user (the claim plycdn_organization_id in ASP.NET Core, your
resolveOrganization or resolve_organization hook elsewhere), never from the browser. The Node.js and Python hooks must
return exactly true / True. Mount the Node.js router before express.json(). Browsers get 403 forbidden when your rule says no.
6Put the player on the page
React
import { PlycdnProvider, PlycdnPlayer } from '@plycdn/react/video';
<PlycdnProvider config={{ baseUrl: '/api/plycdn/v1' }}>
<PlycdnPlayer libraryId={libraryId} videoId={videoId} startSeconds={0}
onStatus={(s) => console.log(s.status)} onProgress={(p) => setSeconds(p.currentTime)} />
</PlycdnProvider>
Angular
PlycdnVideoModule.forRoot({ apiBaseUrl: '/api/plycdn/v1' }) // once, in your module
<plycdn-player [libraryId]="libraryId" [videoId]="videoId" [startSeconds]="0"
(statusChange)="onStatus($event)" (progress)="onProgress($event)"></plycdn-player>
startSecondsopens the video at a moment (set while loading, it applies once the video is ready). ChangestartKey([startKey]in Angular), a click counter for instance, to go to the same moment again.onStatus/(statusChange)reports{ status: 'loading' | 'ready' | 'error', error, message }.onProgress/(progress)reports{ currentTime, duration, playing, ended }about once a second. These positions come from the browser: fine for a progress bar, never for anything you enforce (see Serial playback).resume(default on) remembers where each viewer stopped in the browser's own storage and starts there next time; passresume={false}/[resume]="false"to always start at the beginning.ttlSecondssets how long each playback link lasts; the library's link lifetime applies when left out.
The player offers a quality menu ("Auto" first), speed, captions, full screen, scrubbing previews and a download button when the video has a download. It renews its link before it runs out, so a long video plays through. Keyboard use and accessibility are described in Playing.
7Play in a player of your own, or a phone app
Your backend gives the player an address; there are two ways to get one.
| You want | Ask for | Server package call |
|---|---|---|
| An ordinary link for a page or app of your own | POST /sign with bucketId (the library) and videoId |
SignVideoAsync, signVideo, sign_video (signed in your process) |
| A link that also keeps the viewer's place and watch time (and works for serial playback) | POST /playback-sessions with bucketId, videoId and optionally resumeId |
StartPlaybackAsync, startPlayback, start_playback |
// your own route, after your own sign-in check
const link = await videoSigner.signVideo(orgId, libraryId, videoId, { ttlSeconds: 3600 });
res.json({ url: link.url, posterUrl: link.posterUrl, expiresAt: link.expiresAt });
Give url to any video player: the one built into iOS and Android, or a player library. The answer also carries
posterUrl, thumbnailsUrl, downloadUrl (when the video has them), expiresAt and nativeUrl.
- A link opens one video, and every quality and image under it. Use it exactly as issued: a player that adds its own query parameters or changes the address is refused.
- Ask for a new link before
expiresAt(the players do it at 80 % of the lifetime) and again when a request is refused. - On your own web page, add
crossoriginto the video element before you give it a session's address:<video crossorigin controls>. nativeUrlis filled in only where plycdn offers the path form of a link; where it isnull, useurl.- In a browser,
usePlayback(libraryId, videoId)(React) andPlycdnPlaybackService(Angular) give you self-renewing links for your own player, withsrc,nativeSrc,poster,thumbnails,download,expiresAtandrefresh(). The Video guide has a full example.
Playback links don't need CORS settings on the library: its delivery answers a player's reads for you.
8Store and sign on your domain
What to keep in your database. Keep the video's id (and its library's id) with your own record for the lesson
or recording; that is all you need to play it later. Every video plays from the same place in its library, given as
playbackPaths on the video:
"playbackPaths": { "playlist": "/v/0b6f1c3e-2d4a-4f5b-9c8d-7e6f5a4b3c2d/master.m3u8",
"poster": "/v/0b6f1c3e-2d4a-4f5b-9c8d-7e6f5a4b3c2d/poster.jpg",
"thumbnails": "/v/0b6f1c3e-2d4a-4f5b-9c8d-7e6f5a4b3c2d/thumbnails.vtt", "download": null }
playlist is the stream (an HLS playlist) every player opens; the others are null until the video has them. You
get the id the moment the video is created (CreateVideoAsync, createVideo, create_video, or the videoId in an
upload's completion answer), before it has finished encoding, and the paths never change. Don't store a signed link:
it opens one video for a limited time and stops working when it expires. Sign a fresh one for each viewer instead.
Play on your own domain. Add a hostname of yours to the library, such as video.example.com (dashboard: open the
library → Settings → Custom domains → Manage domains, or POST /domains with the library's id as bucketId; the
steps and DNS records are in Your own domain). Once it is active, sign on it:
ASP.NET Core
var link = await signer.SignVideoAsync(orgId, libraryId, videoId,
new PlycdnVideoSignOptions { Ttl = TimeSpan.FromHours(1), Hostname = "video.example.com" });
// link.Url: https://video.example.com/v/<videoId>/master.m3u8?token=...
Node.js
const link = await videoSigner.signVideo(orgId, libraryId, videoId, { ttlSeconds: 3600, hostname: 'video.example.com' });
Python
link = signer.sign_video(org_id, library_id, video_id, ttl_seconds=3600, hostname="video.example.com")
Every address in the answer (url, nativeUrl, posterUrl, thumbnailsUrl, downloadUrl) is then on your hostname.
POST /sign and POST /playback-sessions take the same hostname (sessions are how serial and watermarked libraries
play), and the players send it for you when you give them one:
React
<PlycdnPlayer libraryId={lesson.libraryId} videoId={lesson.videoId} hostname="video.example.com" />
Angular
<plycdn-player [libraryId]="lesson.libraryId" [videoId]="lesson.videoId" hostname="video.example.com"></plycdn-player>
Leave hostname out for the library's own address (acme-lectures.plycdn.net). A hostname that isn't an active
domain of that library is refused with 409 domain_not_active. Live streams always play on the library's own address.
Add your hostname to your Content Security Policy too.
In the dashboard: open a video → Playback. It shows the video's id, its paths and the full address on each of the library's hostnames, each with a copy button. Copy test link signs a link for one hour on the hostname you picked, to try in any player.
9Content Security Policy
If your site sends a Content-Security-Policy, add:
connect-src https://*.plycdn.net;
media-src blob: https://*.plycdn.net;
img-src https://*.plycdn.net https://watch.plycdn.com;
worker-src blob:;
https://watch.plycdn.com is where your player logo comes from: leave it out and videos play without the logo.
For uploads from the browser, connect-src also needs https://{label}.upload.plycdn.com for your library's region (for
example https://in-mum.upload.plycdn.com). You can narrow *.plycdn.net to your libraries' own hostnames. More in
Content Security Policy.
10Mobile viewers and links bound to the viewer's address
A library can bind each link to the IPv4 address it was signed for (Settings → bind links to the viewer's address). It stops link sharing, and it suits mobile viewers badly: a phone moves between Wi-Fi and mobile data, and many networks are IPv6 only, which an address-bound link cannot serve. Turn it on only for viewers on fixed networks.
If you do, your sign route signs for the address of the request, so your server must see the viewer's address, not your load balancer's:
- Node.js:
app.set('trust proxy', 1), the number of proxies in front of you. - Python: run uvicorn with
--proxy-headersand--forwarded-allow-ipsset to your balancer's address. - ASP.NET Core: configure forwarded headers, for example
app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor }), before the plycdn routes.
With no IPv4 address the sign route answers 422 validation_error. From your own code, pass the viewer's address as ip
(POST /sign requires it on an address-bound library and refuses it on any other). For a phone app calling your own
route, read the address the same way.
11Choose the qualities
In the dashboard: Video streaming → Video encoding sets what every new video in your libraries is made with: Qualities, and under Extras the poster and scrubbing previews, the MP4 download and keeping the original. In force now shows what applies and where each choice comes from. A library can choose its own under its Settings. A sub-organization uses its parent's settings unless it sets its own.
From your code: send processing when you create the library, or PATCH /libraries/{libraryId}:
POST /api/content/v1/libraries
{ "name": "acme-lectures", "region": "mumbai",
"processing": { "ladder": [240, 360, 480, 720, 1080], "download": true } }
ladder takes any of 240, 360, 480, 720, 1080, 1440, 2160 (the short side), never above the source. download
makes an MP4 viewers can download, from the highest quality at or below 720p.
Settings apply to videos encoded afterwards. To make a video you already have with the new settings: open it in the
dashboard → Process again (available once it is ready, and only when its original was kept), or call ReencodeVideoAsync,
reencodeVideo or reencode_video (admin key). It keeps playing from its current qualities until the first new one
exists, and encoding it again is counted like the first encode. See Encoding a video again.
12Keep viewers from skipping ahead
For courses and training, turn on serial playback and name each viewer on your server. Everything else on this page stays the same: see Serial playback, step by step.
13Show who is watching, and your logo
Draw each viewer's name, email or your ID for them over the video, so a recording shows who it came from, and your logo in a corner: Account → Branding, then a few lines on your server to name the viewer. The players need no change. See Viewer watermark and your logo.
14Know how long each viewer watched
Name the viewer on your server (PlaybackResumeIdFor, playbackResumeIdFor, playback_resume_id_for) and plycdn keeps
each viewer's watch time in any library, serial or not, with no change to your pages. Read it per viewer and per video in the
dashboard (library → Watch time) or from your server: see Watch time per viewer, step by step.
15Check it works
- Upload a short video from the dashboard and wait for ready.
- Open it in the dashboard and press play.
- Sign in to your application as a person your rule allows. The player appears, plays, and resumes where you stopped after a reload.
- Sign in as a person your rule refuses: the player shows a refusal, not the video.
- Open your browser's developer tools: the page talks to your backend and to
*.plycdn.netonly.
16Questions and problems
The player says the video isn't ready (video_not_ready). The video isn't playable yet. Wait, or subscribe to
video.playable. Starting a session shortly after an upload can also answer video_not_ready with retryable: true:
ask again.
I get forbidden. Your authorizer (or authorize hook) refused GetVideo or WatchVideo. It refuses everything
until you allow it. If it should allow them, check that it is asked for the right operation and that the organization comes from the signed-in user.
The player says "playback link failed" (playback_link_failed). 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, that your rule still allows the viewer,
and (if you rotated the library's signing key a moment ago) wait a minute; the viewer presses Try again.
The player says the browser can't play the video (playback_unsupported). The browser can play the stream in neither
form. Suggest a current browser.
video_not_found. The id is wrong, the video was deleted, it belongs to another library or organization, or plycdn has
taken it down.
The video failed. errorCode says why: video_unreadable (export it as MP4 and upload again), video_no_video_stream
(a library takes video, not audio), video_too_long (longer than your plan allows), video_resolution_unsupported,
video_source_missing, upload_abandoned (create the video again) or encoding_failed (upload it again; contact us if it
repeats). Failed videos are not charged.
video_source_exists on upload. A video was already made from that name. Delete that video first, or upload under
another name.
original_not_kept on Process again. The video's library didn't keep originals when it was made.
domain_not_active when signing a video. The hostname isn't an active custom domain of that library: check the
spelling, that it was added to this library (not another one) and that it shows as active.
serial_playback_requires_session. Serial libraries play only through a session: see
Serial playback.
My own player plays nothing, or stops after a while. Check that it uses the address unchanged, that the page has
crossorigin on the <video> for a session, that your Content Security Policy allows *.plycdn.net, and that it asks for
a new link before expiresAt.
Viewers on phones are refused. The library binds links to the viewer's address. Switch it off for mobile audiences, or make sure your server passes the viewer's current IPv4 address on every request.
403 not_included_in_plan on creating a library. Video isn't part of your plan: contact us to add it.