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.

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.

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.

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": true so 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 as blobService (the account endpoint) plus container; 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.
  • schedule is manual or nightly. 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 the Microsoft.EventGrid resource 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 — moved in 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 under sourceMissing in library health until it comes back, rather than being returned with a link that cannot open.
  • defaultAclMode decides what synced files start as. Canvas content defaults to restricted, 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 /usage carries a processing section — 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 no ocr_frames entry. Called for your account it lists every file in the account, each with its organizationId; 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:

  • PUT replaces 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.
  • callbackSecret is 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 - only callbackSecretSet tells you whether one exists.
  • "sourceHosts": [] means inherit, not "allow nothing". To return an organization to its parent's settings use DELETE /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:

  1. 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.
  2. That URL and the same signing secret are set in the plycdn dashboard, under Content search → Indexing settings. The secret there must match Plycdn:CallbackSecret in your backend exactly, or every callback is rejected. Miss the second and registration is refused with callback_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-results uses 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.

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:

  1. 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.
  2. 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.
  3. If you must use a wildcard, confine it to media-src and frame-src. Those only load content and cannot send anything out. Keep connect-src narrow, 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
PDF 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:

  • ContentLocation on ContentSearchResult.Location - where the passage sits. Seek video to PlayFromSeconds; open a PDF at Page; highlight Row and Sheet for a spreadsheet, LineStart for text, Block for Word. Which fields are set depends on the kind, so read the ones you need and ignore the rest.
  • ContentSourceReference on ContentSearchResult.SourceReference - ExternalId, SourceUrl and SourceVersion, your own identifiers for the matched file.
  • ContentFormat on ContentCapabilities.Formats - one family of file types, its extensions, and what a block of it represents.
  • ContentResponse - the base of all of these, carrying Additional.

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.

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 RegisterAssetAsync on 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.

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 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, moderate or low. Results weaker than low are not returned at all.
  • MatchType — keyword when the passage contains the words searched for, semantic when it matched by meaning without sharing them, both when 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:

  • ResolveReadAsync must 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.
  • sourceVersion must 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

  1. 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.
  2. Sign in as a user with the correct organization and read/manage permissions.
  3. Upload a short TXT file. Wait for indexing to finish, then search a phrase from it.
  4. Upload a short Hindi/English video. Search a phrase from it and confirm playback near the result timestamp.
  5. Test a PDF, DOCX and XLSX; confirm the page, heading and sheet/row information on results.
  6. Interrupt an upload and retry with the same file. Confirm that indexing starts only after completion.
  7. Test a read-only user: search works, upload/management is denied.
  8. Test a different organization: files are not shared across organizations.
  9. 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.
  10. Open a result after an earlier playback link has expired: opening it again obtains fresh access.
  11. 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 sourceVersion is 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 contentSha256 matches 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.
  • /related counts 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 Limit once 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 RegisterAssetAsync on 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:

  1. ResolveReadAsync must work for any file you have registered, not only recent ones.
  2. sourceVersion must change when the file changes. An entity tag or content hash.
  3. 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.

[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.