plycdn for Node.js
Two packages put plycdn in your Node.js server: @plycdn/content-search-node for Content Search (search, answers, administration, and the routes your own frontend calls) and @plycdn/node for storage, delivery and video. They need Node.js 18 or later, run on your server only (the API key never reaches a browser), carry TypeScript types, and work from both CommonJS and ES modules. The browser side is in Storage & video with Angular and Storage & video with React; what each call returns, field by field, is in the Storage and Video guides and the API reference.
1Install the packages
Install only what you use. The two packages are independent: use either, or both in one application.
npm install ./plycdn-content-search-node-1.19.0.tgz # Content Search
npm install ./plycdn-node-1.11.0.tgz # storage, delivery and video
The routes for your frontend are built on Express (NestJS uses Express by default), and the S3 and Azure clients use the tools you already know. Install what you need next to the packages:
npm install express # the routes for your frontend and the webhook receiver
npm install @aws-sdk/client-s3 # an S3 client for plycdn storage
npm install @azure/storage-blob # an Azure Blob client for plycdn storage, or your own container for Content Search uploads
If your servers restrict outbound traffic, allow api.plycdn.com on port 443.
2Configure
Create the clients once, when your server starts. Keep the API key and the callback key in your secret store, never in source control and never in a browser bundle.
import { PlycdnContentApi } from '@plycdn/content-search-node';
import { PlycdnStorageApi } from '@plycdn/node/storage';
export const content = new PlycdnContentApi({
apiKey: process.env.PLYCDN_API_KEY!,
callbackKey: process.env.PLYCDN_CALLBACK_KEY,
});
export const storage = new PlycdnStorageApi({ apiKey: process.env.PLYCDN_API_KEY! });
| Option | Default | Meaning |
|---|---|---|
apiKey |
none | Your server's API key, from the dashboard. Creating, changing and deleting buckets, libraries, domains and webhooks needs an admin key (admin_key_required otherwise). |
apiBaseUrl |
https://api.plycdn.com/ |
The address of the API. Leave it out unless your plycdn contact gives you another HTTPS address. |
callbackKey |
none | The callback key issued to you. Registrations use it unless you pass another. |
timeoutMs |
35000 | How long one attempt may take. Every call also takes { timeoutMs } for itself. |
maxRetries |
2 | Further attempts after a temporary failure. |
signal (per call) |
none | An AbortSignal that cancels the call. A cancelled call is never retried. |
Every call takes the organization it acts for as its first argument. Pass the plycdn organization id of the customer the call is for; a malformed id throws a TypeError before anything is sent.
What is retried
Reads, replacements and deletes are retried after a temporary failure (a network error, a timeout, a 408, 429 or 5xx), waiting longer each time and honouring Retry-After. A call that creates something is retried only when it carries an idempotency key, so a retry can never create a second copy: a create sent without a key is sent exactly once. These carry a key automatically: registrations, imports, re-indexing, transcript corrections, and creating a bucket, library, video, webhook, domain, live input or destination. Pass { idempotencyKey } to choose your own.
3Content Search from your server
import { PlycdnContentApi, paginate } from '@plycdn/content-search-node';
const content = new PlycdnContentApi({ apiKey: process.env.PLYCDN_API_KEY!, callbackKey: process.env.PLYCDN_CALLBACK_KEY });
export async function demo(organizationId: string): Promise<void> {
// Register a file that already sits in your storage. The same file registered again is the same asset.
await content.registerAsset(
organizationId,
{ externalId: `${organizationId}/lecture-1.mp4`, filename: 'lecture-1.mp4', kind: 'video',
sourceUrl: 'https://files.example.com/lecture-1.mp4', sourceVersion: '1' },
{ url: 'https://files.example.com/lecture-1.mp4?token=...', expiresAt: new Date(Date.now() + 3600_000).toISOString() },
{ collections: ['week-1'], principals: ['group:students'] },
);
// Search by meaning and wording.
const found = await content.search(organizationId, { query: 'how do I reset my password', limit: 5 });
console.log(found.results);
// Written answer, as it is written. Stopping early is not charged.
for await (const event of content.streamAnswer(organizationId, { query: 'what changed in the new policy?' })) {
if (event.type === 'delta' && 'text' in event) process.stdout.write(event.text ?? '');
if (event.type === 'citations' && 'citations' in event) console.log(event.citations);
if (event.type === 'error') throw new Error('The answer stopped early');
}
// Every document, page by page.
for await (const item of paginate((cursor) => content.findDocuments(organizationId, { cursor, limit: 100 }))) {
console.log(item);
}
console.log(await content.getUsage(organizationId, { year: 2026, month: 10 }));
}
import brings in many files at once (content.import(organizationId, items, { idempotencyKey })); a retry with the same key never registers a file twice. answer returns the whole written answer in one piece; streamAnswer yields delta, citations, done and error events as they arrive. The same object also covers the activity list, jobs, transcripts, metadata, permissions, collections, synonyms, boosts, pins, connectors, usage and settings; every method is named like its ASP.NET Core counterpart, without the Async and with a lower-case first letter (Backend API reference).
The account API manages sub-organizations from the same key, with an admin key:
import { PlycdnAccountApi } from '@plycdn/content-search-node';
const accounts = new PlycdnAccountApi({ apiKey: process.env.PLYCDN_ADMIN_KEY! });
export async function listCustomers(accountId: string): Promise<void> {
console.log(await accounts.listSubOrganizations(accountId));
}
4Routes for your own frontend
The Angular and React packages talk to your backend, not to plycdn. createContentSearchRouter is that backend: search, answers, activity, uploads and the callback plycdn makes to read your files. It is an Express router.
import express, { type Request } from 'express';
import { PlycdnContentApi } from '@plycdn/content-search-node';
import { createContentSearchRouter } from '@plycdn/content-search-node/express';
import { AzureBlobFileStorage } from '@plycdn/content-search-node/azure';
type SignedIn = { id: string; plycdnOrganizationId?: string; roles: string[] };
const signedIn = (req: Request) => (req as Request & { user?: SignedIn }).user;
const api = new PlycdnContentApi({ apiKey: process.env.PLYCDN_API_KEY!, callbackKey: process.env.PLYCDN_CALLBACK_KEY });
const files = AzureBlobFileStorage.fromConnectionString(process.env.AZURE_STORAGE_CONNECTION_STRING!, 'content');
const app = express();
// Mount the router before any body parser (see "Mounting order" below).
app.use('/api/plycdn-content/v1', createContentSearchRouter({
api,
resolveOrganization: (req) => signedIn(req)?.plycdnOrganizationId ?? null,
authorize: (req, access) => {
const roles = signedIn(req)?.roles ?? [];
return access === 'read' ? roles.includes('reader') || roles.includes('manager') : roles.includes('manager');
},
resolvePrincipals: (req) => (signedIn(req)?.roles ?? []).map((role) => `role:${role}`),
resolveUser: (req) => signedIn(req)?.id ?? null,
storage: files,
replay: files,
callbackSecret: process.env.PLYCDN_CALLBACK_SECRET,
}));
app.use(express.json());
app.listen(3000);
How it decides, in plain terms:
- Every route is refused until
authorizesays yes. With noauthorize, every route answers403 forbiddenand nothing is sent to plycdn.accessis'read'(search, answers, status, viewing) or'manage'(uploads, re-indexing, cancelling, deleting, collections); a manage route needs both. authorizemust return exactlytrue.1,'yes', an object or a promise of one are refusals. If the hook throws, that is your application's error (a500), not an answer to the browser.- The organization comes only from
resolveOrganization. A browser that sendsX-Organization-Id,?organizationId=ororganizationIdin a body still acts for the organization you resolved. When you return nothing, the request is refused with403and nothing is sent. Likewise the principals used for permission-aware search come only fromresolvePrincipals. - Uploads and the source callback need
storage(where your customers' files live; an Azure Blob container is included, and you can supply your own object withcreateUpload,completeUploadandresolveRead),replay(remembers callback requests so none is served twice) and acallbackSecretof at least 32 characters. Without all three, those routes answer404. The callback,/source-callback, needs no sign-in: it is checked by its signature instead, so it must be reachable without your session or CSRF middleware. - Upload sessions are sealed so a browser cannot change them. They use a key derived from
callbackSecret; if you would rather not have that secret also seal sessions, pass a separateticketSecretof at least 32 characters. - Other options:
publicReadBaseUrlandpublicWriteBaseUrl(https only),maxUploadBytes,allowUploadRelay, andwarnfor the router's own warnings.
The router answers browsers with these codes: 403 forbidden (the hook said no, or answered something other than true), 403 organization_forbidden (no organization for this request, or an upload session that belongs to someone else or has expired), 400 invalid_request (a body that is missing, too large, not application/json, not valid JSON, nested more than 64 levels deep, a malformed Idempotency-Key, or a number outside the allowed range), 400 invalid_upload_session (a changed session), 405 for OPTIONS (answer cross-origin preflights in your own application), and what plycdn itself answers for a refused call (404, 429, ...), passed on as it is. A failure to reach plycdn is a 502 content_unavailable; a storage problem is 502 storage_unavailable or 409 storage_operation_incomplete. These are the same codes as the ASP.NET Core controller.
Mounting order
Mount the routers before express.json() and the other body parsers, as above. They read and limit their own bodies: JSON up to 1 MiB, the callback up to 16 KiB, uploads up to maxUploadBytes; your parser's limit is not involved. If your application already parses JSON for every path, the browser routes still work, but the source callback needs the original bytes to check its signature: mount the router first, or give that path express.raw({ type: '*/*' }) before your parser. A callback whose body another parser has already read answers 401, and the router logs one warning saying so.
Behind a load balancer, and cross-site
For a bucket that binds links to the viewer's address, the sign route signs for the address of the request. Behind a load balancer or reverse proxy, tell Express to trust it, so req.ip is the viewer and not the proxy: app.set('trust proxy', 1) (the number of proxies in front of you). The address must be IPv4; with none, or an IPv6 one, the sign route answers 422 validation_error rather than issue a link the delivery network would refuse.
Cross-site protection is your application's job, as for any other route of yours. If a cookie signs your users in, set it SameSite=Lax (or Strict) and keep your usual CSRF protection on POST and DELETE; use SameSite=None only for a frontend on another site, together with that protection. A cross-origin frontend also needs your own CORS policy for these paths. In production, run Express with NODE_ENV=production so its error page shows no stack traces.
5Storage and delivery
import { createReadStream, statSync } from 'node:fs';
import { PlycdnStorageApi, PlycdnUploader, PlycdnLinkSigner } from '@plycdn/node/storage';
const storage = new PlycdnStorageApi({ apiKey: process.env.PLYCDN_ADMIN_KEY! });
const uploader = new PlycdnUploader(storage);
const signer = new PlycdnLinkSigner(storage);
export async function publish(organizationId: string, path: string): Promise<string> {
// Admin key. A bucket's name is global, like an S3 bucket's: choose one that is yours.
const bucket = await storage.createBucket(organizationId, { name: 'acme-course-media', region: 'mumbai' });
const size = statSync(path).size;
// Plans the parts, uploads them with retries, completes the session.
const stored = await uploader.upload(organizationId, bucket.id, 'week-1/intro.mp4', createReadStream(path), size, 'video/mp4');
// Made in your process from the bucket's cached signing profile: no request to plycdn per link.
return signer.sign(organizationId, bucket.id, stored.key, { ttlSeconds: 900 });
}
Region codes come from storage.listRegions(organizationId); treat them as data. A bucket is creating until it is active (poll getBucket). PlycdnUploader is for bytes your server holds (an import, a migration, a nightly job); when a browser uploads, it goes straight to the bucket with a short-lived ticket and your backend only relays the control calls through the routes below. An upload interrupted by a temporary failure keeps its session: the error carries uploadId, so you can resume or abortUpload it. An organization can hold at most 1,000 open uploads; one more fails with plan_limit_reached (403).
signer.sign caches each bucket's signing profile for 10 minutes (signingProfileCacheMs on the client). After you rotate a bucket's signing key, call signer.forget(organizationId, bucketId). Options: ttlSeconds (60 seconds to 7 days), ip (for a bucket that binds links to the viewer), hostname (a custom domain that is active on the bucket; non-ASCII names are converted for you) and tags (delivery analytics). Delivery analytics, purges, domains and usage are methods of PlycdnStorageApi (getDeliveryAnalytics, purgeCache, createDomain, getUsageByResource, ...); the CSV breakdown is a stream: getDeliveryBreakdownCsv.
6Video and live
import { PlycdnStorageApi, PlycdnLinkSigner } from '@plycdn/node/storage';
import { PlycdnVideoApi, PlycdnVideoSigner } from '@plycdn/node/video';
const storage = new PlycdnStorageApi({ apiKey: process.env.PLYCDN_ADMIN_KEY! });
const video = new PlycdnVideoApi(storage);
const videoSigner = new PlycdnVideoSigner(new PlycdnLinkSigner(storage), video);
export async function playback(organizationId: string, libraryId: string, videoId: string): Promise<string> {
// A protected recording must be signed by plycdn: use signVideoFromPlycdn for it.
const link = await videoSigner.signVideo(organizationId, libraryId, videoId, { ttlSeconds: 3600 });
return link.url;
}
export async function goLive(organizationId: string, libraryId: string): Promise<string> {
const input = await video.createLiveInput(organizationId, libraryId, { name: 'Weekly town hall' });
const token = await video.createPublishToken(organizationId, input.id, { ttlSeconds: 600 });
return token.token;
}
createVideo returns the upload to complete; videos play as soon as their lowest quality is encoded and at every quality once ready (Video). signVideo signs in your process; signVideoFromPlycdn asks plycdn to sign and is required for a protected recording; signLive signs a live input's playback link (mode is 'broadcast' or 'realtime'). Live inputs, destinations, share links, watch pages and domains are methods of PlycdnVideoApi (Live streaming).
7Routes for your players and uploaders
createPlycdnRouter is the backend for the storage and video packages of Angular and React: listing, uploading, signing and playback.
import express, { type Request } from 'express';
import { PlycdnStorageApi, PlycdnLinkSigner } from '@plycdn/node/storage';
import { PlycdnVideoApi } from '@plycdn/node/video';
import { createPlycdnRouter, PlycdnOperation } from '@plycdn/node/express';
type SignedIn = { plycdnOrganizationId?: string; roles: string[] };
const signedIn = (req: Request) => (req as Request & { user?: SignedIn }).user;
const storage = new PlycdnStorageApi({ apiKey: process.env.PLYCDN_API_KEY! });
const app = express();
app.set('trust proxy', 1);
app.use('/api/plycdn/v1', createPlycdnRouter({
storage,
signer: new PlycdnLinkSigner(storage),
video: new PlycdnVideoApi(storage),
resolveOrganization: (req) => signedIn(req)?.plycdnOrganizationId ?? null,
authorize: (req, operation, bucketId, key) => {
const roles = signedIn(req)?.roles ?? [];
const watching = operation === PlycdnOperation.ListObjects || operation === PlycdnOperation.GetObject ||
operation === PlycdnOperation.Sign || operation === PlycdnOperation.GetVideo || operation === PlycdnOperation.WatchVideo;
return watching ? roles.includes('learner') : roles.includes('instructor');
},
authorizeGoLive: (req, liveInputId, libraryId) => (signedIn(req)?.roles ?? []).includes('broadcaster'),
maxBrowserLinkTtlSeconds: 3600,
}));
app.use(express.json());
The video routes exist only when you pass video. As with the content router, every route is refused until authorize returns exactly true, the organization comes only from resolveOrganization, and going live is decided apart by authorizeGoLive (refused when absent): being allowed to watch never lets anyone broadcast.
PlycdnOperation |
Asked for | key |
|---|---|---|
ListObjects |
listing a bucket's files | the prefix, if any |
GetObject |
reading one file | the file's key |
StartUpload |
starting an upload | the key it will have |
ContinueUpload |
renewing, completing, aborting or reading an upload | the upload's key |
Sign |
a signed link for a file | the file's key |
GetVideo |
reading a video | the video id |
WatchVideo |
a playback link for a video or live input | the video id, or the live input id |
For a route addressed by an upload, file, video or live input, the router first looks the item up with your server key, then asks your hook, so a missing item answers 404 and a forbidden one 403 without revealing anything else. The sign route takes one target (a file, a video or a live input), signs for the request's own address on a bucket that binds links to the viewer, never issues a link longer than maxBrowserLinkTtlSeconds (at least 60), and adds tags only when your tagsFor(req, body) supplies them (or you set acceptBrowserTags).
Browsers get these answers: 403 forbidden, 404 for a missing item, 422 validation_error for a request that is not valid (a body that is not application/json, too large, nested more than 64 levels, an out-of-range number or lifetime, or no usable viewer address on an address-bound bucket), 405 for OPTIONS and HEAD, and 502/503 provider_unavailable when plycdn cannot be reached; other refusals from plycdn are passed on with their own code. The same advice applies as for the content router: mount before body parsers, set trust proxy behind a load balancer, keep your cookies SameSite and your CSRF protection.
8Webhooks
plycdn signs every delivery with the endpoint's secret. The receiver checks the signature and calls you with the event:
import express from 'express';
import { createPlycdnWebhookHandler } from '@plycdn/node/express';
const app = express();
const seen = new Set<string>();
// Before express.json(): the signature covers the exact bytes that were sent.
app.post('/webhooks/plycdn', createPlycdnWebhookHandler({
secrets: [process.env.PLYCDN_WEBHOOK_SECRET!, process.env.PLYCDN_WEBHOOK_SECRET_PREVIOUS ?? ''].filter(Boolean),
onEvent: async (event) => {
if (seen.has(event.id)) return; // a delivery can arrive twice: act on each event id once
seen.add(event.id);
if (event.type === 'video.ready') console.log('ready', event.data);
},
}));
app.use(express.json());
Answers: 200 after your onEvent returns, 401 for a missing, stale or forged signature, 400 for a body that is too large (over maxBodyBytes, default 1 MiB) or not an event, and 500 when your own code throws, which makes plycdn deliver again. Rules the check applies: the timestamp must be within toleranceSeconds (default 300) of now, past or future; there must be exactly one timestamp and at least one well-formed signature; a secret that is empty never matches; and a bad setting (a tolerance that is negative or not a number, a non-positive maxBodyBytes) is refused with an error and never silently widened. During a secret rotation, list both secrets: either one verifies. Because secrets can also be a function (sync or async), you can read them from your store at each delivery.
On another server, verify by hand: PlycdnWebhookVerifier.verify(rawBody, signatureHeader, secrets, { toleranceSeconds }) returns true or false, and PlycdnWebhookVerifier.read(req, secrets) reads a Node request and throws PlycdnWebhookSignatureError when it is not genuine. Event names are in PlycdnWebhookEvents.
9S3 and Azure clients
The S3 and Azure tools you already use work against plycdn storage. These helpers build a client for your region from the list plycdn publishes, so a region added later works without a new package. The credentials are a storage credential from Developers → Storage credentials in the dashboard, never your API key.
import { PlycdnStorageApi } from '@plycdn/node/storage';
import { PlycdnS3 } from '@plycdn/node/s3';
import { PlycdnBlob } from '@plycdn/node/azure';
const storage = new PlycdnStorageApi({ apiKey: process.env.PLYCDN_API_KEY! });
export async function clients(organizationId: string) {
const s3 = await PlycdnS3.createClientFromRegions(storage, organizationId, 'mumbai',
process.env.PLYCDN_STORAGE_KEY_ID!, process.env.PLYCDN_STORAGE_SECRET!);
const blob = await PlycdnBlob.createServiceClientFromRegions(storage, organizationId,
process.env.PLYCDN_STORAGE_ACCOUNT!, process.env.PLYCDN_STORAGE_ACCOUNT_KEY!, { region: 'mumbai' });
return { s3, blob };
}
See the compatibility guide for what is supported and the limits.
10Errors
Content Search calls throw PlycdnApiError; storage and video calls throw PlycdnError. Both carry the same fields:
| Field | Meaning |
|---|---|
status |
The HTTP status; 502, 503 or 504 when plycdn could not be reached. |
code |
The error code, such as plan_limit_reached or forbidden; null when plycdn gave none. ERROR_CODES lists the codes the packages know, and isErrorCode(code) tests one. |
retryable |
Whether trying again later can succeed. The package has already retried what is safe to retry. |
retryAfterMs |
How long plycdn asked you to wait, in milliseconds, when it did. |
fields |
Which fields of the request were wrong, for validation errors. |
error |
The whole answer body. |
uploadId |
On PlycdnError, the open upload an interrupted upload left behind. |
Failures to reach plycdn use codes that already exist: content_unavailable (502), content_timeout (504) and invalid_content_response (502) for Content Search, and provider_unavailable for storage and video. The routers' own browser-safe answers are listed above; the error table in the API reference lists every code.
11Troubleshooting
- Every route answers 403. There is no
authorize, or it returns something other than the booleantrue. Returntrueexplicitly. - A route answers 403
organization_forbidden, orforbiddenwith a signed-in user.resolveOrganizationreturned nothing for this request. It must return the plycdn organization id of the signed-in user. - The uploads and the source callback answer 404. Pass
storage,replayand acallbackSecretof at least 32 characters together. - The source callback answers 401 and the log mentions a body parser. Another parser read the body first. Mount the content router before
express.json(), or useexpress.raw({ type: '*/*' })on that path. - Webhooks answer 401. The body was changed before the receiver (mount it before
express.json()), the secret is the wrong one, or the server clock is more than five minutes off. - A signed link for an address-bound bucket answers 422. The request has no IPv4 viewer address. Set
trust proxybehind a load balancer. - A call fails with
admin_key_required. Creating, changing or deleting buckets, libraries, domains and webhooks needs an admin key.