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

  1. You make a video library: a private home for videos that plycdn prepares for streaming.
  2. You upload a video. It is queued, then encoding, then playable (the lowest quality plays already) and ready (every quality, the poster and the scrubbing previews are made).
  3. 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.
  4. 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. bucketId is the library; key is the video id.
  • WatchVideo: a playback link for a video. Same bucketId and key.
  • 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>
  • startSeconds opens the video at a moment (set while loading, it applies once the video is ready). Change startKey ([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; pass resume={false} / [resume]="false" to always start at the beginning.
  • ttlSeconds sets 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 crossorigin to the video element before you give it a session's address: <video crossorigin controls>.
  • nativeUrl is filled in only where plycdn offers the path form of a link; where it is null, use url.
  • In a browser, usePlayback(libraryId, videoId) (React) and PlycdnPlaybackService (Angular) give you self-renewing links for your own player, with src, nativeSrc, poster, thumbnails, download, expiresAt and refresh(). 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-headers and --forwarded-allow-ips set 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

  1. Upload a short video from the dashboard and wait for ready.
  2. Open it in the dashboard and press play.
  3. Sign in to your application as a person your rule allows. The player appears, plays, and resumes where you stopped after a reload.
  4. Sign in as a person your rule refuses: the player shows a refusal, not the video.
  5. Open your browser's developer tools: the page talks to your backend and to *.plycdn.net only.

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.