plycdn Content Search — customer integration guide
Audience: your Angular or React, and ASP.NET Core, developers. This guide connects plycdn Content Search to your existing application and login. You retain your original files in your Azure storage account.
1What you receive
Plycdn.ContentSearch.AspNetCore: a NuGet package that adds the customer API controllers, storage integration and authenticated plycdn client.@plycdn/content-search: an Angular package with independent upload, search/results and playback components.@plycdn/content-search-react: a React package of hooks for building your own search, answers and progress interface on the same backend routes.- Your existing plycdn API key, authorized organization UUIDs and callback onboarding details from plycdn.
The browser calls your backend using your application's existing login. Your backend adds the plycdn API key when it calls plycdn. Never put that key in Angular or React configuration, source files, browser storage or a frontend environment file.
You never call the plycdn content API yourself. Registering assets, polling jobs, searching and resolving playback links are all performed by the packages. The only HTTP contracts in this guide are the ones you own: the settings API you may use to configure your own organization, and the source-access callback your backend exposes.
2What can be indexed
| Kind | Formats | How it is indexed |
|---|---|---|
| Video | .mp4 .mov .mkv .webm .avi |
Audio track transcribed; results carry a timestamp and seek the player to it |
| Audio | .mp3 .wav .flac .m4a .ogg .aac |
Transcribed; results carry a timestamp |
| Document | .pdf |
Per page; results carry the page number |
| Document | .docx |
Per paragraph and table cell; results carry the nearest heading |
| Document | .xlsx |
Per row; results carry the sheet name and row number |
| Document | .csv |
Per record; results carry the row number |
| Document | .txt .md |
Per line |
| Document | .html .htm .xhtml |
Per paragraph and heading; scripts and styles are ignored |
| Document | .zip |
A SCORM package: its pages, slides, lessons and the documents inside it |
Search is by meaning, not keyword: a question finds the passage that answers it even when they share no words. It spans every kind at once, so one query returns the paragraph, the spreadsheet row and the moment in the recording together.
Read GET /capabilities rather than copying this table into your code. It is what your plycdn service
actually accepts, and it grows without you needing a new package.
SCORM packages (1.2 and 2004) are read through their manifest, so a result names the page or
module its author titled rather than a file called sco_04.html. Launch pages that navigate between
lesson pages, slide players whose course is in a data file, Articulate Rise exports and PDF or Word
files inside the package are all read, and a result says where it was found - a slide
(location.slide with its title), a page or a lesson - so your player can open it there. Media
inside a package is not transcribed: registering a zip should never silently start an hour of
transcription. A package with no readable text fails with document_empty; a .zip that is not a
SCORM package is refused with not_a_scorm_package.
Presentations are not supported. Neither .ppt nor .pptx can be indexed. If your slides
matter, export the deck to PDF and register that - the text is then extracted per page and behaves
like any other document. (Tell us if this blocks you.)
Scanned PDFs with no text layer cannot be indexed - there is nothing to extract. They fail with
ocr_required rather than indexing blank pages. Legacy binary Office formats (.doc, .xls) are
not supported; convert them to .docx and .xlsx. Spreadsheet formulas index their computed
value, not the formula text.
Text shown on screen in a recording
Where on-screen reading is enabled for your account, a video is also read visually. Text on a slide becomes searchable alongside what was said, carrying the second it appeared.
This matters because half of what a lecture conveys is often written and never spoken - a formula,
a citation, a definition on the slide. Those results carry "location": { "source": "slide", ... },
so you can label them: a reader deserves to know whether they are looking at something said or
something shown.
It applies to recordings only, and only to material indexed after the feature was switched on. Ask us to reindex if you want it applied to an existing library.
3Searching, answers and keeping libraries in step
Everything in this part is optional: an integration that does not use it keeps working unchanged, and gains nothing it did not ask for.
Narrowing a search
filter restricts which files may answer. Every field is optional and the ones you set combine
with AND.
{ "query": "reciprocal rank fusion",
"filter": {
"collections": ["CS101"],
"metadata": { "term": ["autumn", "spring"], "level": "undergraduate" },
"createdAfter": "2026-01-01T00:00:00Z",
"speaker": "instructor",
"language": "hi"
} }
metadata is your own vocabulary — tags you attach to a file and we never interpret. Re-tagging
costs nothing and never triggers a reindex, so you can retag a whole library freely.
Permission-aware search
By default anyone who can search your organization can find any file in it. Mark a file
restricted and it is returned only to callers holding a principal you granted it — a user id, a
group, a role, a course enrolment. Any one match is enough.
Who is asking is decided on your server, never in the browser. The Angular package has no way
to send principals, deliberately: a browser can claim to be anyone, and if the page chose, any
signed-in user could add an administrator's group to a request and read everything. Implement
IContentPrincipalResolver in the ASP.NET Core package and it resolves them from the authenticated
user; the controller replaces whatever arrived from the page. Until you register one, the default
returns no principals at all, so every restricted file is invisible to browser searches.
ClaimsContentPrincipalResolver is a ready starting point (the user's name identifier and roles):
builder.Services.AddScoped<IContentPrincipalResolver, ClaimsContentPrincipalResolver>();.
A restricted file is excluded before ranking, so a passage a reader may not see is never retrieved, never scored, and never counted in any total.
Written answers
POST /answer returns a written answer built from the passages that answer the question, each
cited by number.
The answer is null when nothing in your library answers it. That is a result, not a failure —
show it as "nothing here answers that". Every claim is grounded in your own content; nothing is
added from outside it. Show the citations: for a recording each one carries the timestamp to jump
to, and an answer nobody can check is worth less than the passages it came from.
Suggestions and related content
GET /suggest?prefix= completes a partly typed query from searches that previously found
something. A query is only ever offered once several searches have used it, so one person's search
is never shown to their colleagues.
GET /assets/{id}/related returns other files near this one — one row per file, for a "you might
also want" panel.
Collections and tags
Collections are named groups addressed by an id you choose — normally the one your own system already uses for that course or module. Registering a file against a collection that does not exist is refused rather than silently creating one, because a typo would otherwise drop the file out of the group you meant.
Collection ids and tag keys appear in URLs, so they must contain only letters, digits and
_ . ~ -. Spaces and accented characters are refused at creation rather than accepted and then
found to be unreachable.
Tuning what ranks
Three tools, and one rule that governs all of them: tuning changes the order of results and never
changes relevance. That number stays comparable between queries whatever you configure, so you
can keep thresholding on it.
- Synonyms teach the search your own word for something — a term that appears nowhere in your content.
- Boosts promote or demote against the same tags you filter on. A boost cannot hide a result; that is what a filter is for.
- Pins put chosen files at the top of one exact query. A pinned result comes back with
"pinned": trueso you can label it. A pin promotes but does not inject: if the file does not answer the query well enough to be found, it will not appear, which is telling you something true about that file.
Syncing from storage you already run (connectors)
A connector keeps a library in step with a source you already maintain, so nobody re-uploads files that already live somewhere authoritative. Configure one from the dashboard's Content search → Storage connectors page, or from your own backend (see Plan and connectors in the backend API reference). Three kinds:
- Object storage (
s3) — any S3-compatible storage: Amazon S3, Google Cloud Storage in interoperability mode, MinIO and others. Point it at a bucket (optionally a folder) with a read-only key and the files there are indexed and kept current. - Azure Blob (
azure_blob) — a container read with a List+Read SAS. Configured asblobService(the account endpoint) pluscontainer; create the SAS against a stored access policy on the container so you can extend or revoke it in Azure without sending us a new one. - Canvas LMS (
canvas) — each course's Files area, with enrolments carried across as permissions.
Four properties worth knowing before you create one:
- Credentials are write-only. The secret you supply is stored sealed and never returned by any call. Omit it on an update to keep the stored one.
scheduleismanualornightly. Either way a sync only indexes what changed at the source — an unchanged file costs a comparison, not a re-transcription.- Storage events make it instant. Every connector carries a
notifyUrl; point your bucket's event notifications at it (Amazon S3 EventBridge, Azure Event Grid, Google Cloud Pub/Sub push, MinIO webhooks, or any event service that can POST to a URL) and new uploads are picked up without waiting for the schedule. Subscribe to deletions as well as creations — see the table in the API reference for the event names per cloud, and note that Azure needs theMicrosoft.EventGridresource provider registered on the subscription before its Events page will work at all. The dashboard shows the address with a copy button; treat it like a password. - A rename is not a re-index. No storage service reports a rename, so it arrives as a
deletion and a creation. We match the two by content and re-point the existing asset —
movedin the run summary — keeping the transcript, the corrections, the permissions and any link you have shared, at no cost. A file that really has gone is withheld from search results and listed undersourceMissingin library health until it comes back, rather than being returned with a link that cannot open. defaultAclModedecides what synced files start as. Canvas content defaults torestricted, mirroring enrolments. Bucket content registers organization-visible — a bucket carries no enrolments — and individual files can be restricted afterwards.
Connectors are a plan feature; an account whose plan does not include them refuses with
not_included_in_plan rather than half-working.
Knowing what your plan includes
GET /plan — GetPlanAsync(org) in ASP.NET Core, plan() on the Angular ContentClient —
answers what this organization may use, so your interface offers the features the plan includes
instead of buttons that answer 403 when pressed.
constructor(private content: ContentClient) {}
this.content.plan().subscribe(plan => {
this.showAnswers = plan.features.includes('answers');
this.showConnectors = plan.features.includes('connectors');
});
The features a plan can include are metadata, collections, suggestions, related, analytics,
tuning, answers, slides (reading text shown on screen), permissions (restricting a file to
named principals), connectors, storage (creating buckets) and video (creating video
libraries). Searching itself and reading what you have used are never gated; a plan without
permissions cannot mark a file restricted, but searches that send principals still work.
enforced is false where no plan is applied to your account — everything is available and
features lists everything. After a not_included_in_plan refusal, this is the call that says
what you do have.
Deciding whether we read what is on screen
Recordings are transcribed from their audio. Where on-screen reading is enabled, we also take still frames at slide changes and read the text on them, so what was shown is searchable alongside what was said.
extractContentFromVideoFrames decides whether that happens, and three places can say it — most
specific first: the file at registration, a connector for everything it brings in, and your
organization's settings for everything else. false means audio only, with no frames taken from
the recording at all; leave it unset and nothing changes from how your integration behaves today.
await content.RegisterAssetAsync(organizationId, source, access,
extractContentFromVideoFrames: false); // audio only
In Angular the upload component takes it as an attribute, so a policy of "audio only" is one line:
<plycdn-content-upload [extractContentFromVideoFrames]="false"></plycdn-content-upload>
The choice is sealed into the upload session by your backend, so a page cannot change it after the bytes are uploaded. The setting comes back on the asset, so what a recording was registered with can be checked afterwards — which is the form the question takes in a privacy review.
Seeing what we did with it
Usage answers what you owe. Two things answer what actually happened, and neither is ever gated:
GET /usagecarries aprocessingsection — hours transcribed, frames read for on-screen text, bytes fetched from your storage — whether or not an invoice line covers them.GetUsageByAssetAsync(org)(GET /usage/assets) reports the same per file, which is the form the question always takes: why is this lecture on the bill, did you read the slides in this one. A recording that declined frame extraction simply has noocr_framesentry. Called for your account it lists every file in the account, each with itsorganizationId; called for a sub-organization (X-Organization-Id), only that organization's files.
Your dashboard shows both under Usage, so an administrator can answer a processor review without asking us and without writing code.
Seeing what is happening
The dashboard's Content search → Library health and Search analytics pages, and Account → Usage, cover three questions you should not have to ask us: what is broken, what you have used and what it costs, and what people searched for and did not find. That last list is the one worth acting on — it names the gap between what your library holds and what your readers came looking for. Usage exports to CSV.
4Before starting
Agree these values with your backend developer and plycdn contact:
| Item | Where it is configured |
|---|---|
| plycdn API key | Backend secret store |
| plycdn organization UUID | A trusted authenticated user claim or your server-side organization mapping |
| Private Azure Blob container | Customer Azure storage; default container name is content |
| Storage connection string with SAS signing capability | Backend secret store |
| Callback key | Backend secret store; supplied by plycdn |
| Callback signing secret, at least 32 characters | Backend secret store, and the plycdn dashboard |
| Callback URL | plycdn dashboard: Content search → Indexing settings |
| Customer backend HTTPS origin and frontend origin | Your application hosting/CORS configuration |
| Your storage host, registered with plycdn | plycdn dashboard: Content search → Indexing settings |
Register your storage host before the first upload
plycdn fetches your files from your own storage, and only from hosts you have declared. Until
your storage host is registered, uploads succeed but registration is rejected with
source_host_not_allowed and nothing is indexed.
Sign in to the plycdn dashboard, open Content search → Indexing settings, and add the host part of
your blob endpoint - the host name only, with no https://, port or path:
yourcompany.blob.core.windows.net
Use *.blob.core.windows.net only if you intend to permit every account under that domain; a
single account is the safer choice. If the dashboard reports that the host is not permitted, contact your plycdn representative: there is an upper bound that an
organization cannot widen by itself.
If your account has sub-organizations, each may keep the parent's host list or declare its own under the same screen; anything left unset is inherited. Removing a host stops new and in-progress fetches from it.
Configuring by API instead of the dashboard
The dashboard is the quickest way to do this once. If you are onboarding several sub-organizations, or want your storage hosts to live in your own configuration management, the same settings are available over HTTP. Authenticate with the plycdn API key your backend already holds - from your backend, never from the browser.
Read the effective settings for an organization:
GET /api/content-settings?organizationId=<uuid>
Authorization: ApiKey <your key>
{
"sourceHosts": ["yourcompany.blob.core.windows.net"],
"callbackUrl": null,
"callbackSecretSet": false,
"dailyAudioHours": 10.0,
"requireAcl": false,
"extractContentFromVideoFrames": true,
"searchLogDays": 90,
"origins": { "source_hosts": "deployment", "callback_url": "deployment" },
"allowedHostCeiling": ["yourcompany.blob.core.windows.net"],
"configured": false,
"updatedAt": null
}
(dailyBudgetUsd is still on the response, for wire compatibility — it is deprecated since 1.7.0,
always 0, and not shown above. What you can use is controlled by your plan's quotas; see
"How usage is counted" below.)
origins tells you where each effective value came from: this organization, owner for the
parent organization, or deployment for the default. configured: false means this organization
has no settings of its own and is inheriting everything. allowedHostCeiling is what
plycdn permits at all - you can narrow within it, never widen beyond it.
Replace the settings for one organization:
PUT /api/content-settings?organizationId=<uuid>
Authorization: ApiKey <your key>
Content-Type: application/json
{
"sourceHosts": ["yourcompany.blob.core.windows.net"],
"callbackUrl": "https://YOUR_BACKEND/api/plycdn-content/v1/source-callback",
"callbackSecret": "<32+ random characters>",
"dailyAudioHours": 10,
"requireAcl": false,
"searchLogDays": 90,
"extractContentFromVideoFrames": true
}
The other settings are requireAcl (when true, newly registered files start as restricted),
searchLogDays (how many days search text is kept, 0 to 3650; 0 keeps none; the default is 90) and
extractContentFromVideoFrames (whether on-screen text is read from recordings; see "Deciding whether
we read what is on screen"). A read returns the effective value of each; null on a write means inherit.
Three behaviours worth knowing before you script this:
PUTreplaces the whole row. A field you omit is cleared and reverts to inherited, so send the complete set each time rather than a partial update.callbackSecretis the exception: omit it and the stored secret is kept, so you can change hosts without re-sending a credential you cannot read back. It is never returned - onlycallbackSecretSettells you whether one exists."sourceHosts": []means inherit, not "allow nothing". To return an organization to its parent's settings useDELETE /api/content-settings?organizationId=<uuid>.
To change one field without resending the rest, PATCH the same address: a field sent with a value
is set, one sent as null is inherited again, and anything you leave out is unchanged. From .NET:
await content.UpdateSettingsAsync(organizationId, new ContentSettingsChange()
.SetSourceHosts(new[] { "yourcompany.blob.core.windows.net" })
.Clear(ContentSetting.CallbackUrl)); // inherit the parent's callback again
await content.ClearSettingsAsync(subOrganizationId); // everything inherited again
Omit organizationId to address the organization the API key belongs to. A host outside the
plycdn ceiling is refused with source_host_outside_ceiling and the offending host named in
detail. A host that is not a bare hostname - one with a scheme, a port or a path, such as
https://acct.blob.core.windows.net - is refused with validation_error, again naming it in
detail: send acct.blob.core.windows.net.
Source access callback — required
The ASP.NET Core package will not start without a callback key and a signing secret of at least 32 characters, and it registers every asset with that key. Access to your files is therefore always resolved by calling you back; there is no mode in which a stored upload link is used on its own.
The three names, and what each one is
| Name | What it is | Who creates it | Secret? |
|---|---|---|---|
Plycdn:CallbackKey |
A short identifier carried on every asset you register, marking it as using renewable access. It is not used to authenticate anything. | plycdn gives you a value | No |
Plycdn:CallbackSecret |
The key we sign our callback requests with, so your endpoint can prove a request came from us. At least 32 characters. | You generate it | Yes |
| Callback URL | Where we send those requests. | Your application's address plus the path our package serves | No |
The dashboard field labelled Signing secret is the same value as Plycdn:CallbackSecret.
It has to be byte-identical in both places: your backend computes the expected signature from its
copy, we sign with the copy in the dashboard, and any difference rejects every callback.
Generate one with openssl rand -base64 48, put it in your backend's secret store, and paste the
same string into the dashboard. We never show it again once saved.
Only the address part is yours
The path is served by the package, so you do not choose it:
https://YOUR_BACKEND/api/plycdn-content/v1/source-callback
\_____________/\___________________________________________/
yours fixed by the package
Give us your application's public HTTPS origin followed by exactly that path. If you mount the application under a path base or sit behind a proxy that adds a prefix, include that prefix - what matters is the URL that reaches the controller from the public internet.
Two things must line up before your first upload succeeds:
- Your backend exposes the callback at
https://YOUR_BACKEND/api/plycdn-content/v1/source-callback. The package implements and validates it; you do not write signing code. It must be reachable from the public internet and must not be intercepted by an interactive login page. - That URL and the same signing secret are set in the plycdn dashboard, under
Content search → Indexing settings. The secret there must match
Plycdn:CallbackSecretin your backend exactly, or every callback is rejected. Miss the second and registration is refused withcallback_not_configured- uploads will appear to succeed and nothing will ever be indexed.
Your callback URL does not need to appear in your storage host list; configuring it is the declaration. It must be plain HTTPS on port 443 with no credentials in the URL, and resolve to a publicly routable address.
5Backend developer
1. Install and register
Install the supplied NuGet package from your agreed package feed or local package folder:
dotnet add package Plycdn.ContentSearch.AspNetCore --version 1.12.0 --source YOUR_PACKAGE_FEED_OR_FOLDER
To install from the supplied .nupkg file, put it in a vendor/nuget folder in your solution and, from the
solution directory:
dotnet new nugetconfig
dotnet nuget add source ./vendor/nuget --name PlycdnLocal --configfile ./NuGet.config
dotnet add path/to/YourBackend.csproj package Plycdn.ContentSearch.AspNetCore --version 1.12.0
dotnet new nugetconfig creates NuGet.config beside the solution first (it keeps nuget.org listed; if you
already have one there, skip this step). --configfile matters: without it the source is written to your
user-level NuGet configuration as a relative path, which then applies to every project on that machine and
breaks any build started from a directory where vendor/nuget does not exist. Keeping it in a NuGet.config
beside the solution also means it is committed, so CI and your colleagues need no setup. Keep nuget.org enabled
for the Azure SDK dependencies. Commit the package and your repository's NuGet source configuration, or upload the
package to your private NuGet feed for CI.
In your existing ASP.NET Core startup:
using Plycdn.ContentSearch;
var builder = WebApplication.CreateBuilder(args);
// Keep your existing authentication registration here.
builder.Services.AddPlycdnContentSearch(builder.Configuration);
var app = builder.Build();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
Merge these lines into your existing host; do not create a second authentication system. Existing endpoint routing remains the host's responsibility. The package registers its controllers automatically; you do not copy controller source into your application.
Provide one configuration section through your secret manager. Placeholder values below are not working credentials:
{
"Plycdn": {
"ApiKey": "YOUR_EXISTING_PLYCDN_API_KEY",
"CallbackKey": "YOUR_ASSIGNED_CALLBACK_KEY",
"CallbackSecret": "YOUR_SHARED_CALLBACK_SECRET",
"Azure": {
"ConnectionString": "YOUR_AZURE_STORAGE_CONNECTION_STRING",
"Container": "content"
}
}
}
Equivalent environment names include Plycdn__ApiKey, Plycdn__CallbackKey, Plycdn__CallbackSecret and Plycdn__Azure__ConnectionString. The callback secret must contain at least 32 characters. The hosted API address defaults to https://api.plycdn.com/; set Plycdn:ApiBaseUrl only when your plycdn contact provides a different HTTPS base address.
2. Connect your existing organization and permissions
The default resolver reads plycdn_organization_id from authenticated user claims. Its value must be an authorized plycdn organization UUID, not an arbitrary identifier from your own database. Set Plycdn:OrganizationClaimType to an existing claim name if it already contains that UUID.
Configure these permissions in your identity provider/server, never from browser request data:
| Operation | Default required claims |
|---|---|
| Search, status and source viewing | content:read=true |
| Upload, reindexing, cancellation and deletion | Both content:read=true and content:manage=true |
Your backend must authenticate the user before these claims are trusted. Missing, malformed or conflicting organization claims deny access. A browser X-Organization-Id header does not override this mapping. plycdn additionally verifies that the configured API key can access the selected organization.
If your application already has suitable permission policies, override the package's named policies after registration. For example, when these are real roles already issued by your identity provider:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy(ContentSearchOptions.ReadPolicy, policy =>
policy.RequireAuthenticatedUser().RequireRole("ContentReader", "ContentManager"));
options.AddPolicy(ContentSearchOptions.ManagePolicy, policy =>
policy.RequireAuthenticatedUser().RequireRole("ContentManager"));
});
For a database-backed organization mapping, implement IOrganizationContextResolver.ResolveAsync(ClaimsPrincipal, CancellationToken) and register it with AddScoped<IOrganizationContextResolver, YourResolver>(). Resolve only an organization the authenticated user can access; return null when access is absent. Do not accept an unverified organization ID supplied by Angular.
3. Storage, authentication and hosting
Create the private container before testing. Configure Azure Blob CORS for the exact frontend origin:
- Methods:
GET,HEAD,PUT. - Allowed headers:
content-type,x-ms-version,x-ms-blob-content-type. - Exposed headers:
ETag,Content-Length,Content-Range.
Azure handles browser preflight requests using these CORS rules. The container remains private. The upload component transfers file bytes directly using temporary upload access returned by your backend. Completion registers the stored file for processing. Search appears only after indexing completes.
Use HTTPS. For separate frontend/backend origins, enable customer API CORS for the exact frontend origin, required methods/headers and exposed ETag. Enable credentials only if your existing cookie authentication needs them; never combine credentialed CORS with wildcard origins. Cookie-authenticated applications must retain their normal antiforgery protection and Angular XSRF integration. Bearer-authenticated applications use their existing token interceptor.
Persist ASP.NET Data Protection keys using your existing shared secure key store across restarts and backend replicas. Configure Azure lifecycle cleanup for content-callback-nonces/ after one day and abandoned .upload staging blobs after three days. Exclude committed original files from those cleanup rules.
4. What API-key validation does
The package adds Authorization: ApiKey YOUR_KEY to its server-to-server plycdn requests and supplies the organization resolved by your backend. No browser credential is forwarded as the plycdn key.
plycdn validates the configured key before granting access. Missing, invalid, revoked or expired keys reject requests. Upload-session creation and renewal also verify plycdn access before issuing a new upload link; search and upload completion use the same authenticated client. Do not retry invalid keys indefinitely: update the backend secret and restart/redeploy the host after a key rotation.
Revoking a key blocks subsequent API requests. An upload link already issued to storage remains usable until that link expires; revocation does not retroactively cancel storage access already granted. Completing/indexing the upload still requires valid plycdn access.
Limits are per key and per plan; ask us to raise them. A throttled request answers 429 with Retry-After ("Rate limits" in the API reference).
6Frontend developer
1. Install and import
Install the supplied package from your configured private npm feed, or the supplied archive:
npm install ./plycdn-content-search-1.12.0.tgz
Commit the package file and lockfile, or host the package in your private npm registry, so your CI can install it.
Angular 13 to 22 are supported. The package declares @angular/core, @angular/common,
@angular/forms, @angular/platform-browser and rxjs as peer dependencies — all of which an
Angular application already has. From 1.10 playback is built into the package: there is nothing extra to
install. The preview plays your file as it is. Installing the package also installs what it needs
automatically, from your npm registry or mirror; make sure yours can serve it.
In an Angular NgModule:
import { PlycdnContentSearchModule } from '@plycdn/content-search';
@NgModule({
imports: [PlycdnContentSearchModule.forRoot()]
})
export class AppModule {}
Register forRoot() once at the application root. Feature modules may import PlycdnContentSearchModule without repeating forRoot(). Angular 13+ NgModule integration is supported; a standalone host may register the same module through its Angular-supported provider/module import mechanisms.
The default customer endpoint is /api/plycdn-content/v1 on the same origin. When needed:
PlycdnContentSearchModule.forRoot({
apiBaseUrl: 'https://YOUR_CUSTOMER_BACKEND/api/plycdn-content/v1'
})
For same-origin local development, Angular's development proxy can route /api/plycdn-content to your backend. Use your trusted local HTTPS certificate. A separate port/origin without a proxy requires the explicit backend URL and CORS configuration above.
2. Reuse your application authentication
Your existing Angular HttpInterceptor should attach the signed-in user's bearer token only to your customer backend origin/routes. It must not attach the plycdn API key. If the application uses cookies, keep its existing XSRF handling; set withCredentials: true only when your cross-origin cookie setup requires it.
Storage transfers deliberately bypass normal Angular authentication interceptors, so your user token is not sent to Azure. The package manages temporary upload URLs for you.
3. Render the components
<plycdn-content-upload
(registered)="onRegistered($event)"
(indexed)="onIndexed($event)">
</plycdn-content-upload>
<plycdn-content-search
(resultSelected)="onResultSelected($event)">
</plycdn-content-search>
import { RegisteredAsset, SearchResult } from '@plycdn/content-search';
onRegistered(asset: RegisteredAsset): void {
// Optional: retain asset.assetId and asset.jobId in your application's UI state.
}
onIndexed(asset: RegisteredAsset): void {
// Optional: notify the user that the file is ready to search.
}
onResultSelected(result: SearchResult): void {
// Optional: update your own selection state.
}
The event handlers are optional; both components work without them. Transfer progress and indexing progress are separate. An uploaded file is not searchable until indexing reaches ready. Retry resumes an interrupted upload where the saved session is still valid; the user must reselect the same file. Search results open a fresh source link and seek videos to the returned passage time with a short lead-in. Document results show a page or passage location where available.
A file whose source access has expired shows Check again. Access is renewed by your server,
through the source-access callback, never from the page; the Angular package has no
refreshSource(), and the controller serves neither assets/{id}/refresh-source nor settings.
4. Optional branding and player integration
plycdn-content-upload,
plycdn-content-search {
--pcs-accent: #235b76;
--pcs-on-accent: #ffffff;
--pcs-surface: #ffffff;
--pcs-text: #18232b;
--pcs-border: #d4dce2;
}
Fonts inherit from the host. Set data-theme="dark" on the components for dark defaults. Pass a partial labels object for translated labels. Custom result templates are available through resultTemplate.
To use your own player, bind [playerHandler]="openInPlayer". Its signature is (result: SearchResult, source: SourceAccess) => void. Use source.url and seek after metadata loads to result.location.playFromSeconds ?? 0. Handle browser playback restrictions and expired source links in your own player. Do not persist temporary source URLs as permanent identifiers.
Moving between matches in the preview
A grouped result (one per file, the default) carries every place the file matched in moments,
up to twenty. When the open file has more than one, the preview shows a row under the media or
document — ◀ Previous: 2:00, Match 2 of 9, Next: 9:40 ▶:
- Matches are in the order they occur in the file: by time in a recording; by page, slide, sheet and row, line or paragraph in a document.
- Moving keeps the file that is loaded: a recording seeks, a document moves to the page or passage and marks it. No new source link is fetched.
- At the first and the last match the button stays where it is, marked unavailable
(
aria-disabled), so the row does not jump and keyboard focus is not lost. - Keys, while focus is anywhere in the preview:
]or Alt+→ for the next match,[or Alt+← for the previous one. Each move is announced politely ("Match 4 of 9, 9:40"). (matchChanged)emits the result as it is at the new match — the same shape a moment link opens with.plycdn-content-resultsuses it to move its open link.[matchNavigation]="false"hides the row and turns the keys off; the markers stay.
Recordings play with the preview's own controls: play and pause, seek, back and forward 10
seconds, the time, mute and volume, speed, captions when the file has caption tracks, quality when
a stream offers several, and full screen for video. While the player has focus the keys are the
plycdn player's: Space or K play/pause, ←/→ 5 seconds, J/L 10 seconds, ↑/↓ volume, M mute,
C captions, F full screen, Home/End, 0–9 to jump, < and > for speed. When a source link expires
during playback the preview asks your backend for a fresh one and continues at the same second; a
file that is gone (403 or 404) says so without retrying.
Every timed match has a tick on the seek bar, and the open match's tick is emphasised. The ticks are buttons in their own group beside the slider, each named like the announcement ("Match 3 of 9, 7:55"): Tab reaches one of them and the arrow keys move between them; hovering or focusing one shows its time and snippet; Enter or a click moves there. Ticks closer than 6 pixels merge into one, which moves through its matches on each click. Ticks appear once the recording's duration is known.
[nativeControls]="true" keeps the browser's own controls (and their keys); the ticks then sit in a
strip under the video, and Previous and Next are unchanged. All three components that show a
preview take both inputs: plycdn-content-playback, plycdn-content-results and
plycdn-content-search.
<plycdn-content-search [nativeControls]="false" [matchNavigation]="true"></plycdn-content-search>
<plycdn-content-playback [result]="selected" (matchChanged)="selected = $event"></plycdn-content-playback>
Theme the ticks with --pcs-marker and --pcs-marker-active; the control bar uses
--pcs-player-bar and --pcs-player-text.
Labels, all in labels: matchPrevious (Previous: {pointer}), matchNext (Next: {pointer}),
matchPosition (Match {n} of {total}), matchAnnounce (Match {n} of {total}, {pointer}, also
each tick's name), and the pointers pointerTime ({time}), pointerPage (page {page}),
pointerSlide (slide {slide}), pointerRow (row {row}), pointerSheetRow ({sheet} row {row})
and pointerLine (line {line}). A match with no time, page, slide, row or line is named by its
title or heading, else "Passage n" (the passage label). The controls: play, pause, mute,
unmute, volume, seek, back10, forward10, speed, quality, qualityAuto (Auto, the
quality menu's automatic choice), captions, captionsShort (CC, the captions button's short
text), captionsOff, fullscreen and exitFullscreen.
Using React
@plycdn/content-search-react gives a React application search, answers, suggestions, related
files, the library and indexing progress, as hooks rather than finished components, so the interface
is entirely yours. It has no upload, playback or document components: for those use the Angular
package, or your own. It talks to
the same routes your ASP.NET Core backend already serves; nothing changes on the server.
npm install ./plycdn-content-search-react-1.12.0.tgz
React 18 or later. Wrap the application once:
import { ContentSearchProvider } from '@plycdn/content-search-react';
<ContentSearchProvider config={{ apiBaseUrl: '/api/plycdn-content/v1' }}>
<App />
</ContentSearchProvider>
apiBaseUrl is your backend's route, where the ASP.NET Core package mounts its controllers -
never plycdn's address, and the API key is never involved.
const { results, loading, error, search, more } = useSearch();
await search('what should a student bring to the viva', { filter: { collections: ['CS101'], language: 'hi' } });
const { answer, ask } = useAnswer();
await ask('what is a hazard');
One result per file, with the words that matched marked - render the runs as text, never as HTML:
import { markSpans } from '@plycdn/content-search-react';
const { results, search } = useSearch({ group: 'file' });
// ...
{results?.items.map(file => (
<article key={file.assetId}>
<h3>{file.filename}</h3>
<p>{markSpans(file.snippet, file.highlights).map((run, i) =>
run.mark ? <mark key={i}>{run.text}</mark> : <span key={i}>{run.text}</span>)}</p>
{/* openAt: your own function that opens the file at that place */}
{file.moments?.map(m => <button key={JSON.stringify(m.location)} onClick={() => openAt(m.location)}>
{m.location.playFromSeconds ?? m.location.page ?? m.location.slide}</button>)}
</article>
))}
markTerms(text, page.terms) marks the same words in any other text, such as a document you
render yourself.
Nothing is fetched until search or ask is called: a search is billed, so it should follow a
person asking, never a component rendering. answer.answer is null when nothing in the library
answers the question - show that as "nothing here answers that", not as an error.
| Hook | Use |
|---|---|
useSearch() · useAnswer() |
Search, and written answers with citations |
useSuggestions(prefix) |
Completions as someone types; not billed |
useRelated(assetId) |
Other files near this one |
useAssets() · useAsset(id) · useActivity() |
Registered files, and their latest indexing run |
useAssetJobs(id) · useJob(id, pollMs) |
Indexing progress; polling stops when the run finishes |
useReindex() · useCancelJob() |
Process a file again, or stop work |
useCapabilities() · usePlan() |
What can be indexed, and what the plan includes (allows(feature)) |
Every query hook returns { data, loading, error, reload }. Errors are a ContentError with a
stable code, the HTTP status (0 when the request never reached your backend) and retryable
(true when trying again later might work); match on the code, never on the message. EXPLAINED
maps the common codes to a sentence you can show. useContentClient() returns the configured client
for calls the hooks do not cover, <ContentSearchProvider config={{ apiBaseUrl, withCredentials: true }}>
sends cookies with each request, and client={...} supplies a client of your own, for tests or to
wrap requests with your own telemetry. As with Angular, the browser can never
choose whose permissions a search runs under: your backend's IContentPrincipalResolver decides.
Calling the service directly
The components cover the whole path, but ContentClient is injectable if you are building your own
interface. Every method returns an Observable and calls your backend — the package never
holds an API key and never chooses who the caller is.
constructor(private content: ContentClient) {}
// Search, optionally narrowed.
this.content.search('reciprocal rank fusion', {
kind: 'video',
filter: { collections: ['CS101'], language: 'hi' },
}).subscribe(page => this.results = page.items);
// A written answer, with the passages it used.
this.content.answer('what is a hazard').subscribe(page => {
this.answer = page.answer; // null when nothing answers it
this.citations = page.citations; // each carries the location to jump to
});
// Completions as someone types.
this.content.suggest(typed).subscribe(page => this.suggestions = page.items);
// A "you might also want" panel.
this.content.related(assetId).subscribe(page => this.nearby = page.items);
| Method | |
|---|---|
capabilities() |
What your plycdn service can index |
search(query, options?) |
options takes kind, limit, offset, filter and group |
answer(query, options?) |
A written answer with citations |
suggest(prefix, limit?) |
Completions from searches that previously found something |
related(assetId, limit?) |
Files near this one |
assets(limit?, offset?) |
Registered files |
activity(limit?, offset?) |
Files with their latest indexing run — for a dashboard |
assetJobs(assetId) |
Every run for one file, including failed attempts |
job(jobId) · cancel(jobId) |
Follow or stop one run |
reindex(assetId, key) |
Process the current version again |
source(assetId) |
Short-lived read access, for playback |
ContentSearchComponent also takes a filter input, so you can narrow what the built-in search
box searches without writing any of the above.
There is no method for sending principals, and there will not be. See Permission-aware search above: a browser can claim to be anyone, so your server decides.
7Component layout and playback on another page
The three primary components are independent:
| Component | Responsibility |
|---|---|
plycdn-content-upload |
File selection, transfer/indexing progress and optional local video/audio preview |
plycdn-content-search |
Query/filter controls and results; includes inline playback by default |
plycdn-content-playback |
Authorized video/audio playback or document opening; can live anywhere in your application |
Search composes the standalone playback component for its default inline preview. You can disable that composition and position playback yourself:
<plycdn-content-search
[showInlinePlayback]="false"
(resultSelected)="selected = $event">
</plycdn-content-search>
<plycdn-content-playback
*ngIf="selected"
[result]="selected"
[showClose]="true"
(closed)="selected = undefined">
</plycdn-content-playback>
Declare selected?: SearchResult in your host component. The host controls layout, conditional visibility and navigation. Playback uses the preview's own controls (or the browser's with [nativeControls]="true") and seeks after metadata loads. Autoplay is off by default; use [autoplay]="true" only where appropriate, subject to browser restrictions.
A real link to your playback page
Build a customer application link using the supplied helper:
import { contentPlaybackLink, SearchResult } from '@plycdn/content-search';
playbackLink = (result: SearchResult): string =>
contentPlaybackLink('/recordings/watch', result);
<plycdn-content-search
[playbackLink]="playbackLink"
[showInlinePlayback]="false">
</plycdn-content-search>
Each result includes an Open on playback page anchor. This supports normal navigation, opening in a new tab and copying the link. The helper adds assetId, startSeconds and a document page when available. It does not include a temporary signed storage URL. Your application must register /recordings/watch and serve its SPA entry point on a direct visit.
On that destination page, read the query parameters and render:
<plycdn-content-playback
[assetId]="assetId"
[startSeconds]="startSeconds"
[page]="page">
</plycdn-content-playback>
For an Angular Router page, a minimal parameter binding is:
import { ActivatedRoute } from '@angular/router';
constructor(private route: ActivatedRoute) {}
get assetId(): string | undefined {
return this.route.snapshot.queryParamMap.get('assetId') || undefined;
}
get startSeconds(): number {
const value = Number(this.route.snapshot.queryParamMap.get('startSeconds') || 0);
return Number.isFinite(value) ? Math.max(0, value) : 0;
}
get page(): number | undefined {
const value = Number(this.route.snapshot.queryParamMap.get('page'));
return Number.isInteger(value) && value > 0 ? value : undefined;
}
The playback component fetches file metadata and fresh source access through your authenticated backend. A copied application link does not grant permission: the viewer must still sign in and pass organization authorization.
While the file loads, the component shows a buffering status so a slow first frame does not read as a dead player. If source access expires mid-playback it renews automatically and resumes at the current position, and it will keep doing so for recordings longer than one access lifetime. Failures are separated: a temporary one offers a retry action, while a file your backend refuses outright, because it has been removed or has changed since it was indexed, reports sourceUnavailable and offers no retry, since asking again would return the same refusal.
Changing [startSeconds] or [page] while the same [assetId] is on screen seeks the loaded player. It does not request source access again or reload the media, so moving between passages of one recording is immediate.
Network requirements
The browser talks to two places: your application, and wherever your files are served from. The second is the part that surprises people, because a Content-Security-Policy and a CORS rule both have to allow it, and they fail in ways that look alike.
You can avoid that entirely. If a reverse proxy on your own domain already fronts your storage
- an Application Gateway, an ingress route, anything that forwards to the container - point both settings at it:
options.PublicReadBaseUrl = new Uri("https://app.example.com/org-content");
options.PublicWriteBaseUrl = new Uri("https://app.example.com/org-content");
(Or in configuration: Plycdn:PublicReadBaseUrl and Plycdn:PublicWriteBaseUrl.)
Uploads, playback and document rendering then all happen against the origin the page is already on. No storage host in any policy directive, no CORS rule on the container, and the bytes never pass through your application - the proxy carries them. This is the least configuration of any option, and the one we recommend.
The write host must accept PUT, forward the query string unchanged - the signature and the block
parameters live there - and allow a request body of at least 8 MB, the block size. A read-only CDN
cannot do this: set PublicReadBaseUrl alone and leave writes to one of the routes below.
Each path is appended to the storage path, so both settings name the route prefix and stop before
the container. The storage path already begins with the container, and repeating it produces
/org-content/files/files/..., which resolves to nothing. The package logs a warning naming the
value to use if it sees that.
If the browser does reach storage directly, a policy needs three directives, not one. A policy treats an upload, a video and a PDF as different things:
connect-src 'self' https://ACCOUNT.blob.core.windows.net;
media-src 'self' blob: https://ACCOUNT.blob.core.windows.net;
frame-src 'self' https://ACCOUNT.blob.core.windows.net;
| Directive | Covers | If your host is missing from it |
|---|---|---|
connect-src |
Direct upload; PDF, Word, Excel, CSV and text rendering | Uploads relay through your application; PDFs open in the browser's viewer without highlighting, other documents offer a download |
media-src |
Video and audio playback | The player never loads |
frame-src |
The browser's own PDF viewer, used when a PDF cannot be rendered in place | That fallback does not display |
Rendering a PDF in place needs no worker-src and no 'unsafe-eval': the PDF viewer runs on the
page, without a Web Worker, and never evaluates code from a document.
blob: in media-src is not optional if you keep upload previews: the component plays the file the
user has just selected from an object URL, before it has been uploaded anywhere.
Uploads always have a way through. With PublicWriteBaseUrl set they go to your own origin.
Without it, the components try storage and, if the connection is refused, send blocks through your
application instead - AllowUploadRelay (Plycdn:AllowUploadRelay), on by default. That costs your server the upload
bandwidth but needs no configuration and no policy change, which means a security review is never
on the critical path to a first upload.
CORS applies only to what the page fetches: direct uploads, and Word, Excel, CSV and text
rendering. Video, audio and PDF do not need it, and nothing does when reads and writes go through
your own origin. Where you do need it, allow GET, HEAD and PUT from each origin your
application is served from, with the headers content-type, x-ms-version and
x-ms-blob-content-type, exposing ETag, Content-Length and Content-Range (the same rule as
"Storage, authentication and hosting" above; GET and HEAD alone are enough when you only render
documents and never upload straight to storage).
The largest file the upload route accepts is Plycdn:MaxUploadBytes (20 GB by default); a larger
one is refused with 400 invalid_file.
Serving media through a CDN
If reads go through a delivery host rather than a proxy that also accepts writes, set only:
options.PublicReadBaseUrl = new Uri("https://media.example.com");
The path and the signature are preserved, so configure the host to pass the query string through to the origin. Only the browser's read URL changes: what is registered with us stays the storage origin, because indexing happens server-side, where a CDN adds nothing but a cache that can serve a stale copy of a file you just replaced.
Do not cache these responses on a key that ignores the query string. The signature that authorises a read lives entirely in the query string. If the delivery host caches on the path alone, the first authorised request fills the cache and later requests are served the file without a valid signature - an expired one, a withdrawn one, or a viewer who should not have it. Either include the full query string in the cache key, which makes the cache nearly useless because signatures rotate, or turn caching off for these routes and keep the host for TLS, WAF and routing. A cache set up this way works in testing and only fails later, in production.
If your arrangement is more involved than a host swap, implement ICustomerFileStorage yourself and
return whatever URL is right for your estate.
Serving several organizations
If you run a platform where each customer or sub-organization brings its own storage, resist the
temptation to wildcard a shared storage domain in connect-src. https://*.blob.core.windows.net
is a shared public domain - anyone can create an account on it - and allowing it means that
if your application ever has a scripting flaw, the page is permitted to send data to an account the
attacker controls. That is the main thing a policy is there to prevent.
Better, in order:
- One delivery host on your own domain in front of every organization's storage, named in all three directives. One entry, nothing to review per customer, and it stays correct as organizations are added. This is also the only option that covers an organization that fronts its own storage with its own CDN.
- Emit the policy per request from your gateway, naming only the current organization's host. Your application already knows the organization when it renders the page.
- If you must use a wildcard, confine it to
media-srcandframe-src. Those only load content and cannot send anything out. Keepconnect-srcnarrow, or leave storage out of it entirely and let uploads relay.
Documents are shown in place
A document result renders inside the component, scrolled to the row, page, line or paragraph that matched, with that passage highlighted and the words searched for marked within it. Every indexed format is covered:
| Format | Rendered as | Needs storage CORS |
|---|---|---|
| rendered in the component at the matched page, with the passage highlighted and the words marked on every page; zoom, fit to width, page count and "back to the match" | yes (without it, the browser's own viewer at the matched page) | |
| Word (.docx) | headings, paragraphs and tables, in document order | yes |
| Excel (.xlsx) | one table per sheet, with the file's own row numbers | yes |
| CSV | a table, honouring quoted fields | yes |
| Text, Markdown | the file's lines, numbered as indexed | yes |
The formats marked above are fetched by your application's JavaScript, so your storage container must allow cross-origin reads from your application's origin. Without it nothing breaks: the component quietly offers the original file to download instead. A PDF falls back to the browser's own viewer, still opened at the matched page, just without the highlighting.
For Azure Blob Storage, add a CORS rule to the storage account for each origin your application is
served from, allowing GET and HEAD and exposing Content-Length and Content-Range (add PUT
and the upload headers from "Storage, authentication and hosting" if browsers also upload straight
to storage).
Nothing is uploaded, converted or sent anywhere to render a document: the file is read in the
browser from the same short-lived URL used for download. No viewer service is involved. PDFs are
rendered by the package's own viewer, which installs with the package and loads only when a
PDF result is opened - it is never part of your application's initial bundle. Pages are drawn as they
scroll into view, so a long manual costs only what is read. Set [highlightPdf]="false" on
<plycdn-content-playback> or <plycdn-content-document> to keep the browser's own viewer.
Set [showInlinePlayback]="false" if you would rather send people to your own page, or handle
documents yourself with playerHandler.
Building your own interface entirely
If you would rather not use the components, inject PlycdnContentApi and build whatever you
like on top. Every call is typed, documented in IntelliSense, and resolves the organization on your
server rather than trusting anything from the page.
public sealed class LibraryController : ControllerBase
{
private readonly PlycdnContentApi content;
public LibraryController(PlycdnContentApi content) => this.content = content;
[HttpGet("library")]
public async Task<IActionResult> Library(CancellationToken ct)
{
// One call: files with what each is currently doing.
var page = await content.GetActivityAsync(OrganizationId, limit: 50, ct: ct);
return Ok(page.Items.Select(a => new
{
a.AssetId, a.Filename, a.Kind,
Status = a.LatestJob?.State,
Progress = a.LatestJob?.Progress,
Problem = a.LatestJob?.NeedsAttention == true ? a.LatestJob.ErrorCode : null,
}));
}
}
| Method | Returns | Use it for |
|---|---|---|
GetCapabilitiesAsync |
ContentCapabilities |
Which file types your plycdn service accepts, and the size limits |
GetActivityAsync |
ContentActivityPage |
A library screen: files with their latest run, in one call, filtered by connector, type, state, filename or date |
ListAssetsAsync |
ContentAssetPage |
Files alone, when you do not need their state |
GetAssetAsync |
ContentAsset |
One file, with the state and warnings of its latest revision |
GetAssetJobsAsync |
ContentJobHistory |
Every run for a file, including failed attempts |
GetJobAsync |
ContentJob |
Polling one run |
SearchAsync |
ContentSearchResults |
Searching |
GetSettingsAsync |
ContentOrganizationSettings |
Showing configured hosts and limits |
ReindexAsync |
ContentRegistration |
Indexing the current version again |
CancelJobAsync, DeleteAssetAsync |
– | Stopping a run, removing a file |
Four types appear inside those results rather than being returned directly:
ContentLocationonContentSearchResult.Location- where the passage sits. Seek video toPlayFromSeconds; open a PDF atPage; highlightRowandSheetfor a spreadsheet,LineStartfor text,Blockfor Word. Which fields are set depends on the kind, so read the ones you need and ignore the rest.ContentSourceReferenceonContentSearchResult.SourceReference-ExternalId,SourceUrlandSourceVersion, your own identifiers for the matched file.ContentFormatonContentCapabilities.Formats- one family of file types, its extensions, and what a block of it represents.ContentResponse- the base of all of these, carryingAdditional.
ContentJob answers the questions a screen actually asks - IsReady, IsFinished and
NeedsAttention - rather than leaving you to compare state strings. Treat an unfamiliar State as
still in progress: states may be added.
Settings, transcripts, keys and sub-organizations have methods of their own (Backend API
reference). Anything else is still reachable: PlycdnContentClient.SendAsync(method, path, organizationId, body, idempotencyKey, ifMatch, ct)
takes a relative path and returns the raw JSON. None of these is reachable from a browser through the package's
controller: organization settings decide where we fetch from, so the controller serves neither
reading nor writing them; read them on your server with GetSettingsAsync.
Identifying a search
Every search returns a SearchId, distinct for each call, including a repeat of the same query.
It is the identifier the search is recorded under, so a line in your log and a row in our records
describe the same event:
var results = await content.SearchAsync(organizationId, new ContentSearchQuery { Query = phrase });
logger.LogInformation("Content search {SearchId} for {User} returned {Count}",
results.SearchId, userId, results.Items.Count);
Quote that id when raising anything with us and we can find the exact request. Every response from the API itself
carries an X-Request-Id header; send your own and we echo it rather than issuing a second one, so
a request keeps one identity across your gateway and ours. A refusal that happens before the request
reaches the API (at the network edge) may not carry it.
Responses grow without breaking you
The typed records keep fields they do not recognise in Additional instead of discarding them or
throwing, so a response that starts carrying something new does not break an application built
against an older package. Read them with Additional["newField"] until a released version exposes
the property.
Whichever JSON serializer your application uses
The endpoints this package contributes write their responses directly, so they are unaffected by
the serializer your host configures. An application that calls AddNewtonsoftJson() — common in
services carried forward from earlier ASP.NET Core versions — receives exactly the same bodies as
one left on the default System.Text.Json. No configuration, formatter or converter is needed on
your side.
Separate search controls and results, or build your own UI
For a more customized page, the optional plycdn-content-results renderer is also exported. Set [showResults]="false" on search, receive (resultsChange) / (loadingChange), and bind those values to [results] / [loading] on your results component. The renderer supports the same labels, resultTemplate, playbackLink, showInlinePlayback, and playerHandler inputs.
Use resultTemplate to replace each result's markup completely:
<plycdn-content-search [resultTemplate]="customResult"></plycdn-content-search>
<ng-template #customResult let-result let-open="open">
<article class="your-result">
<h3>{{ result.filename }}</h3>
<p>{{ result.snippet }}</p>
<button type="button" (click)="open(result)">View passage</button>
</article>
</ng-template>
For complete control, use the exported ContentClient in your own components. Its authenticated customer-backend methods are the same ones used by the packaged UI. You do not need to reimplement storage URL resolution to replace the visual presentation.
Theme and CSS customization
Set CSS custom properties on a shared customer container; they inherit through search, results and playback. No ::ng-deep or global reset is required:
.customer-content {
--pcs-font-family: var(--app-font, system-ui);
--pcs-font-size: 1rem;
--pcs-line-height: 1.5;
--pcs-accent: #235b76;
--pcs-on-accent: #ffffff;
--pcs-surface: #ffffff;
--pcs-text: #18232b;
--pcs-muted: #52616b;
--pcs-border: #b8c6ce;
--pcs-focus: #235b76;
--pcs-radius: 10px;
--pcs-control-height: 44px;
--pcs-control-gap: .75rem;
--pcs-button-padding: .7rem 1rem;
--pcs-input-padding: .75rem;
--pcs-result-spacing: 1.5rem;
--pcs-spacing: 1.25rem;
--pcs-player-height: 30rem;
--pcs-player-background: #101820;
}
Use data-theme="dark" on the shared container or components for dark defaults. Explicit customer color variables take precedence; provide matching light/dark values and maintain readable contrast. Other supported variables include --pcs-background, --pcs-preview-surface, --pcs-button-radius and --pcs-snippet-width; the preview's player uses --pcs-player-bar, --pcs-player-text and --pcs-progress-height, a rendered document uses --pcs-highlight, --pcs-document-height and --pcs-mono-font, an error uses --pcs-error, a result's confidence badge uses --pcs-confidence-high, --pcs-confidence-moderate and --pcs-confidence-low, --pcs-accent-contrast is the text colour on an accent-coloured badge, and its match ticks --pcs-marker and --pcs-marker-active.
Marked words use --pcs-mark (their background); a result found by meaning alone is edged in --pcs-meaning, which defaults to the accent. In a PDF, the matched passage is tinted with --pcs-pdf-passage and the area around the pages is --pcs-pdf-background.
For advanced global styles, result elements expose .pcs-result, .pcs-result-title, .pcs-result-meta, .pcs-snippet, .pcs-mark, .pcs-meaning, .pcs-result-action and .pcs-playback-link; a file's jump-to links are .pcs-moments and .pcs-moment (with .pcs-moment-best on the best match, .pcs-moment-open on the one playing and .pcs-moment-more on the "+n more" control); playback exposes .pcs-playback and .pcs-video. [momentsShown] on <plycdn-content-results> sets how many jump-to links show before "+n more" (default 6). Prefer public variables and templates over depending on internal element structure.
The upload component's file picker offers video/*,audio/*,.pdf,.docx,.xlsx,.csv,.txt,.md by default. To offer the other indexable types, set [accept] (for example [accept]="'video/*,audio/*,.pdf,.docx,.xlsx,.csv,.txt,.md,.html,.htm,.xhtml,.zip'"). Upload previews use the file selected by the user, without waiting for processing; object URLs are released on close and component destruction. Disable them with [showPreview]="false". Use [statusLabels] to translate the states a row can show, and the shared [labels] input for buttons, result locations and playback messages. The 1.6 labels are momentsLabel (the accessible name of a file's jump-to links), moreMoments, fewerMoments, matchedByMeaning, slide, filesFound, the result button's playFrom, goToPage, goToSlide, goToRow, goToSheetRow, goToLine and goToPassage, and for the PDF toolbar zoomIn, zoomOut, fitWidth and backToMatch. Three of those labels are worth translating even if you skip the rest, because they are what a user sees when something is wrong or slow: buffering while a recording loads, sourceUnavailable when a file has been removed or changed since it was indexed, and openOriginal on the link offered alongside a rendered document. The complete set is uploading, registering, queued, extracting ("Reading file"), transcribing, embedding ("Indexing"), retry_scheduled, awaiting_source_access, ready, failed and cancelled. Any state you do not supply falls back to built-in English. failed and awaiting_source_access are the ones users actually act on, so translate those even if you skip the rest.
8Uploading entirely from your own server
The endpoints above assume a browser uploads to your storage directly, which is the cheapest path: the bytes never pass through your application. If instead your backend receives the file and writes it to storage itself, register it afterwards:
// Your own upload endpoint, already authenticated by your application.
[HttpPost("library")]
public async Task<IActionResult> Upload(IFormFile file, CancellationToken ct)
{
var organizationId = CurrentOrganization(); // however your application resolves it
var ticket = new UploadTicket(organizationId, User.Identity!.Name!,
quot;{organizationId}/{Guid.NewGuid()}", file.FileName, "document",
file.Length, DateTimeOffset.UtcNow.AddDays(2));
var target = await storage.CreateUploadAsync(ticket, ct);
await using (var incoming = file.OpenReadStream())
await PutToStorage(target.Url, incoming, ct); // your own transfer
var source = await storage.CompleteUploadAsync(ticket, ct);
var access = await storage.ResolveReadAsync(organizationId, source.ExternalId, source.SourceVersion, ct);
var registration = await api.RegisterAssetAsync(organizationId, source, access, ct: ct);
return Accepted(new { registration.AssetId, registration.JobId });
}
Inject ICustomerFileStorage and PlycdnContentApi; AddPlycdnContentSearch registers both (with
the options overload, you register ICustomerFileStorage yourself: see "Telling us where your files
are"). Everything else is your own code, so the transfer can be chunked,
resumable or queued to suit your application.
RegisterAssetAsync is retry-safe by default: registering the same file twice returns the original
registration rather than indexing it again.
⚠️ Never expose
RegisterAssetAsyncon a browser-reachable endpoint. It names the location we will fetch. Forwarding a caller-supplied body to it, signed with your server API key, would let any signed-in user make plycdn fetch a URL your storage never issued. This is why the package's own controller does not offer it, and why the browser-driven path derives the location from a protected upload session instead of from anything the browser sent.
Indexing is asynchronous either way. Give the JobId to your own interface and poll
GetJobAsync, as in Watching your content process below — the part that matters for large media,
where transcription takes minutes.
9Storage
Buckets, uploads, signed links and usage are a separate capability from everything above, carried by three packages you install beside — or instead of — the Content Search ones:
dotnet add package Plycdn.AspNetCore.Storage --version 1.4.0 --source YOUR_PACKAGE_FEED_OR_FOLDER
npm install ./vendor/plycdn-angular-1.4.0.tgz # @plycdn/angular/storage
npm install ./vendor/plycdn-react-1.4.0.tgz # @plycdn/react/storage
Plycdn.AspNetCore.Storage registers with its own AddPlycdnStorage(builder.Configuration), independent of
AddPlycdnContentSearch — call both if your application uses both. The full walkthrough (creating
buckets, uploading, delivering, caching, CORS, purges, usage, delivery analytics, audit) is
the Storage guide; this section covers the one piece that is specific to this guide's authentication
model — deciding who a browser call is allowed to make — and two delivery tasks most integrations
meet: letting your pages' player read files, and clearing the edge after you change files.
The API key AddPlycdnStorage is configured with decides what your server can do: a standard key makes
every storage call a running service needs; creating, changing, deleting, restoring or retrying a
bucket, rotating its signing key, adding or removing a custom domain, creating, changing or deleting a
video library or a webhook endpoint, rotating a webhook secret, and creating or changing the storage
account need an admin key (admin_key_required otherwise; "Which key" in the Storage guide, the full
list in the API reference). If one application does both, configure it with an admin key only where it sets
buckets up.
Signing links
PlycdnLinkSigner and PlycdnVideoSigner sign plycdn links (pl1, and pl1d for a whole video) in your process from
Storage & video 1.4.0; earlier versions ask plycdn to sign them (POST /sign), so they keep working unchanged. Buckets
made before plycdn links keep their earlier links (ps2, ps3), which the signers also sign in your process. To sign
without our packages, see Signing links yourself.
Authorizing browser access to objects
Listing a bucket's objects, reading one, signing a link, and the two upload steps a browser makes
are all reachable through PlycdnStorageController, mounted at api/plycdn/v1 the same way the
Content Search controller is — but storage has no concept of "read" and "manage" claims, because
what a signed-in user may reach depends on which bucket and which key, not on a blanket permission.
Implement IPlycdnStorageAuthorizer:
public interface IPlycdnStorageAuthorizer
{
Task<bool> AuthorizeAsync(ClaimsPrincipal user, PlycdnOperation operation,
Guid bucketId, string? key, CancellationToken cancellationToken);
}
public enum PlycdnOperation { ListObjects, GetObject, StartUpload, ContinueUpload, Sign, GetVideo, WatchVideo }
and register it in place of the default:
builder.Services.AddScoped<IPlycdnStorageAuthorizer, CourseMediaAuthorizer>();
For example, allowing a signed-in member of a course to list and read that course's bucket, and to sign links into it, but nothing else:
public class CourseMediaAuthorizer : IPlycdnStorageAuthorizer
{
private readonly ICourseMembership _membership;
public CourseMediaAuthorizer(ICourseMembership membership) => _membership = membership;
public async Task<bool> AuthorizeAsync(ClaimsPrincipal user, PlycdnOperation operation,
Guid bucketId, string? key, CancellationToken cancellationToken)
{
if (operation is not (PlycdnOperation.ListObjects or PlycdnOperation.GetObject
or PlycdnOperation.Sign))
return false; // no browser-driven uploads for this application
var courseId = await _membership.CourseForBucketAsync(bucketId, cancellationToken);
return courseId is not null
&& await _membership.IsMemberAsync(user, courseId.Value, cancellationToken);
}
}
The default, DenyPlycdnStorageAuthorizer, refuses everything — every one of the seven
operations returns forbidden until you register your own. There is no "allow all" built in: a
browser reaching plycdn through your backend still means your backend decides who the caller is
and what they may touch, exactly as IOrganizationContextResolver decides for Content Search.
Custom domains and links on them
Custom domains are managed from your backend only (they are administrative, like creating buckets),
through PlycdnStorageApi:
| Method | Route |
|---|---|
CreateDomainAsync(organizationId, new DomainCreate(bucketId, "cdn.example.com"), idempotencyKey: null) |
POST /domains (sent with Idempotency-Key: yours, or a new one when null; pass the same key when retrying after a timeout to get the same domain back rather than domain_exists) |
ListDomainsAsync(organizationId, bucketId: null, cursor: null, limit: null) |
GET /domains (DomainPage: Items, NextCursor) |
GetDomainAsync(organizationId, domainId) |
GET /domains/{domainId} |
DeleteDomainAsync(organizationId, domainId) |
DELETE /domains/{domainId} |
CheckDomainAsync(organizationId, domainId) |
POST /domains/{domainId}/check |
Each returns a PlycdnDomain (Hostname, UnicodeHostname, State, FailureReason, Apex,
Records, Checks, NextCheckAt, …): show its Records to whoever manages your DNS, and its
State until it is active. The Storage guide walks through the records, the states and what each
failureReason asks of you. A refusal is a PlycdnException with the codes in the API reference
(domain_exists, domain_limit_reached, plan_limit_reached, domain_rate_limited with
Retryable set, hostname_not_allowed, domain_not_found). Adding a domain and checking one
also need the bucket to be active and not suspended: otherwise bucket_not_active (409) or
bucket_suspended (403).
To sign a link on an active domain instead of the bucket's default hostname, pass the hostname in
PlycdnSignOptions:
var url = await signer.SignAsync(organizationId, bucketId, "week-1/intro.mp4",
new PlycdnSignOptions { Hostname = "cdn.example.com", Ttl = TimeSpan.FromMinutes(15) });
The hostname may be given in any spelling (case, trailing dot, Unicode). A call that passes a literal
null as the fourth argument (SignAsync(organizationId, bucketId, key, null)) does not compile,
because null fits both overloads: name the parameter (ttl: null) or pass a PlycdnSignOptions. A hostname that is not the
bucket's default or one of its active domains throws PlycdnException domain_not_active,
after the signer has fetched the signing profile once more in case the domain has just become
active (at most once a minute per bucket; inside that minute an unknown hostname is refused at once).
The signer caches the profile for SigningProfileCacheFor (10 minutes by default), so a domain that
has just stopped being active is still signed on, with links that do not work, until the cache
expires; call signer.Forget(organizationId, bucketId) after deleting a domain from this process.
Under .NET's invariant globalization mode, pass a Unicode hostname in its xn-- form
(PlycdnDomain.Hostname): the Unicode spelling is refused with validation_error.
From the browser, the same choice goes through PlycdnStorageController, which passes hostname
on to plycdn; your IPlycdnStorageAuthorizer is asked about the bucket and key (Sign) exactly as
before:
// @plycdn/react/storage (client)
const { url } = await client.sign(bucketId, "week-1/intro.mp4", { hostname: "cdn.example.com" });
// @plycdn/angular/storage (PlycdnStorageClient.sign takes one request object and returns an Observable)
storage.sign({ bucketId, key: "week-1/intro.mp4", hostname: "cdn.example.com" }).subscribe(({ url }) => (this.src = url));
// @plycdn/react/storage hook
const { url } = useSignedUrl(bucketId, "week-1/intro.mp4", { hostname: "cdn.example.com" });
In @plycdn/react/storage, client.sign's third argument is either the TTL in seconds, as before, or
{ ttlSeconds, hostname } (its fourth is still { signal }), and useSignedUrl takes
{ ttlSeconds, hostname }. In @plycdn/angular/storage, hostname is a field of the SignRequest beside
ttlSeconds.
Playing files from another origin (CORS)
A JavaScript video player, PDF viewer or anything else that reads bucket files with fetch or
XMLHttpRequest from your application's pages runs on your origin (https://learn.example.com), not
the bucket's (https://course-media.plycdn.net), so the browser lets it read the responses only if
they carry CORS headers for your origin. Set the bucket's cors once, from your backend (or in the
bucket's Delivery tab in the dashboard):
await plycdn.UpdateBucketAsync(organizationId, bucketId, new BucketPatch
{
Cors = new BucketCors
{
AllowedOrigins = new[] { "https://learn.example.com", "http://localhost:4200" },
AllowedHeaders = new[] { "range" }, // players request byte ranges
ExposeHeaders = new[] { "content-length", "content-range" },
},
});
List every origin your pages are served from in allowedOrigins — exact origins only, https:// (plain http:// only
for localhost and 127.0.0.1 while developing), no wildcard subdomains. A <video src> or <img>
tag needs no CORS at all. CORS does not protect files: a request from an origin you did not list is
still served, just without the headers a browser script needs. Keep private files private with signed
links and your IPlycdnStorageAuthorizer. The rules in full, including why allowCredentials never
goes with *, are in the Storage guide.
After you replace a file
Uploading to a key that already exists (with allowOverwrite), or deleting a file, removes that key's
copies from the edge shortly afterwards; you do not need to purge for that. Purge when you need more —
cache settings you changed and want applied now (purge straight after the change: the purge waits
for it to reach the edge), or a set of files (a re-encoded set of video files, a
folder of thumbnails) you want cleared in one step and followed until it is done:
var purge = await plycdn.PurgeCacheAsync(organizationId, bucketId, new CachePurgeRequest
{
Prefixes = new[] { "video/course-1/" },
});
// purge.State is "queued"; GetPurgeAsync(organizationId, purge.Id) until "succeeded" or "failed"
A purge takes 1 to 100 URLs on the bucket's default hostname or one of its active custom domains, 1 to 20
key prefixes, or the whole bucket.
Purges are free, within 60 requests an hour per account, 10 of them for a whole bucket
(rate_limited past that). A purge cannot reach a copy already in a viewer's browser, which keeps it
for as long as the bucket's browserCacheControl allowed — so give a file whose content changes a new
key rather than marking it immutable. Details: the Storage guide.
S3 and Azure tools
Your buckets can also be reached from S3 and Azure tools and SDKs, for moving files in and out:
migrations, backups, sync jobs and pipelines. Plycdn.AspNetCore.Storage.S3 and
Plycdn.AspNetCore.Storage.AzureBlob build an AmazonS3Client or a BlobServiceClient for a region
(PlycdnS3.CreateClient, PlycdnBlob.CreateServiceClient). They sign with a storage credential,
which is a server-side secret like the API key: create it in the dashboard (Developers → Storage
credentials), keep it in your backend's secret store, and never send it to a browser — browsers keep
using PlycdnStorageController and your authorizer above. Viewers keep using signed delivery links on
plycdn.net. Hosts, supported operations, limits and pricing are in the S3 and Azure compatibility guide, which also
has an rclone recipe for bulk copies (a one-time migration of existing files, for example) and
security notes on which request bodies to sign.
10Video
Video libraries, the player and webhooks come with the Plycdn.AspNetCore.Video package and the
video entry points of the two npm packages (from version 1.2.0). The player needs nothing beyond the browser package, installed in the commands below; its streaming
code is loaded only when a player needs it, and never in browsers that play the
stream natively:
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 # Angular: @plycdn/angular/video
npm install ./vendor/plycdn-react-1.4.0.tgz # React: @plycdn/react/video
The whole walkthrough (libraries, adding videos, statuses, signing, Content Security Policy and webhooks) is the Video guide. This section covers what is specific to your application: who may watch, the player in your pages, and receiving webhooks.
Who may watch
The player reaches plycdn only through your backend's browser controllers, with two routes:
GET api/plycdn/v1/videos/{id} (PlycdnVideoController: the video's title, status, captions and
download) and POST api/plycdn/v1/sign with a videoId (PlycdnStorageController: a playback link). Your IPlycdnStorageAuthorizer decides
each, with two operations:
| Operation | Asked for | bucketId |
key |
|---|---|---|---|
PlycdnOperation.GetVideo |
GET videos/{id} |
the video's library | the video id |
PlycdnOperation.WatchVideo |
POST sign with a videoId |
the library | the video id |
For GET videos/{id} the controller reads the video with your key first, so that it can tell you its
library; a refusal answers 403 {"code":"forbidden"} and nothing of the video. Like every operation,
both are refused until your authorizer allows them:
public class CourseVideoAuthorizer : IPlycdnStorageAuthorizer
{
private readonly ICourseMembership _membership;
public CourseVideoAuthorizer(ICourseMembership membership) => _membership = membership;
public async Task<bool> AuthorizeAsync(ClaimsPrincipal user, PlycdnOperation operation,
Guid bucketId, string? key, CancellationToken cancellationToken)
{
if (operation is not (PlycdnOperation.GetVideo or PlycdnOperation.WatchVideo))
return false;
// key is the video id: allow members of the course that lesson belongs to
var courseId = await _membership.CourseForVideoAsync(Guid.Parse(key!), cancellationToken);
return courseId is not null && await _membership.IsMemberAsync(user, courseId.Value, cancellationToken);
}
}
Creating libraries and videos, and everything about webhooks, happens on your server with the API key
(PlycdnVideoApi, which AddPlycdnVideo registers), never through the controller.
The player in Angular
PlycdnVideoModule from @plycdn/angular/video (with forRoot, as for uploads) declares <plycdn-player>:
<plycdn-player [libraryId]="lesson.libraryId" [videoId]="lesson.videoId"
[ttlSeconds]="14400" [resume]="true" (statusChange)="onStatus($event)"></plycdn-player>
It asks your backend for the video and a playback link, renews the link before it expires and when the
delivery network refuses it, and polls a playable video until it is ready. (statusChange) reports
the player's status (loading, ready or error). An error is shown as a sentence, not a code: the
defaults for playback_unsupported, playback_link_failed, video_not_ready and video_not_found are
in PLYCDN_MESSAGES. PlycdnStorageClient.getVideo(id) reads a video, and
PlycdnPlaybackService.link(libraryId, videoId, ttlSeconds?) gives you a playback link for your own
player. The keyboard map and accessibility are in the Video guide.
The player in React
Inside PlycdnProvider:
import { PlycdnPlayer, useVideo } from '@plycdn/react/video';
function Lesson({ libraryId, videoId }: { libraryId: string; videoId: string }) {
const { data: video } = useVideo(videoId); // polls while uploading, queued, encoding or playable
return (
<section>
<h2>{video?.title}</h2>
<PlycdnPlayer libraryId={libraryId} videoId={videoId} onStatus={status => console.log(status)} />
</section>
);
}
<PlycdnPlayer> takes libraryId, videoId, ttlSeconds, resume, className and onStatus, and
behaves exactly as the Angular player. For your own player, usePlayback(libraryId, videoId, { ttlSeconds })
returns { src, nativeSrc, poster, thumbnails, download, expiresAt, loading, error, refresh }: signed
links that renew themselves at 80 % of their lifetime. Call refresh() when your player is refused
with 403. The Video guide has a complete example.
Receiving webhooks in ASP.NET Core
Create the endpoint from your server (CreateWebhookAsync, or in the dashboard under Developers →
Webhooks) and store the secret it shows once. Then verify every request before acting on it:
builder.Services.Configure<PlycdnWebhookOptions>(builder.Configuration.GetSection("PlycdnWebhooks"));
app.MapPost("/plycdn/webhooks", async (HttpRequest request, IOptions<PlycdnWebhookOptions> options,
IVideoEvents events, CancellationToken ct) =>
{
PlycdnWebhookEvent evt;
try { evt = await PlycdnWebhookVerifier.ReadAsync(request, options.Value.Secrets, ct: ct); }
catch (PlycdnWebhookSignatureException) { return Results.Unauthorized(); }
// A delivery can arrive more than once: Plycdn-Delivery is the same on every retry.
await events.EnqueueOnceAsync(request.Headers["Plycdn-Delivery"].ToString(), evt, ct);
return Results.Ok(); // answer quickly; do the work afterwards
});
public sealed class PlycdnWebhookOptions { public string[] Secrets { get; set; } = Array.Empty<string>(); }
PlycdnWebhookOptions is your own settings class: keep the secret in your secret store, and during a
secret rotation list both the new and the old secret (deliveries are signed with both for 24 hours),
then remove the old one. ReadAsync refuses a missing, malformed or forged signature and a timestamp
more than 5 minutes from your clock (tolerance changes that), and never puts the secret or the body in
its exception. evt.Type is video.playable, video.ready, video.failed or webhook.ping, and
evt.AsVideo() gives the video for the first three. For another framework, or to verify bytes you
already hold, PlycdnWebhookVerifier.Verify(body, signatureHeader, secrets) returns true or false.
11Backend API reference
Every call below is available on PlycdnContentApi, injected into your own controllers or
services. Each resolves the organization on your server, so nothing from the page decides whose
content is returned. All are async and take an optional CancellationToken.
Finding things
| Method | Returns | Use it for |
|---|---|---|
SearchAsync(org, query) |
ContentSearchResults |
Searching. See Relevance below. |
GetCapabilitiesAsync(org) |
ContentCapabilities |
Which file types your plycdn service accepts, and the size limits. Read it rather than hardcoding: formats are added over time and your application picks them up without a rebuild. |
ContentSearchQuery takes Query, an optional Kind (video, audio, document) to filter by
type, Limit (1 to 100) and Offset for paging, Group = "file" for one result per file, a Filter
(a ContentSearchFilter: Collections, Metadata, ExternalIds, AssetIds, CreatedAfter,
CreatedBefore, Speaker, Language; the ones you set combine with AND) and Principals, who is
asking. Principals is for a call your own server makes; the browser controller replaces it with
what your IContentPrincipalResolver returns. Leave it empty and only open files are returned.
| Method | Returns | Use it for |
|---|---|---|
AnswerAsync(org, query) |
ContentAnswer |
A written answer with numbered citations, from the same ContentSearchQuery. Answer is null when nothing answers it. |
SuggestAsync(org, prefix, limit) |
ContentSuggestions |
Completions for a partly typed query (two characters or more; limit 1 to 25). Not billed. |
GetRelatedAsync(org, assetId, limit, principals) |
ContentRelated |
Files near this one (limit 1 to 50). |
Listing and inspecting
| Method | Returns | Use it for |
|---|---|---|
ListAssetsAsync(org, limit, offset) |
ContentAssetPage |
A plain page of registered files, newest first. |
GetActivityAsync(org, limit, offset, …) |
ContentActivityPage |
The same page with each file's current indexing state, in one call, and narrowable. Use this for a library view rather than listing and then asking about each file. |
GetAssetAsync(org, assetId) |
ContentAsset |
One file. |
GetJobAsync(org, jobId) |
ContentJob |
Progress of one indexing run. |
GetAssetJobsAsync(org, assetId) |
ContentJobHistory |
Every run for a file, newest first, including earlier failed attempts. This is what answers "why did this take three tries?" |
GetSettingsAsync(org) |
ContentOrganizationSettings |
The organization's configured storage hosts, callback and limits. Useful for a settings screen or a self-check. |
GetUsageByAssetAsync(org, from, to, limit, offset) |
ContentUsageByAsset |
What was done to each file in a period — hours transcribed, frames read, bytes fetched. Never gated. |
GetMetadataAsync(org, assetId) · GetPermissionsAsync(org, assetId) |
ContentMetadata · ContentPermissions |
A file's tags, and who may find it (GET /assets/{id}/metadata and /permissions). |
GetLibraryHealthAsync(org, limit) |
ContentLibraryHealth |
Files that are stuck, failed or missing from their source (limit 1 to 100). |
GetSearchAnalyticsAsync(org, days, limit) |
ContentSearchAnalytics |
What people searched for, and what they searched for and did not find (days 1 to 365, limit 1 to 200). |
GetUsageDailyAsync(org, from, to) |
ContentUsageDaily |
Usage day by day. |
Changing things
| Method | Returns | Use it for |
|---|---|---|
RegisterAssetAsync(org, source, access, …) |
ContentRegistration |
Registering a file your own server placed in storage. See Uploading entirely from your own server, and Everything you can say at registration below. |
ReindexAsync(org, assetId) |
ContentRegistration |
Indexing a file again — after replacing it in storage, or to pick up an improvement. Produces a new revision; the old one stays searchable until the new one is ready. |
CancelJobAsync(org, jobId) |
— | Stopping a run in progress. |
DeleteAssetAsync(org, assetId) |
— | Removing a file and everything indexed from it. |
ImportAsync(org, items, …) |
ContentImport |
Registering many files in one call, for a first load of an existing library. One outcome per file. |
GetImportAsync(org, importId) |
ContentImportStatus |
How that batch is progressing, job by job. |
RenewSourceAccessAsync(org, assetId, access) |
ContentAccessRenewed |
Supplying fresh access after source_access_expired, and resuming whatever was waiting on it. |
UpdateSettingsAsync(org, change) |
ContentOrganizationSettings |
Changing some settings: set fields on a ContentSettingsChange, and Clear(ContentSetting.…) a field to inherit it again. The rest is left alone. |
ClearSettingsAsync(org) |
ContentOrganizationSettings |
Returning an organization to its parent's settings entirely. |
SetMetadataAsync(org, assetId, metadata) |
— | Replacing a file's tags (up to 50 keys). Never re-indexes. |
SetPermissionsAsync(org, assetId, mode, principals) |
— | open or restricted, and who may find a restricted file. Replaces the previous grant. |
GetTranscriptAsync(org, assetId) |
ContentTranscript |
Reading a recording's transcript, with its Revision. |
CorrectTranscriptAsync(org, assetId, revision, edits) |
ContentRegistration |
Replacing the text of some segments. The file is indexed again with your text, not transcribed again; revision_conflict if it changed since you read it. |
RegisterAssetAsync and ReindexAsync accept an optional idempotency key and default to a safe
one, so a retry returns the original registration rather than indexing the same file twice.
Collections and tuning from C#
| Method | Use it for |
|---|---|
ListCollectionsAsync(org, limit, offset) |
Every collection with its file count (limit 1 to 200, default 50, and offset). |
UpsertCollectionAsync(org, externalId, name, metadata) |
Create or rename one. externalId takes letters, digits and _ . ~ - only. |
DeleteCollectionAsync(org, externalId) |
Remove the grouping; its files stay indexed. |
SetCollectionMembersAsync(org, externalId, add, remove) |
Add and remove files; removals apply after additions. |
GetSynonymsAsync(org) · SetSynonymsAsync(org, synonyms) |
Read, or replace, the whole synonym dictionary. |
ListBoostsAsync(org) · SetBoostAsync(org, boost) · DeleteBoostAsync(org, name) |
Boosts: ContentBoost has Name (letters, digits and _ . ~ -), Match, Kind and Factor (above 1 promotes, below 1 demotes, zero is refused). |
ListPinsAsync(org) · SetPinsAsync(org, query, assetIds) |
Pin up to 10 files to one exact query; an empty list removes the pin. |
var page = await api.SearchAsync(organizationId, new ContentSearchQuery
{
Query = "reciprocal rank fusion",
Group = "file",
Filter = new ContentSearchFilter { Collections = new[] { "CS101" }, Language = "hi" },
});
var answer = await api.AnswerAsync(organizationId, new ContentSearchQuery { Query = "what is a hazard" });
For a browser-facing search, implement IContentPrincipalResolver; ClaimsContentPrincipalResolver
is a starting point that returns the user's name identifier (or sub) and every role claim:
builder.Services.AddScoped<IContentPrincipalResolver, ClaimsContentPrincipalResolver>();. Until
you register one, nobody holds any principal, so every restricted file stays invisible to browser
searches.
Showing a library somebody can actually navigate
Each entry says which connector brought the file in, and the same list can be narrowed:
// What one connector brought in, that is still not searchable.
var stuck = await api.GetActivityAsync(organizationId, limit: 50,
connectorId: bucket.ConnectorId, state: "failed");
// Where did lecture-3 go? A fragment, not a whole word.
var found = await api.GetActivityAsync(organizationId, filename: "lecture-3");
// What arrived today.
var today = await api.GetActivityAsync(organizationId,
createdAfter: DateTimeOffset.UtcNow.Date);
Every narrowing combines with AND, and each entry carries ConnectorId and ConnectorName so a
row can show where a file came from as well as be filtered by it.
Without these, a library screen is a reverse-chronological list, which stops being usable once a connector brings in a few thousand files at once. The three questions people actually ask — which files came from that source, which are still working, where did one particular file go — each need one of these filters.
A storage event reaches the list in seconds. A connector with a notifyUrl begins its sync a
few seconds after the notification rather than waiting for the next scheduled pass, so a file
uploaded to your bucket appears — with its stage and progress — while somebody is still looking at
the screen. Poll GetActivityAsync while anything is unfinished and stop when nothing is.
Loading a library you already have
var batch = files.Select(f => new ContentImportItem(
new SourceReference(f.Id, f.Url, f.ETag, f.Name, f.Kind),
new SourceAccess(f.SignedUrl, f.Expires))
{
Metadata = new Dictionary<string, object?> { ["course"] = f.Course },
Principals = f.Enrolled,
});
var result = await api.ImportAsync(organizationId, batch, idempotencyKey: quot;initial-load-{page}");
foreach (var line in result.Results.Where(l => l.Code is not null))
log.LogWarning("{File} refused: {Code}", line.ExternalId, line.Code);
Read Results. Every file is checked independently, so a batch of 2,000 with ten bad source
URLs registers 1,990 and tells you about the ten — treating the call as pass-or-fail throws that
away. Pass an idempotency key: a timeout partway through a large batch would otherwise leave you
unable to tell what registered, and retrying without one would double it.
When access expires before we fetch
A job that cannot reach its file waits in awaiting_source_access rather than failing. Supply a
new URL and it resumes:
var resumed = await api.RenewSourceAccessAsync(organizationId, assetId,
new SourceAccess(freshSignedUrl, expires));
The address must be the same file — the unsigned form has to match what the asset was registered
with, or it is refused with source_access_mismatch, so renewing access can never quietly
re-point an asset at different content. If you set callbackUrl in settings you do not need this
at all: we ask you for access when we need it, which is the arrangement to prefer for a library of
any size.
Everything you can say at registration
Beyond the file itself, all of it optional:
await api.RegisterAssetAsync(organizationId, source, access,
extractContentFromVideoFrames: false, // audio only, no frames read
metadata: new Dictionary<string, object?> // your own filterable fields
{
["course"] = "NUR-204", ["term"] = "2026-spring", ["year"] = 2026,
},
collections: new[] { "nursing-year-2" }, // collections it belongs to
principals: new[] { "student:1481", "staff:nursing" }, // who may find it
language: "hi", // when you know it
audioTrack: 1); // a recording with two audio tracks
metadata, collections and principals can also be set afterwards — SetMetadataAsync,
SetCollectionMembersAsync, SetPermissionsAsync — so passing them here is a convenience.
language and audioTrack cannot, and that is the reason they are worth knowing about.
Indexing starts as soon as this call returns, so by the time any later call could change them the
transcription is already done and you would be paying to do it again. language defaults to
multi, which detects the language; that is the right default for a mixed library and a worse
answer than naming it when you know it. audioTrack is zero-based, for a recording carrying more
than one — a dubbed lecture, or a room feed alongside a presenter feed.
Leaving language or audioTrack out sends nothing, so the service's default applies rather than
a cleared value. The other optional fields (metadata, collections, principals,
extractContentFromVideoFrames) are sent as null when you leave them out, which the service treats
as unset.
Plan and connectors
| Method | Returns | Use it for |
|---|---|---|
GetPlanAsync(org) |
ContentPlan |
What this organization's plan includes. Read it to decide which features to offer; after a not_included_in_plan refusal it says what you do have. |
ListConnectorsAsync(org) |
ContentConnectorPage |
Every configured connector with its state and last sync. |
GetConnectorAsync(org, connectorId) |
ContentConnector |
One connector. The credential is never in the response. |
CreateConnectorAsync(org, request) |
ContentConnector |
Creating one. See ContentConnectorRequest below. |
UpdateConnectorAsync(org, connectorId, request) |
ContentConnector |
Changing one. Omit Credential to keep the stored token. |
DeleteConnectorAsync(org, connectorId) |
— | Removing one. Content it already synced stays indexed. |
SyncConnectorAsync(org, connectorId) |
ContentSyncRequested |
Asking for a sync now, whatever the schedule. Answers whether one is now pending. |
ContentConnectorRequest carries Kind (s3, azure_blob or canvas), Name, Config,
Credential (write-only), Schedule (manual or nightly), Enabled and DefaultAclMode.
ExtractContentFromVideoFrames states once, for the whole source, whether frames may be read
from the recordings it brings in; null inherits the organization's setting. It is sent when you
create a connector and when you update one; an update that leaves it null keeps the value already
stored.
Config by kind: s3 takes endpoint, bucket, accessKeyId, and optionally region and
prefix, with the secret key as the credential; azure_blob takes blobService and container, and
optionally prefix, with a container SAS as the credential (the older single containerUrl is
still accepted); canvas takes baseUrl and optionally
courseIds, with an access token as the credential. Every connector read back carries
NotifyUrl — the address a bucket's event notification POSTs to for instant sync.
The S3 endpoint, the Azure blobService (or containerUrl) and the Canvas baseUrl must be
public HTTPS hosts on port 443, with no credentials in the address. They are checked each time the
connector reads: one that is not HTTPS on 443 stops the sync with invalid_source_url, and one whose
name resolves to a private, local or otherwise non-public address with source_address_forbidden
(the connector's last error; nothing is fetched).
Sub-organizations and the account audit
PlycdnAccountApi manages the account's sub-organizations and reads its audit. API keys are not
here: they are managed in the dashboard only. It needs an admin key (created in the dashboard),
so register it only in the automation that sets accounts up, never in the service that handles your
users; that service keeps a standard key. Pass your account's own organization id.
| Method | Returns | Use it for |
|---|---|---|
ListSubOrganizationsAsync(account) |
IReadOnlyList<SubOrganization> |
Every sub-organization, active or not, with its Metadata. |
GetSubOrganizationAsync(account, id) |
SubOrganization |
One sub-organization. |
CreateSubOrganizationAsync(account, request) |
SubOrganization |
A new sub-organization. It inherits your settings until it has its own. |
UpdateSubOrganizationAsync(account, id, changes) |
SubOrganization |
Changing name, contact, description or metadata; IsActive = true reactivates. |
DeactivateSubOrganizationAsync(account, id) |
– | Closing a sub-organization's library. Nothing is deleted; calls made for it are refused with organization_forbidden until it is reactivated. |
ListAccountAuditAsync(account, limit, before) |
AccountAuditPage |
Who changed which key or sub-organization, and who searched as someone in the dashboard. |
Onboarding a sub-organization:
var subOrganization = await accounts.CreateSubOrganizationAsync(accountId, new SubOrganizationRequest
{
Name = "Campus North", ContactEmail = "[email protected]",
Metadata = new Dictionary<string, object?> { ["region"] = "north", ["seats"] = 400 },
});
await content.UpdateSettingsAsync(subOrganization.Id,
new ContentSettingsChange().SetSourceHosts(new[] { "north.blob.core.windows.net" }));
A standard key is refused with admin_key_required (see the API reference, Managing sub-organizations
from code). To replace a key without downtime, rotate it in the dashboard, deploy the new key, then
deactivate the old one there.
Relevance on a search result
Each result carries three fields that let you present a strong match differently from a possible one:
Relevance— 0 to 1, comparable between queries. This is the one to show or threshold on.Confidence—high,moderateorlow. Results weaker thanloware not returned at all.MatchType—keywordwhen the passage contains the words searched for,semanticwhen it matched by meaning without sharing them,bothwhen it did both. Worth surfacing: it explains why an answer that shares no words with the question is nevertheless the right one.
The page also carries WithheldAsWeak. An empty Items with a figure here means the library
was searched and had nothing good enough — a different thing from an empty library, and worth
saying so in your interface.
ContentJob carries State, Stage, Progress (0–100) and ErrorCode. Treat an unrecognised
state as still in progress; new states may be added. Progress is for showing movement, not for
arithmetic — it does not advance smoothly, and a long recording can sit at one value for minutes.
Responses grow without breaking you
Every response type keeps fields it does not recognise in Additional rather than discarding them
or throwing, so a response that starts carrying something new does not break an application
built against an older package.
12Telling us where your files are
The package needs one thing from you: a way to reach a file, now and again later. "Later" matters — a long recording can take longer to index than a single access grant lasts, and playback and reindexing both need to get back to the original.
That is the whole of ICustomerFileStorage:
public interface ICustomerFileStorage
{
// Where should the browser upload to?
Task<SourceAccess> CreateUploadAsync(UploadTicket ticket, CancellationToken ct);
// The upload finished. Where did it land, and what version is it?
Task<SourceReference> CompleteUploadAsync(UploadTicket ticket, CancellationToken ct);
// Give me read access to that file again.
Task<SourceAccess> ResolveReadAsync(Guid organizationId, string externalId,
string sourceVersion, CancellationToken ct);
}
AddPlycdnAzureBlobStorage provides an implementation for Azure Blob Storage and is what most
integrations use. Write your own when your files already live somewhere, under a naming scheme of
your own — a different provider, an existing container layout, or paths your application already
depends on. Register it instead and everything else in the package works unchanged:
builder.Services.AddSingleton<ICustomerFileStorage, YourStorage>();
Three things to get right, because each one fails later rather than immediately:
ResolveReadAsyncmust work for any file you have registered, not only recent ones. It is used when an access grant expires mid-indexing, when a viewer opens a result, and when you reindex. If it cannot find a file, indexing stalls rather than failing loudly.sourceVersionmust change when the file changes. An entity tag or a content hash is ideal. It is how we detect that a file was replaced, and it is checked before we read.- Scope every method to the organization. The identifier arrives from your own authenticated session, but the storage layer is the last place to confirm that this organization may read this file.
13Listing what has been uploaded
The packaged components cover upload, search and playback. They deliberately do not render a library of everything an organization holds, because what belongs in that view - ordering, grouping, permissions, your own metadata - is specific to your application.
Your backend exposes the list for you to build it from. It is authenticated by your own login, like every other route the package adds:
GET /api/plycdn-content/v1/assets?limit=20&offset=0
{
"items": [
{
"assetId": "be1c954b-83b1-41c6-90ce-de975fa1f48a",
"externalId": "your-own-identifier",
"filename": "lecture.mp4",
"kind": "video",
"sourceUrl": "https://yourcompany.blob.core.windows.net/content/...",
"sourceVersion": "\"0x8DF...\"",
"activeRevision": 1,
"latestRevision": 1,
"createdAt": "2026-09-14T04:40:00Z"
}
]
}
activeRevision is null until indexing finishes; compare it with latestRevision to show whether
an item is searchable yet. GET assets/{id} adds the current state and any warnings, and
GET jobs/{id} reports progress for a specific run.
Without such a view your users can still find everything by searching - that is what the index is for - but they will see nothing immediately after an upload if the page is reloaded, because the upload component holds its own progress in memory only. Build the list if your users expect a library; skip it if search is the whole experience.
14Watching your content process
Indexing is asynchronous: an upload returns as soon as the file is stored, and the content becomes searchable some time later. In the plycdn dashboard, Library lists everything your organization has submitted with its current state, and each item opens to show every processing run for it, including retries and the reason for any failure. The list updates by itself while any file is still being processed.
Check there first when something you uploaded does not appear in search results. Most causes are self-explanatory from that screen: a storage host that is not registered, an expired access link, or a daily limit already spent.
15End-to-end acceptance checklist
- Confirm your storage host is registered under Content search → Indexing settings, and that the callback URL and signing secret are set there and match your backend configuration. Nothing indexes until all three are in place.
- Sign in as a user with the correct organization and read/manage permissions.
- Upload a short TXT file. Wait for indexing to finish, then search a phrase from it.
- Upload a short Hindi/English video. Search a phrase from it and confirm playback near the result timestamp.
- Test a PDF, DOCX and XLSX; confirm the page, heading and sheet/row information on results.
- Interrupt an upload and retry with the same file. Confirm that indexing starts only after completion.
- Test a read-only user: search works, upload/management is denied.
- Test a different organization: files are not shared across organizations.
- In a test environment, use an invalid plycdn key: uploads must not receive new upload links and search must fail. Restore the correct server secret afterward.
- Open a result after an earlier playback link has expired: opening it again obtains fresh access.
- Delete a test asset: it disappears from search; your original storage file remains.
Speech is transcribed automatically to make recordings searchable, and automatic transcription makes mistakes. Treat a matched passage as a pointer into the recording rather than a verbatim record, and review names, numbers and figures against the source before relying on them.
16Checking the integration
Before debugging anything else, ask the package what it thinks. Signed in as a user with
content:manage:
GET /api/plycdn-content/v1/diagnostics
It verifies the pieces that only exist inside your own process — configuration, the organization
claim on the signed-in user, whether plycdn answers your server API key, whether your storage
account will authorise an upload — and answers 200 when everything passes (or only warns,
"status": "degraded") or 503 when something failed, with a line per check:
{
"package": "1.10.0.0",
"status": "ok",
"checks": [
{ "name": "callback", "status": "ok", "detail": "key configured, secret 43 characters (32 required)" },
{ "name": "delivery-hosts", "status": "ok", "detail": "read direct to storage, write direct to storage" },
{ "name": "serialisation", "status": "ok", "detail": "host formatters: …; this package writes responses and reads request bodies directly, so they are unaffected" },
{ "name": "organization", "status": "ok", "detail": "claim 'plycdn_organization_id' resolved to 1000..." },
{ "name": "plycdn-api", "status": "ok", "detail": "api.plycdn.com answered in 142 ms" },
{ "name": "storage", "status": "ok", "detail": "yourcompany.blob.core.windows.net authorised an upload until ... (nothing was written)" }
]
}
These are the failures the plycdn dashboard cannot show you, because nothing reached plycdn. It is safe to leave enabled: it requires the manage policy and reports the presence and shape of configuration, never its values, and the storage check authorises an upload without writing anything. The dashboard remains the place to follow work that was submitted — the Library page lists every file and the state of its indexing, including the error code for anything that failed.
17Reacting to failures in your application
Every component emits (failed) alongside its other outputs, carrying the server's own error code
rather than only the sentence shown on screen:
<plycdn-content-search (failed)="onContentFailure($event)"></plycdn-content-search>
<plycdn-content-upload (failed)="onContentFailure($event)"></plycdn-content-upload>
onContentFailure(failure: ContentFailure): void {
// failure.operation: 'upload' | 'search' | 'source' | 'status' | 'cancel'
// failure.code: the server's code, or 'network_unreachable' when the request never completed
// failure.status: HTTP status, 0 when the browser refused or failed the request outright
// failure.requestId: correlates with server logs when the response carried one
this.telemetry.record(failure);
}
The packaged controller can also answer with these codes, which you may see in failure.code:
invalid_file (400: the file is larger than MaxUploadBytes, has an unknown kind or a filename with a
path), invalid_block (400: a block number outside 0 to 49,999), length_required (400: a relayed
block with no Content-Length), invalid_request (400: no readable JSON body),
storage_unavailable (502: your storage could not be reached; retry) and
storage_operation_incomplete (409: the transfer did not finish; retry resumes it).
If your organization has been suspended by plycdn, every key and credential is refused with organization_suspended (403,
not retryable): "This organization is suspended. Contact plycdn support." Delivery stops too: your public files and
video, signed links you already issued and your custom domains are not served while the organization is suspended, and they are
served again, with your settings as you left them, once plycdn reactivates it (this can take a few minutes to reach every location).
Queued processing, connector syncs and notifications pause, and everything works again once plycdn reactivates the organization.
A network_unreachable with status 0 means the browser refused or could not make the request at
all — most often a Content Security Policy, DNS or connectivity — rather than anything the server
returned. See Network requirements.
18How usage is counted
Five metered quantities, plus your plan's monthly minimum, all of which appear on your statement
and in /usage (see the API reference). The list prices for each plan are on
Plans and prices:
| Metric | Unit | Counted as |
|---|---|---|
| Media hours | hours | Duration of audio and video that finished indexing. |
| Document volume | MB (1,048,576 bytes) | Size of documents that finished indexing. |
| Searches | searches | One per search request. Each returns its own SearchId, and that is the unit. |
| Dashboard searches | searches | Searches made from the plycdn dashboard, free up to a monthly allowance — see Dashboard searches below. |
| Answers | answers | One per written answer actually returned. |
| Content kept searchable | GB-months | Not metered by a request — measured once per day per organization and prorated (below). |
Your plan's monthly minimum is not a metered quantity: it is the floor your metered lines are compared against, and appears as its own line when they add up to less.
Going live on any day
You can go live on any day of the month. Your service starts at the moment you go live and, if it
ever ends, stops at the moment it ends; usage outside it is never invoiced. Delivery and storage API
(S3/Azure) usage is counted in whole UTC days: a UTC day partly inside your service counts in full,
so the UTC day you go live on and the UTC day your service ends on are billed whole, on your first and
last statements, inside that month's included quantities and minimum (even when that day starts before,
or ends after, your local month). Go live at 00:00 UTC to pay for no hours before it. Usage from your last days that is counted after
your final statement closes appears on a short follow-up statement. The month you go live
in, and the month your service ends in, are prorated: your monthly minimum and every included
quantity (the dashboard search allowance too) are multiplied by the days your service was active
that month, in your account's time zone, over the days in the month. Going live on 17 September
gives 14 of 30 days: a minimum of 900.00 becomes 420.00, and 300 included searches become 140.
Usage is priced normally (graduated tier breakpoints are not prorated), and every other month is
billed in full. Monthly quotas and their alerts are not prorated: they count the whole calendar month. The statement says so: proration
on /usage and on the statement, the same on each prorated line, and a Service row and a
Proration column in the CSV (see Your monthly statement in the API reference).
If you move to plycdn from another system in the middle of a month, we set your service start to the moment of the switch: plycdn bills only from then, so nothing is billed twice.
Indexing: charged only on success
- Charged only when a file version finishes indexing successfully. A failure, "no speech detected", a cancellation (the file or job was deleted or cancelled mid-run), and a version replaced by a newer one before it finished are all never charged.
- A document that indexes to no text at all is not charged either — there is nothing there to search.
- Re-indexing a file is not charged. Asking us to process the current version again re-uses what was already extracted from it.
- Registering a new
sourceVersionis charged, once it finishes: it is a new file version, with new content to read. - A transcript correction is not charged: it does not re-run extraction.
Duplicate content: never charged twice within your account
A file version whose bytes are identical to a version we already charged, anywhere in your billing account — your organization or any of its sub-organizations — is indexed and made searchable, but not charged again. The same bytes registered under a different customer's account are charged there as usual; deduplication never crosses accounts.
How we recognise identical content depends on how the file is read:
| Kind of source | How we recognise a duplicate |
|---|---|
| A document, or a recording read in full | The sha256 of the bytes as we fetched them. Nothing for you to send. |
| A large recording we read in byte ranges rather than downloading whole | The contentSha256 you declare at registration (64 lowercase hex). With none declared, this kind of file is never recognised as a duplicate, and is charged normally every time. |
Identical content means identical work too. A copy is waived only when its bytes and the options that decide what we extract from them are the same as the charged original's, and it measured the same:
- for a recording: the same audio track, the same
language, and the same choice about reading text on screen (extractContentFromVideoFrames, your organization's setting and your plan together); - for a document: the same file type, as decided by its filename's extension.
The same recording registered again in another language, for another audio track, or with
on-screen reading switched on is transcribed or read again, and so is charged again. Nothing else
you register — sourceVersion, externalId, the filename (beyond a document's extension),
metadata, collections, permissions — affects whether a copy is waived.
What a declared contentSha256 does, and what it does not. We never waive a charge on your
word alone, because a checksum you declare is not one we computed. So:
- When a recording's
contentSha256matches content already charged in your account, we download that file in full instead of reading it in ranges, so the checksum that may waive the charge and the duration that is charged come from the same bytes. That download takes longer than a ranged read and needs your storage to serve the whole file; where it cannot, the file fails to index as any unreadable file would, and is not charged. - A recording read only in ranges is charged, and its declared checksum is kept, but it cannot itself be what later copies are waived against. So when the first copy was read in ranges, the second correctly declared copy may be charged too: it is downloaded in full and charged, and identical copies after it are the ones waived against it.
- A declared checksum that does not match the bytes changes nothing: the file is indexed and charged normally.
A duplicate shows up as its own zero-charge line item: duplicatesNotCharged on the metric's
/usage line counts them, and /usage/assets shows the specific file with billed quantities of
0 and duplicateOf naming the file it matched. That file can belong to another organization of
your account — a sub-organization's own file list then does not contain it, and the dashboard
labels it "another file in your account".
Dashboard searches
A search made from the plycdn dashboard itself (not your own application) is free up to your
plan's monthly allowance, which is one allowance per account, shared by your parent
organization and every sub-organization: searches from any of them draw on the same pool. Above
the allowance, it is billed at your search list price, on the statement line "Dashboard searches
(over allowance)": its quantity is every dashboard search in the month across the account,
included the ones the shared allowance covered, and billable the ones past it, which are the
only ones charged. Either way it counts
toward your searches quota and its alerts, together with the searches your own application makes.
On the dashboard channel only, a search or answer response carries where you stand:
"dashboardAllowance": {"allowance": 500, "used": 501, "remaining": 0, "overAllowance": true,
"unitPrice": 0.5, "currency": "INR"}
In the month your service starts or ends, allowance is the prorated allowance (a whole number of
searches) and used counts only searches inside your service — exactly what your statement applies.
Before your service starts, or after your service has ended, serviceActive is false (with
serviceStartsAt and serviceEndsAt): nothing is billed then, and the dashboard says so instead of
showing an allowance.
This never appears on a response to your own application's API key — only on the dashboard. The
dashboard's own search box shows the remaining free count once it is low, and a notice once you are
over it; the dashboard shows the same allowance alongside your quotas under Account → Usage.
GET /usage/quotas carries the same figures as dashboardSearches, for the organization you call
with, so a page can show it before the first search. unitPrice is null while searches have no
price configured for your account.
Storage
Content kept searchable is measured once per day, per organization, and prorated: a file kept searchable for half a month contributes half a GB-month to that month's usage, not a full one. Deleting a file stops it accruing from the next day's measurement.
Quotas, alerts and pay-as-you-go
- Every metered quantity can carry a monthly quota (your plan's, or one set for you specifically).
GET /usage/quotas(API reference) reports quota, used, remaining and percent for each. - Quotas are soft. Work already under way when a quota is reached is allowed to finish and is charged normally. Media hours, document MB, searches (dashboard searches included) and answers stop accepting new work at 100% unless your account is on pay-as-you-go, in which case work above quota continues and is billed at your list price. Content kept searchable is only ever reported, never enforced.
- Alerts fire once per threshold per month (your account's thresholds, typically 80% and 100%)
and reach you in the dashboard, on
/usage/quotas, and by e-mail to your organization's contact address.
Every search request counts once
- Every search request counts as one search (or one dashboard search, from the dashboard channel), including a repeat of a query you have run before, and including one that returns nothing because the library had no good answer. The unit is the question asked, not the number of results returned.
/relatedcounts as a search too. A written answer is one search plus one answer; with no passage confident enough to answer from, it is a search only.- Paging is not free. Asking for the next page of results is another search request and is
counted as one. If you page heavily, consider requesting a larger
Limitonce instead. - A search that fails before it reaches us is not counted — an authentication failure or an invalid request never becomes a search.
- A search against a library with nothing indexed yet is a different case: it succeeds, returns no results, and is still not charged. It reaches us and gets a normal response — it just has nothing to search.
Reconcile against your own records using SearchId, which we can trace to the exact request.
Sub-organizations
Your account gets one statement, one allowance and one monthly minimum, applied to the whole
account — your organization plus every sub-organization. /usage called with your own key returns
every line for the whole account, plus a per-organization breakdown of quantities and list amounts.
Per-organization amounts do not add up to the account total, because the included quantities, any
tiers and the minimum apply once, to the account; the quantities do add up exactly. Per-organization
amounts are informational: each is also rounded to the cent on its own, so it can differ from
the account's lines by rounding. The account's lines and total are authoritative — they are
what you are billed.
A sub-organization's own key sees only its own lines, priced at your list price with no minimum of
its own — it is billed on the parent account. Each line carries the sub-organization's own
billable quantity, the same before and after the month is closed. The dashboard searches line is
the exception: the allowance is shared by the whole account, so a sub-organization sees how many
dashboard searches it made (quantity), with included, billable and listAmount null —
which of the account's searches were the free ones is decided on the account's line.
The same holds for statements: a sub-organization lists and reads only its own statement lines,
with its own list amount as subtotal in the list, and never the account's total, another
organization's lines, or the statement's checksum. Its CSV export has its own lines only.
Reading it from .NET
var september = await api.GetUsageAsync(org, 2026, 9);
foreach (var line in september.Lines)
Console.WriteLine(quot;{line.Label}: {line.Quantity} {line.Unit} = {line.Amount} {september.Currency}");
var statements = await api.ListStatementsAsync(org);
var august = await api.GetStatementAsync(org, 2026, 8); // JSON, as stored at close
string csv = await api.ExportStatementCsvAsync(org, 2026, 8); // the same statement as CSV text
var quotas = await api.GetUsageQuotasAsync(org); // includes DashboardSearches
var perFile = await api.GetUsageByAssetAsync(org); // each file with its OrganizationId
19Choosing an integration
Four shapes, in order of how much you keep. Each is complete on its own — you are not expected to progress through them.
A. Our components, your application
You want search working this week.
Install both packages, register the backend, drop the components into a page. Upload, search, results and playback are all provided, themed with your CSS variables.
builder.Services.AddPlycdnContentSearch(builder.Configuration);
<plycdn-content-upload></plycdn-content-upload>
<plycdn-content-search></plycdn-content-search>
You provide: configuration and storage. You get: the whole experience.
B. Your interface, our upload path
You have a design system, or a library view of your own.
Keep the backend registration and build the interface yourself against the endpoints the package adds. Your page calls your own server — same origin, no cross-origin configuration — and the file goes from the browser to your storage without passing through your application.
POST /api/plycdn-content/v1/uploads → { sessionToken, uploadUrl, blockSize }
PUT {uploadUrl}&comp=block&blockid=… → repeat per chunk
PUT {uploadUrl}&comp=blocklist
POST /api/plycdn-content/v1/uploads/complete → { assetId, jobId, revision }
GET /api/plycdn-content/v1/jobs/{jobId} → progress
Note the network requirement. The browser must be able to reach your storage host directly.
If your Content Security Policy forbids that, either allow it in connect-src or use the relay
endpoints, which carry the bytes through your own backend instead. Our components fall back
automatically; an interface of your own has to choose. See Network requirements.
You provide: the interface. You get: upload sessions, resumable transfer, registration, progress.
C. Your upload, our indexing
You already have upload working and do not want to change it.
Your backend receives the file, writes it to storage as it does today, then tells us it is there.
You get assetId and jobId back immediately and drive every part of the experience yourself.
var source = await storage.CompleteUploadAsync(ticket, ct);
var access = await storage.ResolveReadAsync(organizationId, source.ExternalId, source.SourceVersion, ct);
var registration = await api.RegisterAssetAsync(organizationId, source, access, ct: ct);
return Accepted(new { registration.AssetId, registration.JobId });
If your files already live under a naming scheme of your own, implement ICustomerFileStorage
against it — see Telling us where your files are. That is the supported way to keep your existing
layout, and it is what makes access renewal, playback and reindexing work for files you placed
yourself.
⚠️ Keep
RegisterAssetAsyncon a server-only path. It names the location we will fetch, so an endpoint that forwarded a body from the browser to it would let any signed-in user have content fetched from a URL your storage never issued. The browser-driven path in B avoids this by deriving the location from a protected upload session instead.
You provide: upload and interface. You get: indexing, search, playback links.
D. Search only
Your content is already in storage and you never upload through us.
Register what exists — in bulk if you like — and use search alone.
foreach (var file in existingLibrary)
{
var access = await storage.ResolveReadAsync(organizationId, file.ExternalId, file.Version, ct);
await api.RegisterAssetAsync(organizationId,
new SourceReference(file.ExternalId, file.Url, file.Version, file.Name, file.Kind),
access, ct: ct);
}
You provide: everything except retrieval. You get: multilingual search over it.
20Worked example: keeping your own upload
The most common request, complete. Your application already uploads files to your own storage; you want search over them without rewriting that. Nothing below is abbreviated — every type, every injected service and every namespace is shown.
Everything from this package lives in one namespace:
using Plycdn.ContentSearch;
Step 1 — Install
dotnet add package Plycdn.ContentSearch.AspNetCore --version 1.12.0 --source YOUR_PACKAGE_FEED_OR_FOLDER
To install from the supplied .nupkg file, put it in a vendor/nuget folder in your solution and, from the
solution directory:
dotnet new nugetconfig
dotnet nuget add source ./vendor/nuget --name PlycdnLocal --configfile ./NuGet.config
dotnet add path/to/YourBackend.csproj package Plycdn.ContentSearch.AspNetCore --version 1.12.0
dotnet new nugetconfig creates NuGet.config beside the solution first (it keeps nuget.org listed; if you
already have one there, skip this step). --configfile matters: without it the source is written to your
user-level NuGet configuration as a relative path, which then applies to every project on that machine and
breaks any build started from a directory where vendor/nuget does not exist. Keeping it in a NuGet.config
beside the solution also means it is committed, so CI and your colleagues need no setup. Keep nuget.org enabled
for the Azure SDK dependencies. Commit the package and your repository's NuGet source configuration, or upload the
package to your private NuGet feed for CI.
Step 2 — Configuration
appsettings.json. Keep ApiKey and CallbackSecret in user secrets or your secret store, never in
source control:
{
"Plycdn": {
"ApiBaseUrl": "https://api.plycdn.com/",
"ApiKey": "<issued to you>",
"CallbackKey": "<issued to you>",
"CallbackSecret": "<at least 32 characters, chosen by you>",
"OrganizationClaimType": "plycdn_organization_id",
"Azure": {
"ConnectionString": "<your Azure Blob storage connection string>",
"Container": "content"
}
}
}
AddPlycdnContentSearch(IConfiguration) sets up your own Azure Blob storage, so it requires
Plycdn:Azure:ConnectionString (and optionally Plycdn:Azure:Container, which defaults to
content) and refuses to start without it. That same storage also holds the callback's replay
record (content-callback-nonces/, see "Source access callback"), which the callback needs.
CallbackSecret is yours to choose; set the same value in the plycdn dashboard under
Content search → Indexing settings. It signs the requests we make back to you when access to a file
needs renewing.
Step 3 — Register the services
Program.cs:
using Plycdn.ContentSearch;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddPlycdnContentSearch(builder.Configuration); // reads the "Plycdn" section
builder.Services.AddSingleton<ICustomerFileStorage, CourseLibraryStorage>(); // Step 4
builder.Services.AddScoped<IOrganizationContextResolver, OrganizationResolver>(); // Step 5
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
AddPlycdnContentSearch registers everything else, including PlycdnContentApi, which you
inject in Step 6. Your own ICustomerFileStorage registration comes after it and replaces the
package's Azure implementation of that one interface; the Azure storage stays in place as the
callback's replay store.
If you do not use Azure Blob, use the other overload and leave out the Azure section. It takes
the same settings in code, registers no storage, and so you register both interfaces yourself: your
ICustomerFileStorage (Step 4) and an ICallbackReplayStore, which must be shared by every instance
of your backend and remember each nonce it accepts (TryUseAsync(nonce, expiresAt, ct) returns false
for one it has seen):
builder.Services.AddPlycdnContentSearch(options =>
{
options.ApiKey = builder.Configuration["Plycdn:ApiKey"]!;
options.CallbackKey = builder.Configuration["Plycdn:CallbackKey"]!;
options.CallbackSecret = builder.Configuration["Plycdn:CallbackSecret"]!;
});
builder.Services.AddScoped<IOrganizationContextResolver, OrganizationResolver>();
builder.Services.AddSingleton<ICustomerFileStorage, CourseLibraryStorage>();
builder.Services.AddSingleton<ICallbackReplayStore, YourReplayStore>();
The options overload registers a resolver that refuses every request until you register your own
IOrganizationContextResolver (Step 5), or ClaimsOrganizationContextResolver, which reads the
claim named by OrganizationClaimType.
If your storage is Azure Blob and you are happy for files to live under a path we choose, call
builder.Services.AddPlycdnAzureBlobStorage(connectionString, "content")instead of Step 4 and skip it entirely. Step 4 exists for keeping your layout.
Step 4 — Tell us how to reach your files
One class implementing three methods. This example uses Azure Blob Storage with the customer's own naming; adapt the body to whatever you use.
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Azure.Storage.Sas;
using Plycdn.ContentSearch;
public sealed class CourseLibraryStorage : ICustomerFileStorage
{
private readonly BlobContainerClient container;
public CourseLibraryStorage(IConfiguration configuration)
{
container = new BlobContainerClient(
configuration["Storage:ConnectionString"], configuration["Storage:Container"]);
}
// Read access to a file we have already registered. This is the one that matters for a
// server-side upload, and it is called again later: when access expires while a long
// recording is still being indexed, when a viewer opens a result, and when you re-index.
public async Task<SourceAccess> ResolveReadAsync(Guid organizationId, string externalId,
string sourceVersion, CancellationToken ct)
{
var blob = container.GetBlobClient(externalId);
var properties = (await blob.GetPropertiesAsync(cancellationToken: ct)).Value;
// Refuse if the file changed since it was registered, or belongs to another organization.
if (properties.ETag.ToString() != sourceVersion)
throw new UnauthorizedAccessException("The file changed since it was registered");
if (!properties.Metadata.TryGetValue("organization", out var owner) ||
owner != organizationId.ToString())
throw new UnauthorizedAccessException("That file belongs to another organization");
var expiry = DateTimeOffset.UtcNow.AddHours(2);
return new SourceAccess(Sign(blob, BlobSasPermissions.Read, expiry), expiry);
}
// Only used by the browser-driven upload endpoints. Implement them if you use those as well;
// throw if you never will, so a wrong call fails loudly rather than half-working.
public Task<SourceAccess> CreateUploadAsync(UploadTicket ticket, CancellationToken ct) =>
throw new NotSupportedException("This application uploads server-side only");
public Task<SourceReference> CompleteUploadAsync(UploadTicket ticket, CancellationToken ct) =>
throw new NotSupportedException("This application uploads server-side only");
private string Sign(BlobClient blob, BlobSasPermissions permissions, DateTimeOffset expiry)
{
if (!blob.CanGenerateSasUri)
throw new InvalidOperationException("Use a storage credential that can sign SAS URLs");
var builder = new BlobSasBuilder
{
BlobContainerName = container.Name,
BlobName = blob.Name,
Resource = "b",
StartsOn = DateTimeOffset.UtcNow.AddMinutes(-2),
ExpiresOn = expiry,
Protocol = SasProtocol.Https
};
builder.SetPermissions(permissions);
return blob.GenerateSasUri(builder).ToString();
}
}
Three rules, because each one fails hours later rather than at upload:
ResolveReadAsyncmust work for any file you have registered, not only recent ones.sourceVersionmust change when the file changes. An entity tag or content hash.- Every method must be scoped to the organization — the last place to confirm that this organization may read this file.
Note the organization metadata written in Step 6 is what makes rule 3 enforceable. If your files
already carry an organization identifier some other way, check that instead.
Step 5 — Tell us which organization a request belongs to
using System.Security.Claims;
using Plycdn.ContentSearch;
public sealed class OrganizationResolver : IOrganizationContextResolver
{
public Task<Guid?> ResolveAsync(ClaimsPrincipal user, CancellationToken ct)
{
// However your application already identifies the organization of the signed-in user.
var claim = user.FindFirst("organization_id")?.Value;
return Task.FromResult(Guid.TryParse(claim, out var id) ? id : (Guid?)null);
}
}
If your users already carry a claim holding the plycdn organization id, you can skip this class
and set OrganizationClaimType in configuration to its name instead.
Step 6 — Register the file after your own upload
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Plycdn.ContentSearch;
[ApiController]
[Route("api/courses")]
[Authorize]
public sealed class CourseMaterialsController : ControllerBase
{
private readonly PlycdnContentApi content; // from AddPlycdnContentSearch
private readonly ICustomerFileStorage storage; // your CourseLibraryStorage
private readonly BlobContainerClient container; // your own storage client
private readonly IOrganizationContextResolver organizations;
public CourseMaterialsController(PlycdnContentApi content, ICustomerFileStorage storage,
IConfiguration configuration, IOrganizationContextResolver organizations)
{
this.content = content;
this.storage = storage;
this.organizations = organizations;
container = new BlobContainerClient(
configuration["Storage:ConnectionString"], configuration["Storage:Container"]);
}
[HttpPost("{courseId}/materials")]
public async Task<IActionResult> AddMaterial(string courseId, IFormFile file, CancellationToken ct)
{
var organizationId = await organizations.ResolveAsync(User, ct)
?? throw new UnauthorizedAccessException("No organization for this user");
// 1. Your own upload, with your own naming. The organization metadata is what lets
// ResolveReadAsync prove ownership later.
var key = quot;courses/{courseId}/{Guid.NewGuid()}{Path.GetExtension(file.FileName)}";
var blob = container.GetBlobClient(key);
await using (var incoming = file.OpenReadStream())
{
await blob.UploadAsync(incoming, new BlobUploadOptions
{
Metadata = new Dictionary<string, string>
{
["organization"] = organizationId.ToString()
}
}, ct);
}
// 2. What we need to fetch it: where it is, which version, and read access.
var etag = (await blob.GetPropertiesAsync(cancellationToken: ct)).Value.ETag.ToString();
var source = new SourceReference(key, blob.Uri.ToString(), etag, file.FileName, KindOf(file.FileName));
var access = await storage.ResolveReadAsync(organizationId, key, etag, ct);
// 3. Register. Returns immediately; indexing continues in the background.
var registration = await content.RegisterAssetAsync(organizationId, source, access, ct: ct);
return Accepted(new { registration.AssetId, registration.JobId });
}
private static string KindOf(string filename) => Path.GetExtension(filename).ToLowerInvariant() switch
{
".mp4" or ".mov" or ".mkv" or ".webm" or ".avi" => "video",
".mp3" or ".wav" or ".flac" or ".m4a" or ".ogg" or ".aac" => "audio",
_ => "document"
};
}
RegisterAssetAsync returns as soon as the file is accepted. AssetId identifies the file from now
on; JobId identifies this indexing run. Store both against your own course record.
⚠️ Keep this endpoint server-side. It decides which location we fetch. An endpoint that accepted a storage URL from the browser and passed it through would let any signed-in user have content fetched from a URL your storage never issued.
Step 7 — Follow progress
[HttpGet("materials/{jobId:guid}/status")]
public async Task<IActionResult> Status(Guid jobId, CancellationToken ct)
{
var organizationId = await organizations.ResolveAsync(User, ct)
?? throw new UnauthorizedAccessException("No organization for this user");
var job = await content.GetJobAsync(organizationId, jobId, ct);
return Ok(new
{
job.State, // queued, extracting, transcribing, embedding, ready, failed, … (more may be added)
job.Stage,
job.Progress, // 0-100, for showing movement rather than arithmetic
job.ErrorCode // null while healthy
});
}
Poll every few seconds while indexing. A long recording takes minutes and Stage advances through
it, so a progress bar shows real movement. Treat an unrecognised State as still in progress —
new states may be added.
For a list view, call GetActivityAsync once rather than asking about each file separately.
Step 8 — Search
[HttpGet("{courseId}/search")]
public async Task<IActionResult> Search(string courseId, [FromQuery] string q, CancellationToken ct)
{
var organizationId = await organizations.ResolveAsync(User, ct)
?? throw new UnauthorizedAccessException("No organization for this user");
var results = await content.SearchAsync(organizationId,
new ContentSearchQuery { Query = q, Limit = 10 }, ct);
if (results.Items.Count == 0 && results.WithheldAsWeak > 0)
return Ok(new { message = "Nothing here answers that closely enough." });
return Ok(results.Items.Select(item => new
{
item.AssetId,
item.Filename,
item.Snippet,
item.Relevance, // 0-1, comparable between queries
item.Confidence, // high | moderate | low
item.MatchType, // keyword | semantic | both
StartSeconds = item.Location.StartSeconds, // media: where to seek
Page = item.Location.Page // documents: which page
}));
}
SearchAsync searches everything the organization holds. To scope to one course, keep your own
mapping from AssetId to course and filter the results.
What is injected, and where each comes from
| Service | Registered by | What it is |
|---|---|---|
PlycdnContentApi |
AddPlycdnContentSearch |
Every call to us: search, register, jobs, activity, delete. |
ICustomerFileStorage |
you, in Step 4 | How we reach your files, now and later. |
IOrganizationContextResolver |
you, in Step 5 | Which organization a signed-in user belongs to. |
PlycdnContentClient |
AddPlycdnContentSearch |
The lower-level HTTP client. You will not normally need it. |
21Troubleshooting
| Symptom | Check |
|---|---|
| Anything at all, first | GET /api/plycdn-content/v1/diagnostics as a manage user. It names the failing piece. |
| Backend fails at startup | Read the named setting in the error; check server secrets, HTTPS API address and storage configuration. Do not send secrets in support screenshots. |
| Customer route returns 404 | Confirm AddPlycdnContentSearch and MapControllers; verify the frontend endpoint/proxy. |
| 401 without a plycdn error code | Check the user's customer login/token/cookie. |
500 with No authenticationScheme was specified |
The host has no authentication registered. The package's routes require your login; add your existing AddAuthentication(...) and app.UseAuthentication(). It does not bring its own. |
invalid_api_key or api_key_required |
Check the backend plycdn key, expiry/revocation and the API base address, and that the key travels in the Authorization: ApiKey header (a key in a URL or a request body is refused). This is not fixed by placing a key in Angular. |
organization_forbidden / 403 |
Check trusted organization mapping, plycdn organization access and your read/manage policies. |
| Azure upload blocked by browser | Check Blob CORS against the exact frontend scheme/host/port and the allowed headers. |
invalid_upload_session or renewal denied |
Check shared Data Protection keys, session expiry and whether the user/organization changed. Start a new session when necessary. |
| Waiting for source access | Verify callback onboarding, callback reachability and storage permissions; use the renewal action where shown. |
content_timeout, content_unavailable, or invalid_content_response |
Check backend connectivity and configured plycdn API origin; retry transient failures. |
| File uploaded but search is empty | Wait for indexing, then open Library in the dashboard and read the item's processing state. |
source_host_not_allowed |
Your storage host is not registered for this organization. Add it under Content search → Indexing settings, then re-upload. |
source_host_outside_ceiling when saving settings |
plycdn does not permit that host. Contact your plycdn representative; an organization cannot widen this itself. |
source_access_expired |
The upload link expired before processing finished. Supply fresh access, or configure a callback URL and secret so links renew automatically. |
source_version_changed |
The stored file changed after it was registered. Register the new version. |
organization_daily_limit |
A protective daily limit on processing was reached; work resumes tomorrow. Contact us if you expect this volume. |
invalid_file |
The file is larger than MaxUploadBytes (20 GB by default), has an unknown kind, or its filename contains a path. |
invalid_block, length_required, invalid_request |
A relayed upload block or request body was malformed. A component never sends these; check an interface of your own. |
storage_unavailable, storage_operation_incomplete |
Your storage could not be reached, or an upload did not finish transferring. Retry; a component resumes. |
callback_not_configured |
No callback URL is configured for this organization. Every asset is registered with a callback key, so this blocks the first upload until it is set under Content search → Indexing settings. |
When contacting support, provide the operation, time, error code, asset/job identifier and framework versions. Do not include API keys, storage connection strings, user tokens or signed upload/playback URLs.
22Appendix: the source-access callback contract
The .NET package implements this endpoint and validates it for you. You need this section only to log or debug what arrives, to satisfy a security review, or to implement the endpoint yourself on a stack other than ASP.NET Core.
What plycdn sends
POST /api/plycdn-content/v1/source-callback
Content-Type: application/json
X-Content-Timestamp: 1789458231
X-Content-Nonce: 9f4c1ab27e0d4358bd61f0a2c7e39d84
X-Content-Signature: 4m0Xx1x0Zr0h8Yw1pA2Qq7c...
{"organizationId":"...","externalId":"...","sourceVersion":"...","sourceUrl":"https://..."}
The body is compact JSON with no whitespace, UTF-8, never larger than 16 KB. The signature is
base64(HMAC-SHA256(secret, "<timestamp>\n<nonce>\n<body>")) over the exact bytes received -
verify against the raw body, never against a re-serialized copy of the parsed object, or a
different key order will fail a request that was perfectly valid.
What you return
{
"url": "https://yourcompany.blob.core.windows.net/content/<blob>?<sas>",
"expiresAt": "2026-09-13T20:04:00Z"
}
url must point at the same object that was registered: everything before the query string has to
match that asset's sourceUrl exactly, or the response is rejected as source_access_mismatch.
HTTPS only, port 443, no credentials embedded in the URL. If you would rather keep the address and
the credential separate, return an unsigned url and the token as sasToken - send one or the
other, never a query string on url and a sasToken.
Any status other than 200, or an answer larger than 64 KB, is reported as source_callback_refused,
and the job is retried with backoff: an endpoint that is down comes back, and the next attempt asks it
again.
What the package enforces on your behalf
| Check | Behaviour |
|---|---|
| Timestamp skew | Rejected beyond 300 seconds either way |
| Signature | Constant-time comparison; mismatch returns 401 |
| Replay | Each nonce is accepted once, then remembered for 10 minutes |
| Body size | Capped at 16 KB |
| Version | The blob's current ETag must still equal the requested sourceVersion, and the blob's organization metadata must match the calling organization; otherwise 403 |
That last check is what stops a valid, correctly signed callback from handing out access to a file that has since been replaced, or to another organization's content.
The nonce store is the replay defence. Keep it, and keep its lifecycle rule - see "Storage, authentication and hosting" for cleaning up
content-callback-nonces/ after one day. Deleting the store entirely
makes every captured callback replayable for its 300-second window.