plycdn Content Search — HTTP API guide

Audience: developers integrating without our SDKs, or extending an SDK integration with calls the packaged components do not make.

You do not need this guide to use plycdn Content Search. The Angular and ASP.NET Core packages cover the whole path — upload, indexing, search, playback — and the Integration guide is the shorter road. Use this one when you are building your own client, integrating from a language we do not ship a package for, or automating configuration.

Everything here is called from your backend. The API key authenticates your server to plycdn and must never reach a browser.

1Before starting

Item Where it comes from
API key Created in the dashboard under Developers → API keys; see Getting and rotating API keys
Organization UUIDs Your account's id and each sub-organization's, shown in the dashboard under Developers → API keys and Account → Sub-organizations, or read with GET /api/sub-organizations
Your storage Yours — files stay in your own account

Base address: https://api.plycdn.com/api/content/v1

2Authentication

Every request carries two headers:

Authorization: ApiKey YOUR_KEY
X-Organization-Id: 00000000-0000-0000-0000-000000000000

The key identifies your account. X-Organization-Id selects which of your organizations the request applies to — your own, or one of its sub-organizations. Content is isolated per organization: a search in one never returns content from another, and an asset registered under one is invisible to the rest.

A request without a key in the Authorization header returns 401 api_key_required; a key that is not valid (unknown, revoked or expired) returns 401 invalid_api_key. A missing or malformed X-Organization-Id returns 400 organization_required. An organization your key does not cover returns 403 organization_forbidden. A key is not a password for a user; authorize your own users yourself before calling on their behalf.

Every key belongs to your account and reaches the account's own organization and every one of its active sub-organizations. There is no key per sub-organization: X-Organization-Id is what chooses. A sub-organization that has been deactivated is no longer covered: content calls that name it are refused with 403 organization_forbidden until it is reactivated (GET /api/sub-organizations still lists it).

The Authorization: ApiKey header is the only place a key is read. A key anywhere else is never read, checked or accepted:

  • In the query string (?apiKey=, in any letter case or escaping): the request is answered 401 api_key_required on every route, before anything else is looked at, even with a valid header beside it.
  • In a JSON request body (a top-level apiKey field, including the older form that sent it with organizationId): the request is answered 401 api_key_required once its body is read, unless an earlier check answers first (a missing or invalid header key, organization_forbidden, admin_key_required and the like). Remove the field and send the header.

An address ends up in proxy and browser logs, and a body may be logged or passed on, which is why a key is accepted only in the header. (?apiKey= was deprecated in 1.7.0 and has since been removed, including from the dashboard's content routes.)

3Getting and rotating API keys

Keys are created in the dashboard under Developers → API keys. A key is shown once, when it is created: copy it into your server's secret store then. It cannot be read again, by you or by us.

Scope What it can do Where it belongs
standard Everything under /api/content/v1: registering, searching, settings, usage, and storage files, uploads, signed links, cache purges, delivery analytics, videos, storage usage and the storage audit. The default. The service that handles your users' requests
admin All of that; managing sub-organizations and reading the account audit from code (next section); and the storage operations that create, change or remove a bucket, a video library, a custom domain, a webhook endpoint or the storage account, or rotate a signing key or webhook secret (below) Only the automation that sets accounts and buckets up. Created only in the dashboard

Storage operations that need an admin key. A standard key is refused with admin_key_required (403) on these, and only these; everything else in storage, including GET /bucket-names, works with either key:

POST   /api/content/v1/buckets                            # create a bucket
PATCH  /api/content/v1/buckets/{id}                       # change its settings
DELETE /api/content/v1/buckets/{id}                       # delete it
POST   /api/content/v1/buckets/{id}/restore               # restore it
POST   /api/content/v1/buckets/{id}/retry                 # retry a failed creation
POST   /api/content/v1/buckets/{id}/signing-key/rotate    # replace its signing key
POST   /api/content/v1/domains                            # add a custom domain to a bucket
DELETE /api/content/v1/domains/{id}                       # remove a custom domain
POST   /api/content/v1/libraries                          # create a video library
PATCH  /api/content/v1/libraries/{id}                     # change its settings
DELETE /api/content/v1/libraries/{id}                     # delete it
POST   /api/content/v1/videos/{id}/reencode               # encode a ready video again
POST   /api/content/v1/webhooks                           # create a webhook endpoint
PATCH  /api/content/v1/webhooks/{id}                      # change it
DELETE /api/content/v1/webhooks/{id}                      # delete it
POST   /api/content/v1/webhooks/{id}/secret/rotate        # replace its signing secret
POST   /api/content/v1/storage-credentials                # storage credentials: made in the dashboard only
POST   /api/content/v1/storage-credentials/{id}/rotate    #   (a standard key is refused with admin_key_required,
DELETE /api/content/v1/storage-credentials/{id}           #   an admin key with dashboard_session_required)
POST   /api/content/v1/storage-account                    # create the storage account
PATCH  /api/content/v1/storage-account                    # change its home region

In the dashboard every member of your organization can make these changes.

Keys are credentials, so they are managed by people, in the dashboard only: creating, rotating, deactivating and reactivating keys has no API. The key routes refuse every API key, admin keys included, with dashboard_session_required.

At most two keys are active at once, so a key can be replaced without downtime:

  1. Choose Rotate on the key in use in the dashboard. A new key of the same scope is created and shown once. The old key keeps working.
  2. Deploy the new key to your backend.
  3. When the old key's Last used stops moving, deactivate it in the dashboard.

A third active key is refused with api_key_limit_reached: deactivate one first. An expired key stops working at its expiry and no longer counts towards the two. More than 10 keys created or rotated by your account in an hour is refused with rate_limited.

4Managing sub-organizations from code

These routes sit beside the content API, at https://api.plycdn.com/api, and need an admin key. X-Organization-Id must be your account's own id: sub-organizations belong to the account, not to one of its sub-organizations. A standard key is refused with admin_key_required; any other organization id with organization_forbidden.

GET    /api/sub-organizations                 # every sub-organization, active or not
POST   /api/sub-organizations                 # { "name", "contact_email", "description",
                                              #   "metadata" }
GET    /api/sub-organizations/{id}
PUT    /api/sub-organizations/{id}            # only the fields you send change;
                                              #   "is_active": true reactivates
DELETE /api/sub-organizations/{id}            # deactivate; nothing is deleted

GET    /api/account-audit?limit=50&before=41  # who changed which key or sub-organization

More than 60 sub-organizations created by your account in an hour is refused with rate_limited.

GET /api/sub-organizations answers a bare JSON array. The other routes answer one object with id, parent_organization_id, name, contact_email, description, is_active, metadata, created_at and updated_at. POST answers 201; DELETE answers {"success": true, "message": "…"}, and deleting one that is already deactivated changes nothing. A PUT that sends none of the editable fields is 422 validation_error. An unknown id, or one that belongs to another account, is 404 sub_organization_not_found; an id that is not a UUID is 422 validation_error, fields [["path","id"]].

A sub-organization's metadata is your own labels for it: an object of at most 50 keys, each 1 to 128 characters, whose values are strings of up to 512 characters, numbers or booleans, and at most 16 KiB as JSON. Send null to remove it. It is returned as you wrote it. The other fields: name is 1 to 255 characters, contact_email an e-mail address or null, and description up to 2,000 characters or null. Anything else is refused with 422 validation_error, whose fields names the first field at fault as a location path, for example [["body","metadata"]]. The older field (that field's name) is still sent so integrations written against earlier releases keep working: read fields.

Every change to a key (in the dashboard) or a sub-organization (in the dashboard or from code) is recorded in the account audit, naming the person (by e-mail) or the key (by its name):

{ "items": [ { "id": "…", "at": "2026-09-26T10:00:00Z",
               "actor": { "kind": "api_key", "id": "…", "label": "provisioner", "prefix": "…" },
               "action": "sub_organization.updated",
               "resourceKind": "sub_organization", "resourceId": "…",
               "detail": { "fields": ["metadata"] } } ],
  "next": "41" }

Pass next as before for the next page. limit is 1 to 100 and 50 when omitted (a larger value is lowered to 100, not refused); before must be the next of the previous page, digits only, otherwise 422 validation_error, fields [["query","before"]]. An actor of kind api_key also carries the key's prefix, so a key can be told apart from another with the same name. detail names what changed — the fields an edit touched, a new sub-organization's name, or a key's name, prefix, scope and expiry — and never holds a key or what a field was changed to. Actions: api_key.created, api_key.rotated, api_key.deactivated, api_key.reactivated, sub_organization.created, sub_organization.updated, sub_organization.deactivated, sub_organization.reactivated, and dashboard.search_as.

dashboard.search_as is a person in the dashboard searching as someone else — sending principals, to check what a restricted reader would see. Its resourceKind is organization, resourceId the organization searched, and detail { "route": "search", "principals": 2 }: the route (search or answer) and how many principals, never who they were and never the query. If the row cannot be written, the search is not run and the dashboard answers 503 audit_unavailable.

People, invitations and your organization's profile are managed in the dashboard only: every API key is refused there with dashboard_session_required.

5What can be indexed

Kind Formats Indexed as
video .mp4 .mov .mkv .webm .avi Transcribed audio, with timestamps
audio .mp3 .wav .flac .m4a .ogg .aac Transcribed, with timestamps
document .pdf One block per page
document .docx One block per paragraph or table cell, with the nearest heading
document .xlsx One block per row, with sheet name and row number
document .csv One block per record
document .txt .md One block per line
document .html .htm .xhtml One block per paragraph or heading
document .zip A SCORM package — its pages, slides, lessons and the documents inside it

Scanned PDFs with no text layer fail with ocr_required. Plain text, Markdown and CSV files must be UTF-8; one in another encoding fails with text_encoding_must_be_utf8 (save it again as UTF-8). Legacy binary Office formats (.doc, .xls, .ppt) are not supported. Spreadsheet formulas index their computed value, not the formula text.

Read GET /capabilities rather than hard-coding this table — it is what your plycdn service actually accepts, and it grows without an SDK release.

SCORM packages (1.2 and 2004, any edition) are read through their imsmanifest.xml, so a result names the page or module the author titled rather than sco_04.html. Every common shape is read:

  • one page per item, in the author's order;
  • one launch page that navigates between lesson pages - the pages it links to and the files the manifest lists are read in the order the launch page names them, and its own Previous / Next / Exit controls are not indexed;
  • a slide player whose course is in a data file (JSON, or JSON assigned in a script) - each slide's text, narration script, alternative explanations and quiz questions, located by slide;
  • an Articulate Rise export - lesson by lesson, located by the lesson's title;
  • PDF and Word documents shipped inside the package, located by resource and page;
  • a package zipped inside its folder, or using xml:base paths.

When the manifest leads nowhere readable, every page in the package is read in path order. Media inside a package is not transcribed: registering a zip should never silently incur 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.

Slides and on-screen text. Where on-screen reading is enabled for your account, video is also read visually: text on a slide becomes searchable alongside what was said, with the timestamp it appeared. Those results carry "location": { "source": "slide", … } so you can label them — half of what a lecture conveys is on the slide and never spoken aloud, and a reader deserves to know which they are looking at.

6Configure your sources first

Nothing indexes until your organization declares where its files live. This is also available in the dashboard under Content search → Indexing settings.

PUT /api/content/v1/settings
Authorization: ApiKey YOUR_KEY
X-Organization-Id: <uuid>
Content-Type: application/json

{
  "sourceHosts": ["yourcompany.blob.core.windows.net"],
  "callbackUrl": "https://YOUR_BACKEND/your/callback/path",
  "callbackSecret": "<32+ random characters>",
  "dailyBudgetUsd": 50,
  "dailyAudioHours": 10
}

PUT replaces the whole row. Send the complete set every time: any field you omit reverts to inherited, so a request meaning to change one host will quietly clear your callback and limits too. Read the current values first and send them back with your edit, or change only what you mean to with PATCH:

PATCH /api/content/v1/settings
Authorization: ApiKey YOUR_KEY
X-Organization-Id: <uuid>
Content-Type: application/json

{ "sourceHosts": ["yourcompany.blob.core.windows.net", "archive.yourcompany.com"],
  "callbackUrl": null }

A field you send is set, a field sent as null is cleared and inherited again, and a field you leave out is unchanged. "callbackSecret": null removes the stored secret. The response is the same document as GET /settings.

dailyBudgetUsd is deprecated since 1.7.0: it is accepted here and ignored — never stored, never enforced — and GET /settings always returns it as 0. Send it or not, it makes no difference. What you can start is controlled by your plan's quotas; see What you have used and /usage/quotas. dailyAudioHours is unaffected and still caps how many hours of audio/video an organization can start indexing in a day (below).

Limits: callbackSecret is 32 to 512 characters, dailyAudioHours 0 to 100,000 and searchLogDays 0 to 3,650; at most 200 sourceHosts, each a bare hostname (a leading *. is allowed). requireAcl needs the permissions feature in your plan (not_included_in_plan otherwise).

callbackSecret is the one exception on PUT — omit it and the stored secret is kept, because it cannot be read back. "sourceHosts": [] means inherit, not allow nothing; use DELETE /api/content/v1/settings to return an organization to its parent's settings.

Hosts are checked for shape, not reachability. A typo is accepted here and surfaces later as source_host_not_allowed on registration, or a failed fetch — so read the response back and confirm it says what you intended.

GET /api/content/v1/settings returns the effective values: sourceHosts, callbackUrl, callbackSecretSet (whether a secret is stored; the secret itself is never returned), dailyBudgetUsd (always 0), dailyAudioHours, requireAcl, extractContentFromVideoFrames, searchLogDays (90 where none is set), allowedHostCeiling, configured (true once the organization has settings of its own) and updatedAt. An origins map, keyed by setting in snake case (source_hosts, callback_url, …), shows where each value came from: the organization itself (its UUID), owner (your account) or deployment (the plycdn default).

Video settings

videoDefaults sets how videos are encoded for this organization's libraries, unless a library has settings of its own. Its fields are ladder (any of 240, 360, 480, 720, 1080, 1440, 2160, each once), keepOriginal, download and thumbnails, each optional; null means not set here. A sub-organization uses its parent's value unless it sets its own, higher or lower: a parent's setting is a default, not a limit. Where it applies, a library with its own processing wins, then the library's organization, then its parent, then plycdn's defaults, all worked out when a video is encoded. Changing them affects videos encoded afterwards; see Encoding a video again.

PATCH /api/content/v1/settings
X-Organization-Id: <sub-organization uuid>
{ "videoDefaults": { "ladder": [360, 720, 1080, 2160], "download": true } }

On PUT an omitted videoDefaults leaves every video setting unset. On PATCH, videoDefaults changes only the fields it names, a field sent as null is unset again, and "videoDefaults": null unsets all four, so the organization uses its parent's. An invalid ladder is 422 validation_error (fields [["body","videoDefaults","ladder",0]]). GET /settings answers, besides the fields above:

{ "videoDefaults": { "ladder": [360, 720, 1080, 2160], "keepOriginal": true, "download": true, "thumbnails": true },
  "videoDefaultsOrigins": { "ladder": "organization", "keepOriginal": "parent", "download": "organization", "thumbnails": "default" },
  "videoDefaultsOwn": { "ladder": [360, 720, 1080, 2160], "keepOriginal": null, "download": true, "thumbnails": null },
  "videoPlatformDefaults": { "ladder": [240, 360, 480, 720, 1080], "keepOriginal": true, "download": false, "thumbnails": true } }

videoDefaults is what applies, videoDefaultsOrigins says where each value comes from (organization for the organization you asked about, parent, or default), videoDefaultsOwn is what this organization set itself, and videoPlatformDefaults is plycdn's own.

The callback is described under Renewing access below. It is required if you register assets with a callbackKey, and optional otherwise.

7Registering content

You upload the file to your own storage, then tell us where it is.

POST /api/content/v1/assets
Authorization: ApiKey YOUR_KEY
X-Organization-Id: <uuid>
Idempotency-Key: <your own unique value>
Content-Type: application/json

{
  "externalId": "lecture-2026-09-02",
  "filename": "Cardiology week 3.mp4",
  "kind": "video",
  "sourceUrl": "https://yourcompany.blob.core.windows.net/content/lecture.mp4",
  "sourceVersion": "\"0x8DF1151249B1D1B\"",
  "sourceAccess": { "url": "https://...mp4?<sas>" },
  "language": "multi",
  "audioTrack": 0
}
{ "assetId": "…", "jobId": "…", "revision": 1 }

Returns 202 — the work is queued, not done.

sourceUrl must be unsigned. It identifies the file; credentials travel separately in sourceAccess or come from your callback. A signed URL here is refused with source_url_must_be_unsigned.

sourceVersion must be the storage ETag, quotes included, or a sha256: digest of the content. We verify it before processing: if the file changed after registration you get source_version_changed rather than an index of something you did not register. Inventing a value like "v1" fails, because it will not match what your storage reports.

externalId is yours — your own identifier for the file. It must be unique per version within the organization; registering the same externalId and sourceVersion twice returns 409 source_version_already_registered.

Idempotency-Key makes a retry safe. Repeating a request with the same key returns the original response instead of creating a second asset.

sourceAccess supplies read access at registration. Omit it if you use a callback. Its unsigned form must equal sourceUrl, or the request is refused with source_access_mismatch.

language defaults to multi, which detects per segment and handles mixed speech. Set a specific language only when you know every recording is in it.

Sizes. externalId and sourceVersion are 1 to 512 characters, filename 1 to 255, sourceUrl at most 4,096, language at most 16, audioTrack 0 to 15. You can also send metadata, collections (at most 100) and principals (at most 5,000) here. A collections entry that does not exist is refused with 400 collection_not_found and nothing is registered. A callbackKey needs a callbackUrl in your settings: without one, 400 callback_not_configured.

contentSha256 is optional: 64 hex characters (either case; it is stored in lower case), a sha256 of the file's bytes. Send it for a large recording we read in ranges rather than downloading whole, so identical content can be recognised without a full fetch. See Duplicate content in the Integration guide — without it, that kind of file is indexed and charged normally every time, because there is nothing to compare.

Registering many at once

POST /api/content/v1/imports
{ "assets": [ { …as above… }, … ] }

Up to 100 per request, answered 202. Each item succeeds or fails independently:

{ "importId": "…", "results": [
  { "externalId": "a", "assetId": "…", "jobId": "…", "revision": 1 },
  { "externalId": "b", "code": "source_host_not_allowed" } ] }

Poll GET /imports/{importId} for the batch:

{ "importId": "…", "items": [
  { "jobId": "…", "assetId": "…", "state": "embedding", "progress": 75, "errorCode": null } ] }

8Following progress

Indexing is asynchronous. A video is transcribed and then prepared for search; a document has its text extracted and is then prepared for search. Poll rather than assume.

GET /api/content/v1/jobs/{jobId}
{ "jobId": "…", "assetId": "…", "revision": 1, "state": "transcribing",
  "stage": "transcribing", "progress": 45, "errorCode": null, "retryAt": null }
state Meaning
queued Waiting to start, or to start its next step
extracting Reading the file and taking its text out
transcribing Speech-to-text in progress
embedding Preparing the text for search
retry_scheduled A temporary failure; retryAt says when it will be attempted again
awaiting_source_access We could not read the file; supply fresh access to resume
ready Indexed and searchable
failed Stopped, and will not retry on its own
cancelled Cancelled before finishing
deleting Removing the index of a file you deleted
cleanup Clearing what a cancelled or replaced run left behind

stage is the step the job is on (extracting, transcribing, embedding, deleting or cleanup): state shows the same word while that step runs and queued while the job waits between steps. Unknown or another organization's job ids answer 404 job_not_found.

GET /assets/{assetId}/jobs lists every run for one asset, including earlier failed attempts. GET /activity returns a page of assets each with its most recent job — one call rather than one per asset, which is what you want for a dashboard.

Poll at a sensible interval; a minute is plenty. progress is a percentage within the current stage, not the whole pipeline.

9Searching

POST /api/content/v1/search
{ "query": "what does the patient take before bed", "kind": "video", "limit": 20, "offset": 0 }
{ "items": [ {
    "assetId": "…", "filename": "Cardiology week 3.mp4", "kind": "video",
    "snippet": "There is nothing like a good movie before bed…",
    "highlights": [ { "start": 36, "end": 46 } ],
    "revision": 1, "score": 0.032,
    "relevance": 0.83, "confidence": "high", "matchType": "both",
    "speaker": "instructor", "language": "en",
    "metadata": { "course": "CS101", "term": "autumn" },
    "location": { "startSeconds": 80.69, "endSeconds": 88.51,
                  "playFromSeconds": 77.69, "timingPrecision": "segment" },
    "sourceReference": { "externalId": "lecture-2026-09-02",
                         "sourceUrl": "https://…", "sourceVersion": "\"0x8DF…\"" } } ],
  "withheldAsWeak": 0, "hasMore": false, "terms": ["patient", "bed"],
  "searchId": "8f14e45f-ceea-467a-9f4b-4c2d1a9e77b3" }

Search is by meaning. A question finds the passage that answers it even when they share no words, and it spans every kind at once unless you narrow it.

query is 1 to 2,000 characters, limit 1 to 100 (20 when omitted) and offset 0 to 200. Results can be paged through the first 200 places: hasMore is false once a page reaches the 200th, and an offset above 200 is refused with 422 validation_error, fields [["body","offset"]]. Narrow the search with filter rather than paging deeper. Every result, and every citation in an answer, carries the file's own metadata object ({} when it has none), so you can label results without a second call.

speaker and language describe the passage itself. speaker is filled in where we could tell the speakers in the recording apart, language where the transcription identified one; both are null on documents and wherever we do not know. language is the language of the passage, not of your question — an English question can return a Hindi passage, and labelling it is often worth doing.

One result per file

A file that answers a query in several places - five moments in a lecture, three pages of a handbook - comes back as five or three results by default. Ask for one result per file instead:

POST /api/content/v1/search
{ "query": "internal complaints committee", "group": "file", "limit": 10 }
{ "items": [ {
    "assetId": "…", "filename": "POSH induction.mp4", "kind": "video",
    "snippet": "…the Internal Complaints Committee must be set up…",
    "highlights": [ { "start": 5, "end": 34 } ],
    "relevance": 0.86, "confidence": "high", "matchType": "both",
    "location": { "startSeconds": 128.4, "endSeconds": 141.0, "playFromSeconds": 131.2 },
    "momentCount": 3,
    "moments": [
      { "snippet": "…", "highlights": [ … ], "location": { "playFromSeconds": 42.0, … },
        "relevance": 0.61, "confidence": "moderate", "matchType": "keyword", "speaker": null, "language": "en" },
      { "snippet": "…the Internal Complaints Committee must be set up…", "location": { "playFromSeconds": 131.2, … }, … },
      { "snippet": "…", "location": { "playFromSeconds": 612.5, … }, … } ],
    "sourceReference": { … }, … } ],
  "withheldAsWeak": 0, "hasMore": true, "terms": ["internal", "complaints", "committee"], "searchId": "…" }
  • Each item is the file's best passage, with every field a result has always had, so code that reads results keeps working.
  • moments lists every matching passage in the file - up to twenty - in the order they occur: by time in a recording, by page, slide or line in a document. Offer each as a jump-to link: seek to its location.playFromSeconds, or open its location.page. momentCount is how many matched in total.
  • limit, offset and hasMore then count files, not passages, so a second page never repeats a file from the first.
  • group does not change what is found, how it is ranked or what it costs: one search either way. /answer ignores it, because an answer cites passages.

Leave group out for one result per passage, as before.

Marking the words that matched

Every result carries highlights: where the words searched for appear in its snippet, as start/end offsets (end exclusive) in UTF-16 code units - the way JavaScript and .NET index strings, so snippet.slice(start, end) and snippet[start..end] are exact. Other forms of a word count ("complaints" for "complaint"), as do your configured synonyms; words so common in your library that they distinguish nothing ("the", "for") are not marked, and adjacent marked words merge into one span so a phrase reads as a phrase. Render the snippet as text around the marks - never as HTML.

highlights is empty when a passage matched by meaning alone (matchType semantic): it answers the question without using its words. Say so rather than showing an unmarked snippet as though nothing matched - "matched by meaning" is the honest label.

terms on the response lists the words that were matched, for marking them inside a whole document when a result is opened; highlights covers only the snippet.

Two separate things narrow a search, and it is worth being clear which is which.

filter is a preference — it says which files you are interested in. Every field is optional and the ones you set combine with AND.

POST /api/content/v1/search
{ "query": "reciprocal rank fusion",
  "filter": {
    "collections": ["CS101"],
    "metadata": { "term": ["autumn", "spring"], "level": "undergraduate" },
    "createdAfter": "2026-01-01T00:00:00Z",
    "speaker": "instructor",
    "language": "hi"
  } }
Field
collections Only files in these collections, by the external id you gave each collection.
metadata Only files whose metadata matches. A single value must match exactly; an array matches any one of its values. At most 50 keys, each value a string, number or boolean or a list of at most 100 of them, and at most 64 KiB as JSON in all; beyond that the search is refused with 422 validation_error, fields: [["body","filter","metadata"]].
externalIds / assetIds Only these specific files. At most 100 of each; collections is also capped at 100.
createdAfter / createdBefore When the file was registered with us.
speaker Only passages spoken by this speaker. Passages with no known speaker never match.
language Only passages in this language.

principals is a constraint — it says who is asking. See below; it is not part of filter, because a filter is something you may drop to widen a search and this is something you must not.

How well a passage matched

Three fields describe the match, so you can present a strong result differently from a possible one.

Field
relevance 0 to 1. Comparable between queries — this is the one to show or threshold on.
confidence high, moderate or low. Anything weaker than low is not returned.
matchType keyword when the passage contains the words you searched for, semantic when it matched by meaning without sharing them, both when it did both.

matchType is worth surfacing. It is what explains to a reader why a passage that shares no words with their question is nevertheless the right answer — particularly across languages, where an English question can be answered by a Hindi passage.

score is the older ranking value. It orders results within one response and is not comparable between queries; prefer relevance for anything a person sees or a threshold acts on. It remains in the response so existing integrations keep working.

withheldAsWeak counts passages found but judged too weak to offer. An empty items with a figure here means the library was searched and had nothing good enough, which is a different thing from a library with nothing in it — and worth saying differently in your interface.

location describes where the passage sits, and its fields differ by kind. Treat it as open: read the fields you need and ignore the rest.

Kind Fields
Video, audio startSeconds, endSeconds, playFromSeconds, timingPrecision
PDF page, charStart, charEnd
Word block, heading, and row/column inside a table
Spreadsheet sheet, row
Delimited text (CSV) row
Plain text, Markdown lineStart, lineEnd, charStart, charEnd
HTML element, heading
SCORM package resource, title, heading; slide (from one) for slide-player courses, element for HTML pages, page for a PDF inside the package

Text read from a video's frames rather than its speech also carries source: "slide".

Seek to playFromSeconds, not to startSeconds. They answer different questions:

  • startSeconds and endSeconds bound the passage the snippet came from. They describe the recording, not the query, so the same passage reports the same bounds whatever was searched. Use them for display.
  • playFromSeconds is where to start playing: the moment inside that passage your query actually matched, with a short lead-in so playback does not clip the phrase. It moves with the query, and for a long passage it can be well after startSeconds.

timingPrecision is segment or word and reflects how finely the recording was timed. Where it is coarse the playback offset is estimated within the passage rather than exact, so treat it as a good place to start listening rather than a frame-accurate cue.

A snippet is the passage that matched — the unit the content was indexed as, which is a page, a paragraph, a spreadsheet row or a span of speech depending on the kind. Search returns matching passages, not whole documents or full transcripts of recordings.

A snippet is an excerpt, not the whole passage: for speech it is the sentence the query landed in widened with its neighbours, so it stays readable however the recording was chunked. Its length still varies with the content — a spreadsheet row may be a few words — and it is not capped to a display size, so lay results out to wrap or clamp rather than assuming a fixed row height. A leading or trailing … marks where an excerpt was trimmed.

searchId identifies this search. It is distinct for every request, including an identical repeat of the same query, and is the identifier we record the search under. Log it: quoting it lets us find the exact request. Every response to a request that reaches the API carries an X-Request-Id header, and if you send one (printable characters, up to 128) we echo it rather than issuing our own, so a request keeps a single identity across your infrastructure and ours. A refusal made before the request reaches the API — a rate limit, a missing or invalid key, content_service_unavailable — may not carry it.

score ranks within one response. It is not a percentage and not comparable between queries; do not show it as a confidence, and do not threshold on it.

Only the active revision of an asset is searchable. Content still indexing does not appear.

10What your plycdn service can index

GET /api/content/v1/capabilities
{ "formats": [ { "kind": "document", "extensions": [".pdf"], "describes": "One block per page" },
               { "kind": "video", "extensions": [".mp4", ".mov"], "describes": "Transcribed audio, with timestamps" } ],
  "maxFileBytes": 21474836480, "maxDocumentBytes": 268435456, "maxAudioSeconds": 43200 }

Read this rather than hardcoding a list of file types. Formats are added to the service over time, and a client that asks will accept a new one without being rebuilt. The supplied Angular package already does this, falling back to the formats it shipped with if the call fails.

11Following a library

GET /api/content/v1/activity?limit=20&offset=0
{ "items": [ { "assetId": "…", "externalId": "lecture-2", "filename": "Week 3.mp4", "kind": "video",
               "sourceUrl": "…", "sourceVersion": "…", "activeRevision": 2, "latestRevision": 3,
               "createdAt": "…", "revisionState": "transcribing",
               "connectorId": "…", "connectorName": "Lecture recordings bucket",
               "latestJob": { "jobId": "…", "state": "transcribing", "stage": "transcribing",
                              "progress": 45, "errorCode": null, "attempt": 0, "updatedAt": "…" } } ] }

Files with their most recent run, in one call. Use this for a library or progress screen rather than listing assets and then asking about each one's job, which is a request per row.

connectorId and connectorName say which connector brought a file in, or are null when it was registered directly. Show the name beside the file: "where did this come from" is the first question anybody debugging a sync asks.

Narrowing it

Parameter
connectorId Only what that connector brought in
kind video, audio or document
state A job state from the table under Following progress, matched exactly — ready, failed, awaiting_source_access, or for work in progress queued, extracting, transcribing or embedding
limit, offset Paging: limit is 1 to 100 (20 when omitted); offset starts at 0
q A fragment of the filename (at most 200 characters), not a whole word: lecture-3 finds courses/2026/lecture-3-revised.mp4
createdAfter, createdBefore ISO 8601. URL-encode them — a raw + in a query string is read as a space, and an offset like +05:30 then fails validation

Everything combines with AND. Without these, a library screen is a reverse-chronological list and nothing else — which stops being usable the moment a connector brings in a few thousand files at once, and leaves you watching a wall of filenames until something succeeds or fails.

GET /api/content/v1/activity?connectorId=…&state=failed&limit=50

A storage event reaches the library in seconds. A connector with a notifyUrl starts its sync within a few seconds of the notification rather than waiting for the next scheduled pass, so a file uploaded to your bucket appears here — with its job, its stage and its progress — while somebody is still watching the screen.

GET /api/content/v1/assets/{assetId}/jobs

Every run recorded for one file, newest first, including attempts that failed - which is what tells someone why a file is not searchable.

GET /api/content/v1/settings

The organization's effective settings, as described under Configure your sources first. The callback secret is reported only as a boolean (callbackSecretSet); it is never returned.

allowedHostCeiling is the set of hosts plycdn permits at all, which bounds your own sourceHosts. Empty means unbounded. Show it wherever you let somebody type a storage host: without it, a host outside the ceiling is refused with source_host_outside_ceiling and nothing on screen says a bound exists, let alone what it is.

12Renewing access

Access to your files can expire — a signed URL has a lifetime, and processing may start later than you expect or run longer. There are two ways to handle it.

Supply fresh access when asked. When a job is awaiting_source_access:

PUT /api/content/v1/assets/{assetId}/source-access
{ "url": "https://…?<fresh sas>" }

Paused jobs for that asset resume. It answers 202 with {"assetId": "…", "resumedJobs": ["<jobId>", …]}. The address must equal the asset's registered sourceUrl before its query string (source_access_mismatch otherwise). To keep address and credential apart, send an unsigned url plus sasToken instead — a signed url, or an unsigned one with sasToken, never a signed url with sasToken (ambiguous_source_credentials).

Or let us ask you. Register assets with a callbackKey and configure callbackUrl and callbackSecret in your settings. We then request access when we need it, and you never have to watch for awaiting_source_access. This is the arrangement to prefer: nothing is stored that can expire, so a file registered once stays readable for as long as it exists.

Every asset reports which of the two it is on, as sourceAccessMode:

renewable Registered with a callbackKey. We ask you for a URL each time we read it. Nothing expires.
stored A signed URL captured at registration. It will eventually die and someone must send another.

Worth checking first when a file stops being readable, because the remedy is opposite in each case — and a callbackKey only helps if your callback can resolve that file's externalId. A file registered with renewable access whose id your callback does not recognise is worse off than one with a stored link: there is nothing to fall back to.

The callback contract

POST <your callback URL>
Content-Type: application/json
X-Content-Timestamp: 1789458231
X-Content-Nonce: 9f4c1ab27e0d4358bd61f0a2c7e39d84
X-Content-Signature: <base64 HMAC-SHA256>

{"organizationId":"…","externalId":"…","sourceVersion":"…","sourceUrl":"https://…"}

The body is compact JSON, UTF-8, never over 16 KB. The signature is base64(HMAC-SHA256(secret, "<timestamp>\n<nonce>\n<body>")). Verify it against the raw bytes received, not a re-serialized copy of the parsed object — a different key order fails a request that was perfectly valid.

Reject anything whose timestamp is more than 300 seconds from now, and remember each nonce for at least that window so a captured request cannot be replayed.

Answer 200 with:

{ "url": "https://…?<sas>", "expiresAt": "2026-09-14T20:04:00Z" }

Everything before the query string must match the asset's registered sourceUrl, or the response is rejected with source_access_mismatch. HTTPS only, port 443, no credentials in the URL. To keep address and credential separate, return an unsigned url plus sasToken — one or the other, never both.

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 rather than paused — an endpoint that is down comes back, and the next attempt asks it again. Answer 200 with a URL you can serve, for every externalId you have registered.

13Managing content

POST   /api/content/v1/assets/{assetId}/reindex     # process the current version again
POST   /api/content/v1/jobs/{jobId}/cancel          # stop work in progress
DELETE /api/content/v1/assets/{assetId}             # remove from the index
GET    /api/content/v1/assets?limit=20&offset=0     # list
GET    /api/content/v1/assets/{assetId}             # one asset, with state and warnings

reindex answers 202 with the new run (assetId, jobId, revision); while the file has work in progress it is 409 asset_busy, and it needs an Idempotency-Key. cancel answers 202 {"jobId": "…", "state": "cancelled"} (409 job_not_cancellable once the job has finished). DELETE answers 202 with the removal's assetId, jobId and revision. GET /assets takes limit (1 to 100, 20 when omitted) and offset; each asset carries sourceAccessMode, metadata, aclMode and extractContentFromVideoFrames as well.

Deleting removes our copy of the index. Your original file is untouched — it was never ours. Deletion is not reversible; register the file again to re-index it.

activeRevision is null until indexing finishes. Compare it with latestRevision to tell whether an asset is searchable yet.

14Reading and correcting a transcript

A recording's transcript can be read and corrected from your backend. A correction replaces the text of the segments you name, and the file is indexed again with your text, without being transcribed again.

GET /api/content/v1/assets/{assetId}/transcript
{ "revision": 3, "warnings": [],
  "segments": [ { "id": "s1", "text": "helo class", "start": 0.0, "end": 1.5,
                  "speaker": "A", "language": "en",
                  "words": [ { "text": "helo", "start": 0.0, "end": 0.4, "confidence": 0.61 } ],
                  "timingPrecision": "word" } ] }

The ETag header carries the same revision. To correct:

PATCH /api/content/v1/assets/{assetId}/transcript
If-Match: "3"
Idempotency-Key: <a new UUID>
Content-Type: application/json

{ "segments": [ { "id": "s1", "text": "hello class" } ] }

segments holds at most 10,000 edits, each text at most 20,000 characters. 202, with the indexing run it started (assetId, jobId, revision). A corrected segment keeps its times and loses its word timings (timingPrecision: "segment"). If-Match must name the latest revision: if the file was indexed again since you read it, 412 revision_conflict, so read it again. A document, or a recording still indexing, answers 409 transcript_not_ready; a segment id that is not in the transcript, 400 invalid_segment_ids. From .NET: GetTranscriptAsync and CorrectTranscriptAsync.

15Tagging, grouping and permissions

Three separate mechanisms, easy to confuse. Metadata describes a file, collections group files, and permissions decide who may find one.

Metadata

Your own tags, in your own vocabulary. We never interpret them — they exist so you can narrow a search with filter.metadata.

PUT /api/content/v1/assets/{assetId}/metadata        # and GET, to read it back
{ "metadata": { "course": "CS101", "term": "autumn", "week": 3, "level": "undergraduate" } }

This is a PUT: what you send becomes the whole document. Values may be strings, numbers, booleans, or arrays of those. Up to 50 keys per file, each key 1 to 128 characters; a string value is at most 512 characters and an array at most 100 values. It answers {"assetId": "…", "metadata": { … }}, which GET /assets/{assetId}/metadata returns again. Writing metadata needs the metadata feature in your plan (not_included_in_plan otherwise).

Re-tagging never triggers a reindex and never costs you indexing. Metadata is filtered on, not searched — it does not change what a passage means, only which searches it is eligible for. Retag a whole library as freely as you like.

You can also send metadata when you register a file, which saves a call.

Collections

Named groups — a course, a module, a folder. You address them by an external id you choose, which is normally the id the same thing already has in your system.

PUT    /api/content/v1/collections/CS101
{ "externalId": "CS101", "name": "Introduction to Computer Science" }

PUT    /api/content/v1/collections/CS101/assets
{ "add": ["<assetId>", "…"], "remove": ["<assetId>"] }

GET    /api/content/v1/collections?limit=50&offset=0   # with an assetCount for each
DELETE /api/content/v1/collections/CS101            # the grouping only; files stay indexed

The external id appears in the address, so it is 1 to 512 characters of letters, digits and _ . ~ - only: MATH-101 works, MATH 101 (a space) or an accented letter is refused with 422 validation_error. The name is 1 to 512 characters. A collection can also carry metadata, with the bounds of a file's. Creating or changing one needs the collections feature in your plan.

PUT .../assets takes at most 1,000 ids in each of add and remove, applies add first and then remove, and answers {"externalId": "CS101", "assetCount": 42}. An asset id that is not yours is 404 asset_not_found; a collection that does not exist is 404 collection_not_found. DELETE answers 204.

GET /collections lists by name, a page at a time: limit is 1 to 200 (50 when omitted) and offset starts at 0; a value outside those bounds is 422 validation_error. Each item carries externalId, name, metadata, assetCount, createdAt and updatedAt. There is no total: keep paging while a page comes back full, and stop at the first that is not.

Registering a file against a collection that does not exist is refused (400 collection_not_found) rather than creating one. A typo would otherwise produce a new empty collection and quietly drop the file out of the one you meant — a mistake that surfaces weeks later as "search is missing things".

Permissions

By default a file is open: anyone who can search your organization can find it. That is the right default for a course catalogue and the wrong one for anything a particular person should not see.

PUT /api/content/v1/assets/{assetId}/permissions     # and GET, to read it back
{ "mode": "restricted", "principals": ["user:alice", "group:cs101-enrolled", "role:faculty"] }

A principal is any opaque string your system understands — a user id, a group, a role, an enrolment key. We never parse it and never expand groups, because your directory is the only place that knows who is in what. Each is 1 to 512 characters; a file holds at most 5,000, and a search sends at most 1,000. mode is open (no principals) or restricted. The answer is {"assetId": "…", "mode": "restricted", "principals": [ … ]}, which GET /assets/{assetId}/permissions returns again. Restricting a file needs the permissions feature in your plan; returning one to open always works.

Then say who is asking, on each search:

POST /api/content/v1/search
{ "query": "…", "principals": ["user:alice", "group:cs101-enrolled", "role:faculty"] }

Send every principal the person holds, expanded on your side. A restricted file is returned when any one of them has been granted it.

Four things worth being precise about:

  • Omitting principals is not a way to search everything. It means "I am not telling you who this is", and the search then returns open files only. A job or integration that forgets to send them loses access rather than gaining it.
  • PUT replaces the whole grant list. If you are mirroring permissions from another system, that system is authoritative and incremental grant/revoke would leave behind a permission it removed months ago.
  • Restricted files are excluded before ranking, not filtered out of the results afterwards. A passage you may not see is never retrieved, never scored, and never counted in any total.
  • Set permissions at registration — "principals": [...] on POST /assets — if a file should never be world-visible. A follow-up call leaves a window in which it is indexed and open.

To make the strict posture the default for everything you register:

PUT /api/content/v1/settings
{ "requireAcl": true, "sourceHosts": ["…"] }

New files are then restricted unless you say otherwise, so an ingestion path that forgets permissions hides the content instead of publishing it.

16Answering a question

Where searching returns the passages, this returns a written answer built from them, with the passages it used.

POST /api/content/v1/answer
{ "query": "what should a student bring to the viva", "principals": ["user:alice"] }
{ "searchId": "…",
  "answer": "Bring the referral letter and photo identification [1]. Arrive fifteen minutes early [2].",
  "citations": [ { "number": 1, "cited": true, "snippet": "…",
                   "location": { "startSeconds": 80.69, … },
                   "sourceReference": { "externalId": "lecture-2026-09-02", … } } ],
  "withheldAsWeak": 0 }

The body is the same as a search — filter, principals and kind all work identically, because this runs a search and then answers from it. Three things follow, and they are the point:

  • The answer only ever uses your content. Every claim is grounded in the passages, and each one is cited by number so a reader can check it.

  • Nothing you cannot see can be cited, because nothing you cannot see was retrieved.

  • A question your library cannot answer gets "answer": null and no citations, rather than a confident paragraph. If nothing clears the relevance floor, there is nothing to answer from.

  • Only passages worth quoting are used. A passage can be relevant enough to return from a search and still be too distant to answer from; those are left out rather than padding the context. Which bands qualify is decided by plycdn.

Show the citations, and mind cited. citations lists every passage the answer was offered; cited says whether the answer actually leant on one. Show the cited ones — put the rest behind a disclosure or leave them out. Presenting both alike is how a reader ends up judging a good answer by a passage it never drew on. When an answer cites nothing at all, every passage comes back cited: true, because saying that none of your content was used would be a worse answer than showing the passages the answer was built from.

An answer about a lecture is worth much less without the timestamp that lets a reader jump to 14:32 and see it for themselves.

Answers are billed separately from the search underneath them, per answer — not per token, and not per passage.

17Suggesting queries as someone types

GET /api/content/v1/suggest?prefix=recipro&limit=10

Draws on searches your organization has made before that found something. Two rules keep it useful and safe: a query is only ever suggested if it returned results, and only if at least three searches have used it — so one person's unique search is never shown back to their colleagues.

{ "prefix": "recipro", "items": [ { "query": "reciprocal rank fusion", "searches": 7, "meanResults": 12.4 } ] }

prefix is 1 to 200 characters (longer is 422 validation_error, fields [["query","prefix"]]); a prefix of fewer than 2 characters answers an empty items. limit is 1 to 25 (10 when omitted). Suggestions need the suggestions feature in your plan.

Not billed. It is a keystroke, not a question.

18Finding related content

GET /api/content/v1/assets/{assetId}/related?limit=10&principals=user:alice

Other files whose content sits near this one — for a "you might also want" panel on a file's page. One row per file, not per passage. Permissions apply exactly as they do on search, so pass principals here too (repeat the parameter for each). limit is 1 to 50 (10 when omitted). It answers {"searchId": "…", "assetId": "…", "items": [ … ]}, each item with assetId, externalId, filename, kind, metadata and a similarity score. It needs the related feature in your plan.

Billed as one search: no question was typed, but an answer was returned.

19Finding what is broken in your library

GET /api/content/v1/analytics/library?limit=20

A count by state, what needs attention, and the list worth acting on first: files that indexed successfully and hold nothing searchable. That is the most confusing state a library can be in — the job succeeded, the file looks correct everywhere else, and it simply never appears in results. Almost always a PDF that is a scan with no text layer, or a recording with no speech in it.

Each entry names the file and carries the warnings recorded while it was processed, so you can tell which of the two it is without opening anything.

The answer holds states (a count of jobs by state), needsAttention (how many files have failed or are waiting for access), items (those files, each with assetId, externalId, filename, kind, jobId, state, stage, errorCode, attempt and updatedAt), indexedButEmpty and sourceMissing. limit is 1 to 100 (20 when omitted) and caps each list.

sourceMissing lists files that are registered here and no longer present where they came from — renamed, moved or deleted in your own storage. They are withheld from search results while that is true, because returning a link that cannot open is worse than returning nothing. Each entry carries sourceUrl and missingSince, so you can tell whether a bulk reorganisation in your storage has quietly taken content out of your index. Put a file back and the next sync clears the mark; nothing here has been deleted.

20Seeing what people search for

GET /api/content/v1/analytics/searches?days=30&limit=25

Returns a summary (summary), your most common queries (topQueries), a daily series (daily) — and the queries that returned nothing (withoutResults). days is 1 to 365 (30 when omitted) and limit 1 to 200 (25 when omitted); windowDays repeats the window. It needs the analytics feature in your plan. 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, and nothing else in your systems can tell you.

Query text is kept for 90 days by default. Change it with searchLogDays on your settings, or set it to 0 and we keep none at all — searches still work and are still counted.

recordingSince is when we began keeping query text, or null if none is kept. Show it. These figures cover searches from that moment; earlier ones were counted and appear on your usage statement, but what was asked was not recorded — so without it the totals here look like they contradict the bill, which is the first thing anybody reconciling the two will conclude.

21Tuning relevance

Three tools, and one rule that applies to all of them: tuning changes the order of results. It never changes relevance. That number stays comparable between queries whatever you configure, so you can keep thresholding on it.

PUT /api/content/v1/tuning/synonyms
[ { "term": "viva", "expansions": ["oral examination"] } ]

Your own name for something, where it appears nowhere in your content. Replaces the whole dictionary, and answers it as {"items": [ { "term": "viva", "expansions": [ … ] } ]}. At most 500 terms; a term is 1 to 100 characters with 1 to 20 expansions of 1 to 100 characters each, and a term that only expands to itself is refused. Terms are compared without regard to case.

PUT /api/content/v1/tuning/boosts/current-year
{ "name": "current-year", "match": { "year": "2026" }, "factor": 3.0 }

Above 1 promotes, below 1 demotes: factor is above 0 and at most 10. Matched against the same metadata you filter on, or against a kind (video, audio or document), or both; a boost names at least one. The name in the address and in the body must be the same (name_mismatch), 1 to 100 characters of letters, digits and _ . ~ -. A boost cannot hide a result — that is what a filter is for.

PUT /api/content/v1/tuning/pins
{ "query": "exam policy", "assetIds": ["<assetId>"] }

For this exact query, put these files first, in this order. A pinned result comes back with "pinned": true so you can label it. The query is at most 400 characters and is stored trimmed and in lower case, which is how it is matched; at most 10 assetIds. The answer is {"query": "exam policy", "assetIds": [ … ]}, and sending an empty assetIds removes the pin.

Read what is configured, and remove a boost you no longer want:

GET    /api/content/v1/tuning/synonyms
GET    /api/content/v1/tuning/boosts
GET    /api/content/v1/tuning/pins
DELETE /api/content/v1/tuning/boosts/current-year

Changing tuning needs the tuning feature in your plan. DELETE on a boost answers 204.

A pin promotes; it does not inject. If the file does not answer the query well enough to be found, it will not appear — which tells you something true about that file.

22Keeping a source in step automatically

If your content already lives somewhere, a connector keeps your library in step with it instead of you writing and running the registration loop yourself. Three kinds are supported:

s3 — any S3-compatible storage. Amazon S3, Google Cloud Storage in interoperability mode, MinIO, or any other service that offers an S3-compatible endpoint and an access key. Give it a read-only key; we list and download, and never write.

POST /api/content/v1/connectors
{
  "kind": "s3",
  "name": "Lecture recordings bucket",
  "config": { "endpoint": "https://s3.ap-south-1.amazonaws.com", "bucket": "lecture-recordings",
              "region": "ap-south-1", "prefix": "courses/2026/", "accessKeyId": "AKIA…" },
  "credential": "<secret access key>",
  "schedule": "nightly"
}

azure_blob — an Azure Blob container, read with a container SAS carrying List and Read only.

POST /api/content/v1/connectors
{
  "kind": "azure_blob",
  "name": "Lecture recordings container",
  "config": { "blobService": "https://youraccount.blob.core.windows.net",
              "container": "lectures", "prefix": "courses/2026/" },
  "credential": "<container SAS token>",
  "schedule": "nightly"
}

blobService is the account endpoint — Storage account → Endpoints → Blob service — and container is the container name on its own; put any folder in prefix. The older single containerUrl field is still accepted, so nothing you already created needs changing.

Two things about the SAS are worth planning for rather than discovering:

  • It expires, and when it does the connector stops. Create it against a stored access policy on the container rather than ad hoc. You can then extend or revoke it from the Azure portal without sending us a new one, and revoking the policy revokes the access immediately. Without a policy the only way to extend it is to issue a new token and update the connector.
  • List and Read, nothing else. We list the container and download from it. We never write and never delete, so a token with more than that grants us access we will not use. A token missing List is the one failure that looks like success: the container appears empty rather than returning an error.

We do not ask for your storage account key, and you should not send one. A key cannot be scoped to a container and cannot be revoked without rotating it for everything else that uses it.

canvas — Canvas LMS course files, with course enrolments carried across as permissions.

POST /api/content/v1/connectors
{
  "kind": "canvas",
  "name": "Nursing school Canvas",
  "config": { "baseUrl": "https://yourschool.instructure.com" },
  "credential": "<access token>",
  "schedule": "nightly",
  "defaultAclMode": "restricted"
}

Instant sync from storage events

Every connector read back carries a notifyUrl. POST anything to it and the connector syncs soon after, instead of waiting for the schedule — point your bucket's event notifications at it (S3 via EventBridge, Azure Event Grid, GCS Pub/Sub push, MinIO webhook targets, or any event service that can POST to a URL). The payload is never trusted or parsed for content: an event is only ever "something changed", and the next run believes what the source itself lists. The URL is a capability — treat it like a password, and note that deleting the connector retires it.

The address is the credential, so the call carries no Authorization header. It answers 202 {"accepted": true} and queues the sync; an address that was altered or has been retired answers 401 invalid_callback, and one that is not shaped like a notification address at all 404 {"error": "Not found"}. The body is not read except for the Event Grid validation handshake below; it may be up to 256 KB and must not be compressed (415 unsupported_media_type). Each sender address may send 600 notifications a minute; past that the answer is 429, which event services retry. A 502 or 504 means we could not be reached for a moment; retry. notifyUrl is null where plycdn has no public address for events to reach.

Which events to send. Send creations and deletions. Nothing is gained by filtering narrowly, and a deletion you do not tell us about is one we only notice on the next scheduled run.

Source Subscribe to
Azure Event Grid Blob Created, Blob Deleted. Add Blob Renamed and Directory Renamed only on a storage account with a hierarchical namespace — on an ordinary account they never fire at all.
Amazon S3 s3:ObjectCreated:*, s3:ObjectRemoved:*, delivered through EventBridge. S3 notifications cannot call a web address directly.
Google Cloud Storage OBJECT_FINALIZE, OBJECT_DELETE on a Pub/Sub push subscription.
Any other event service Its object-created and object-deleted events, delivered as a POST to the address.

Which event schema. Azure Event Grid's Event Schema dropdown offers three. Event Grid Schema and Cloud Event Schema v1.0 both work — each has its own endpoint-validation handshake and notifyUrl answers both. Custom Input Schema also reaches us, because the body is never read; choose one of the first two so the subscription can validate itself. Creating an Event Grid subscription requires the Microsoft.EventGrid resource provider to be registered on the Azure subscription: Subscription → Resource providers → Microsoft.EventGrid → Register. On a new subscription it is not registered, and the storage account's Events page fails before it can ask you anything.

Renames. No storage service reports a rename as a rename. S3 has no rename operation, so a console rename is a copy followed by a delete; Cloud Storage behaves the same way; Azure emits BlobRenamed only on hierarchical-namespace accounts. So a rename reaches us as one path disappearing and an identical one appearing, and we match the two by content and simply re-point the existing asset — reported as moved. The transcript, the corrections made to it, the permissions and any link you have already shared all survive, and you are not charged to transcribe it again. Where the content is genuinely ambiguous — two identical files, one of them renamed — we re-index rather than guess, because attaching the wrong transcript to a recording is not something you could undo.

The credential is sealed when stored and never returned — not here, not by any later read. The connector tells you whether one is set (credentialSet) and nothing more. To replace it, send a new one; to keep it while changing anything else, leave the field out.

schedule is nightly or manual (manual when omitted). enabled (default true) switches a connector off without deleting it; syncPending is true while a requested sync has not started. A nightly run only indexes what changed at the source, so it costs nothing on a night when nothing did. defaultAclMode applies to everything the connector registers (restricted when omitted): for Canvas, restricted mirrors the source's own enrolments, so a student finds what they are enrolled in and nothing else. A bucket carries no enrolments, so its files register as organization-visible; restrict individual files afterwards through the permissions endpoint.

GET    /api/content/v1/connectors
GET    /api/content/v1/connectors/{connectorId}
PUT    /api/content/v1/connectors/{connectorId}
DELETE /api/content/v1/connectors/{connectorId}
POST   /api/content/v1/connectors/{connectorId}/sync

POST answers 201 with the connector, DELETE answers 204, and PUT replaces the configuration and answers the connector (leave credential out to keep the stored one; leave extractContentFromVideoFrames out to keep its stored value, or send null to follow your organization's setting). Connectors need the connectors feature in your plan. sync returns 202 as soon as the run is queued — {"connectorId": "…", "syncPending": true, "requestedAt": "…"} — because a first pass over a real library registers thousands of files and would outlast any request. Ask twice and you still get one run; a connector that is switched off answers 404 connector_not_found. Read the connector back for lastRunAt, lastError and lastSummary:

{ "new": 12, "changed": 3, "unchanged": 481, "reauthorized": 40,
  "moved": 2, "removed": 1, "failed": 0 }

changed and reauthorized are counted separately because the difference is what the run costs you. A file whose content moved is transcribed again; a file whose enrolments moved is not re-indexed at all, only re-granted. Enrolments change constantly, so collapsing the two would mean re-transcribing your library every time somebody joined a course.

moved is a file that was renamed or moved within the source. It is re-pointed, not re-indexed, so it costs nothing.

failed is a count, so the connector also carries failures — the items it could not register, most recent first, each with the object's path and an error code from the table below. Capped at twenty, so a connector pointed at a source that is refused wholesale names the problem rather than returning thousands of identical lines.

"failures": [
  { "externalId": "azure:lectures/week-1.mp4",
    "errorCode": "source_host_not_allowed",
    "seenAt": "2026-09-18T04:07:23Z" }
]

The two causes worth knowing, because both are a minute's work once named: source_host_not_allowed means the file sits on a host you have not declared under settings — a connector is held to the same allowlist your own registrations are, so a source can never introduce content from an address you never approved — and a format code means we do not read that file type. Everything else is in the error table.

removed means the source stopped listing it. We mark it removed rather than deleting it — a source that returns an empty page during an outage would otherwise erase a library, and there is no undo for that. What the mark does do is withhold it from search: a result you cannot open is worse than no result, and a renamed or deleted file would otherwise keep being returned with a link that no longer resolves. It appears under sourceMissing in library health, and the moment the source lists the file again the mark is cleared and it is searchable as before. Nothing is deleted at any point.

Deleting a connector does not delete your library. The link goes and the stored credential with it; everything it indexed stays indexed and searchable.

Connectors go through the same registration path your own calls do, which means the same host allowlist applies. A source returning files on a host you have not declared under settings is refused, and the run reports it rather than registering content from an address you never approved.

23What your plan includes

GET /api/content/v1/plan
{ "plan": "growth", "enforced": true,
  "features": ["analytics", "collections", "metadata", "related", "suggestions", "tuning"],
  "limits": { "maxAssets": 50000 } }

features is drawn from metadata, collections, suggestions, related, analytics, tuning, answers, slides, permissions, connectors, storage and video; search itself is never a feature. Without one, the calls it covers answer 403 not_included_in_plan: writing metadata, collections, tuning or a restricted permissions list; suggest, related, analytics/searches, answer and the connector routes; and, for storage, creating a bucket or a custom domain and starting or completing an upload (S3 and Azure writes included); for video, creating a library or a video. slides decides whether frames of a recording are read at all; it never refuses a call. limits holds the numeric ceilings your plan sets — maxAssets, and where they apply buckets, custom_domains, object_bytes, video_duration_seconds and storageRequestsPerMinute (S3 and Azure tool requests a minute per storage credential) — and a ceiling that is not listed has no plan limit; read it as data and expect new ones.

This call is never gated and is safe to make from a page. Read it when your interface loads and hide what is not included, rather than offering a control that fails when pressed — and read it after a not_included_in_plan refusal to find out what you do have.

enforced is false where no plan is applied to your account; everything is then available and features lists everything. That is not the same as a plan that includes nothing.

24What you have used

GET /api/content/v1/usage?month=2026-09

or a range:

GET /api/content/v1/usage?from=2026-09-01&to=2026-09-30

Defaults to the current month. to includes its own day — the range above covers all of September, through the 30th, not just through the 29th.

The period is read in your account's time zone, not UTC — a calendar month means that month where your account is, and the response says which one in period.timeZone. Every amount is in your account's currency, given as currency.

Each line carries the quantity, what your plan includes at no extra charge, what is billable after that allowance, the unit price and the amount — so a figure on your invoice can always be traced back to the work that produced it.

The allowance and the monthly minimum apply only to a whole calendar month. month= and a from/to pair that exactly spans one month both get you priced lines: quantity, included, billable, unit price, amount and a total. Any other range — a week, a quarter, an arbitrary pair of dates — gets you quantities only: included, billable, unitPrice, amount and total are all null, because there is no single month to apply an allowance or a minimum against.

priced is false where pricing has not been configured for your organization yet: the lines carry quantities and total is null. Treat that as "not priced yet" rather than as a total of zero.

Your first and last months are prorated. Your service starts at the moment you go live, on whatever day of the month that is, and ends at the moment it ends, if it does. Usage before you go live, or after your service ends, is never invoiced, with one rule for delivery and storage API (S3/Azure) usage: it is counted in whole UTC days, so a UTC day that is partly inside your service counts in full. 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 like any other day (even when that day starts before, or ends after, your local month). To pay for no hours before going live, go live at 00:00 UTC. Usage from your last days that is counted after your final statement closes appears on a short follow-up statement. In a month your service covers only in part, your plan's monthly minimum and every included quantity — the dashboard search allowance too — are multiplied by the calendar days your service was active that month, counted in your account's time zone (a day counts when any part of it is inside the service), divided by the days in the month: going live at 10:30 on 17 September gives 14 of 30 days, so a 900.00 minimum becomes 420.00 and 300 included searches become 140. Usage itself is priced normally. Such a month's response carries proration, and each prorated line carries it too; a whole month carries neither, and is exactly as it would otherwise be. The dashboard search allowance, a count of searches, is prorated to a whole search (half up); other included quantities keep six decimal places and money two, as everywhere on a statement. Graduated price tiers are not prorated: a tier's starting quantity is the same in every month, so only the included quantity and the minimum shrink. Monthly quotas are not prorated either (see Your quotas).

Fields on the response

Field Meaning
plan Your plan's name.
currency Your account's currency. Every amount on this response is in it.
status "open" for the current, still-moving month, or "closed" once a statement exists for it (see Statements below) — /usage?month= for a closed month returns the statement's own stored figures rather than recomputing them.
statementNumber The closed statement's number, null while the month is open.
scope "account" when you called with X-Organization-Id set to your account (the parent organization), "organization" when you set it to a sub-organization — decides which of the fields below are present, per Account and sub-organization scope below.
payAsYouGo Whether work above quota continues (billed) or pauses at 100%, for your account.
total The sum of every line's amount, including the monthly minimum and any adjustment. null for a range that is not a whole month, when no prices are configured for your account yet, and always when X-Organization-Id is a sub-organization. A line with priced: false adds nothing to it.
organizations[] Present when X-Organization-Id is your account (the parent): one entry per organization with usage — organizationId, listAmount, and lines[] each carrying metric, dimension, quantity, included (always null: allowances, the dashboard search allowance included, apply to the whole account), billable (that organization's own billed quantity; null on the dashboard searches line, whose free allowance is one pool for the account), listAmount and duplicatesNotCharged. See Account and sub-organization scope below.
lines[].quantity How much of that metric was used.
lines[].included How much your plan includes at no extra charge.
lines[].billable quantity less included, floored at zero.
lines[].unitPrice Your price per unit, in your account's currency.
lines[].amount billable × unitPrice — what that line adds to your total.
lines[].listAmount quantity × unitPrice, the line's amount before your plan's included quantity is deducted. On the Dashboard searches (over allowance) line it is null at the account scope: that line's amount already covers only the searches past the account's shared allowance.
lines[].priced false when the metric has no price configured for your account: unitPrice and amount are then null, and the quantity is still shown.
lines[].lateFor Only on a late usage line: the closed month (YYYY-MM) the usage belongs to. Absent on every other line. See Late usage lines below.
lines[].duplicatesNotCharged How many file versions in this line matched content already charged, and so were indexed but billed nothing — see Duplicate content.
proration Present only for a month your service covered in part (the month it started or ended in): activeDays, daysInMonth, and from / to, the first and last active days (YYYY-MM-DD, null when no day of the month was active). The minimum and every included quantity were multiplied by activeDays / daysInMonth. Absent for a whole month and for a range.
lines[].proration On a line whose included quantity was prorated (and on the monthly_minimum line when the minimum was): {"activeDays": 14, "daysInMonth": 30}. Absent otherwise.

Two kinds of line are not metered work. monthly_minimum is your plan's monthly minimum (prorated in a month your service covered in part), shown as its own line when your metered lines add up to less than it. Adjustment lines are a correction or credit, each with its own customer-visible description, never inferred from usage. index_gigabyte_months is measured once per day and prorated: a file kept searchable for half a month contributes half a GB-month, not a full one.

Late usage lines

Delivery figures settle a few days after the day they describe. One that settles after its month was already closed into a statement is never added to that statement: it is billed in the month that is open when it settles, on a line of its own. The line's label names the month (for example Delivery (India & Asia), late usage for 2026-08), lateFor holds that month ("2026-08"), and dimension is the price key followed by /late-2026-08, so it is never merged with the open month's own line for the same metric.

A late line is charged the way the month it belongs to would have charged it, on that month's plan, prices, included quantity and minimum: what that month would have charged with this usage included, less what it already charged. So on a late line:

  • included is the part the closed month's unused allowance (or its monthly minimum) absorbed, and billable the rest;
  • amount is not billable × unitPrice: a quantity that spans price tiers, or that the closed month's minimum partly absorbed, is charged exactly as that month would have charged it;
  • unitPrice is the rate charged when one rate covered every billable unit, and null otherwise.

Late lines are added to total, but they never count toward the open month's own monthly minimum. Several late batches for the same month add up the way one batch of their total would have.

The processing section (below) carries an events count per metric alongside the quantity — how many indexing runs contributed to it, useful for sanity-checking a bulk import.

Account and sub-organization scope

You always call with a key of your account; X-Organization-Id chooses the scope. Calling with it set to your account (the parent organization) returns every line for the whole account — the parent and all its sub-organizations together — plus organizations[]: each sub-organization's own quantities, billable quantities and listAmount. 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 as a whole. They are informational: each organization's amount is also rounded to the cent on its own, so even where nothing else differs they can differ from the account's lines by rounding. The account's lines and total are authoritative. The quantities do add up exactly.

Calling with X-Organization-Id set to a sub-organization returns only that sub-organization's own lines — the metrics it recorded something for — each with unitPrice and listAmount but no amount or total, which describe the whole account's bill. Every line carries billable: the sub-organization's own billed quantity for a whole priced month (null for a range or an unpriced metric, as on every line). included is null. The Dashboard searches (over allowance) line is the exception to billable: the dashboard allowance is one pool for the whole account, shared by every sub-organization, so a sub-organization sees its own dashboard searches (quantity) with included, billable and listAmount all null; the account's line applies the allowance. duplicatesNotCharged counts its own file versions. The lines are the same, key for key and figure for figure, before and after the month is closed. There is no other organization, and no minimum or adjustment, in a sub-organization's view.

  • /related counts as a search.
  • /answer is one search plus one answer when it returns a written answer; with no passage above the confidence floor, it is a search only.
  • Each further page (offset > 0) is its own search.
  • A search made from your dashboard counts the same as one from your application, and counts toward the same searches quota — see Dashboard searches for the free monthly allowance that applies there.
  • A library with nothing indexed yet returns no results and is not charged.
  • A search that fails before a result is returned is not charged.

What we processed, billed or not

The same response carries a processing section describing everything your content went through in the period, whether or not an invoice line covers it:

{ "processing": [
    { "metric": "audio_hours", "label": "Audio transcribed", "unit": "hours",
      "quantity": 12.5, "events": 9 },
    { "metric": "ocr_frames", "label": "Video frames read for on-screen text", "unit": "frames",
      "quantity": 428, "events": 9 }
  ] }

No money appears here — it answers what happened, not what is owed. Everything listed is a figure you could count for yourself from your own library.

25Usage by day

GET /api/content/v1/usage/daily?from=2026-09-01&to=2026-09-30

Quantities per local day, for a chart. No money — this endpoint never returns a price, an amount or a total, whatever your plan looks like.

{ "period": {"from":"2026-09-01","to":"2026-09-30","kind":"range","timeZone":"Asia/Kolkata"},
  "timeZone": "Asia/Kolkata",
  "metrics": [
    { "metric": "media_hours", "label": "Video and audio indexed", "unit": "hours",
      "points": [ {"day":"2026-09-01","quantity":1.2}, {"day":"2026-09-03","quantity":0.4} ] }
  ] }
Parameter
from, to Optional; YYYY-MM-DD, to inclusive as elsewhere. Without from the period starts on the first of the current month, and without to it ends on the last day of the month from falls in (both in your account's time zone). At most 400 days: period_too_long; a malformed date or to before from is invalid_period.
metric Optional. One metric only, to narrow the response. invalid_metric if it is not one of your catalogue's metrics.
breakdown=organization Optional, account scope only (X-Organization-Id is your account). Adds organizations, the same series per organization (below). invalid_breakdown for any other value.

Days are in the account's time zone. Only days with recorded usage appear in points: a day that is missing had none, so chart it as 0. A metric with no usage at all in the period is left out, unless you asked for it with metric=, in which case it is present with no points. Content kept searchable is a level, so its daily figure is that day's size in GB rather than GB-months.

With breakdown=organization and X-Organization-Id set to your account, the response also carries the same series split by organization:

"organizations": [
  { "organizationId": "…",
    "metrics": [ { "metric": "searches", "label": "Searches", "unit": "searches",
                   "points": [ {"day":"2026-09-01","quantity":3.0} ] } ] } ]

Called for a sub-organization, the response has its own figures only, with no organizations key, whatever breakdown says.

26Your quotas

GET /api/content/v1/usage/quotas
{ "month": "2026-09", "timeZone": "Asia/Kolkata", "payAsYouGo": false,
  "alertThresholds": [80, 100],
  "metrics": [
    { "metric": "media_hours", "label": "Video and audio indexed", "unit": "hours",
      "quota": 200.0, "used": 168.0, "remaining": 32.0, "percent": 84.0,
      "enforced": true, "alertsReached": [80] },
    { "metric": "index_gigabyte_months", "label": "Content kept searchable", "unit": "GB-months",
      "quota": 500.0, "used": 210.0, "remaining": 290.0, "percent": 42.0,
      "enforced": false, "alertsReached": [] }
  ],
  "dashboardSearches": {"allowance": 500, "used": 120, "remaining": 380, "overAllowance": false,
                        "unitPrice": 0.5, "currency": "INR", "serviceActive": true,
                        "serviceStartsAt": "2026-09-17T05:00:00+00:00", "serviceEndsAt": null},
  "serviceActive": true, "serviceStartsAt": "2026-09-17T05:00:00+00:00", "serviceEndsAt": null }
  • quota is null where none is set — unlimited for that metric.
  • enforced is true for the metrics that stop new work at 100% (media hours, document MB, searches, answers) and false for a metric that is only ever reported (content kept searchable is a level, not a flow, and refusing to keep content already indexed makes no sense).
  • alertsReached lists which of your account's alertThresholds this metric has crossed this month.
  • payAsYouGo: when true, work above a quota continues and is billed at your list price rather than refused.
  • serviceActive, serviceStartsAt, serviceEndsAt: your service period — when you went live and, if it ends, when it ends (UTC instants; null when open) — and whether it is active now. While serviceActive is false (before your service starts, or after your service has ended) nothing is billed, dashboard searches included; the same three fields are on dashboardSearches.
  • Quotas are not prorated. A monthly quota and its alerts are the same in every month, the month you go live or your service ends included, and count everything used in the calendar month, including anything before your service starts; only the minimum and the included quantities are prorated.
  • Quotas are soft. Work already under way when a quota is reached is allowed to finish and is charged normally, so used can pass 100% by whatever was already in flight. At 100%, with payAsYouGo: false and that metric enforced, new work of that kind is refused with quota_exceeded (429) until the quota is raised or the month resets. Reading, search results already returned, and playback are never affected.
  • dashboardSearches is your account's dashboard search allowance — one pool shared by your parent organization and every sub-organization, so used counts all of them, whichever calls — see Dashboard searches. In the month your service starts or ends it is the prorated allowance, and used counts only searches inside your service, exactly as the statement applies it. It shares the searches quota and its alerts. unitPrice is your list price per search past the allowance, null while searches have no price configured for your account.
  • Until a billing account is set up for you, the response is empty rather than an error: month, timeZone and dashboardSearches are null and metrics is [].

27Your monthly statement

Once a month ends, plycdn closes it into an immutable statement with a permanent number. A closed month's figures never change; a later correction appears as an adjustment line on a different, still-open month, never a rewrite of the closed one.

GET /api/content/v1/statements
{ "items": [
    {"statementNumber":"PLY-202608-1A2B3C4D","month":"2026-08","currency":"INR",
     "total":500.00,"closedAt":"2026-09-02T04:00:00Z"} ] }

Newest first. Only closed months appear here — the current, still-open month is /usage (with status: "open"), not a statement.

Called with X-Organization-Id set to a sub-organization, each item has total: null and a subtotal instead: that organization's own amount at list price for the month (null if it had no priced usage). It is informational — the sub-organization is billed on the parent account, whose total is authoritative. subtotal is never sent at the account scope.

GET /api/content/v1/statements/2026-08

The statement as JSON: the same lines and fields as /usage?month=2026-08 for that month, with status: "closed" and its statementNumber, exactly as stored at close and never recomputed. It also carries:

Field Meaning
month The month it covers, YYYY-MM.
customer Your account's display name, as printed on the statement.
closedAt When the month was closed.
sha256 A checksum of the stored statement, so you can tell it has not changed since you last read it.
organizationNames Display names for organizations, by organizationId, where we were given them at close.
proration As on /usage: present only when the month was covered in part, and stored with the statement.

It has no processing section — that is /usage. Called with X-Organization-Id set to a sub-organization, the statement is that organization's own view: the same lines its /usage showed for the month (see Account and sub-organization scope above), with total null, no sha256 (it covers the whole account's statement), no minimum or adjustment line, and empty organizations and organizationNames.

GET /api/content/v1/statements/2026-08/export

Returns text/csv; charset=utf-8 with Content-Disposition: attachment; filename="PLY-202608-1A2B3C4D.csv" — the same numbers as a downloadable file, one row per statement line:

Statement,PLY-202608-1A2B3C4D
Customer,<your display name>
Period,2026-08-01,2026-08-31,Asia/Kolkata
Plan,growth
Currency,INR
Status,closed

Section,Organization,Item,Metric,Quantity,Unit,Included,Billable,Unit price,Amount
account,,Documents indexed,document_megabytes,6.000000,MB,1.000000,5.000000,2.000000,10.00
account,,Monthly minimum,monthly_minimum,,,,,,486.83
organization,Acme Main,Documents indexed,document_megabytes,2.000000,MB,,2.000000,2.000000,4.00
total,,,,,,,,,500.00

A prorated month (the month your service started or ended in) adds a Service row under Status — the first and last active days and the proration — and a final Proration column, filled on each line whose included quantity or minimum was prorated:

Status,closed
Service,2026-09-17,2026-09-30,prorated 14/30 days

Section,Organization,Item,Metric,Quantity,Unit,Included,Billable,Unit price,Amount,Proration
account,,Documents indexed,document_megabytes,30.000000,MB,14.000000,16.000000,2.000000,32.00,prorated 14/30 days
account,,Monthly minimum,monthly_minimum,,,,,,358.00,prorated 14/30 days
total,,,,,,,,,420.00

A whole month's CSV has neither, and is exactly as above.

The Organization column names a sub-organization when you gave us its display name at close, otherwise its id. An organization row's Billable is that organization's own, as in organizations[].lines on /usage; Included is empty, and both are empty on the dashboard searches row, whose free allowance is shared by the whole account. Organization rows, like the per-organization amounts on /usage, are informational; the account rows and the total row are authoritative. 404 statement_not_found when that month has never been closed.

Text cells are safe to open in a spreadsheet. A text cell (a name, a label, a plan) that would begin with =, +, -, @, a tab or a carriage return is written with a leading ', so a spreadsheet shows it as text rather than running it as a formula: an organization named =Sales appears as '=Sales. Numbers are never altered.

Called with X-Organization-Id set to a sub-organization, the export has that organization's own lines only, as account rows whose Amount is the line's list amount, no organization rows, and an empty total row.

From .NET, ExportStatementCsvAsync returns this CSV as a plain string, not a stream — read it as text, not through GetAsync<T>.

28What was done to each file

GET /api/content/v1/usage/assets?from=2026-09-01&limit=50&offset=0

The question a period total cannot answer: not what you owe, but what happened to this recording. Each row names the file and what was done to it. limit is 1 to 200 (50 when omitted), offset starts at 0, and from and to default to the current month as on /usage.

{ "period": {"from":"2026-09-01","to":"2026-09-30","kind":"month","timeZone":"Asia/Kolkata"},
  "items": [
    { "assetId": "…", "organizationId": "…", "externalId": "lecture-04",
      "filename": "lecture-04.mp4", "kind": "video",
      "createdAt": "2026-09-14T09:12:00Z", "extractContentFromVideoFrames": null,
      "deleted": false, "billed": { "media_hours": 0.0 }, "duplicateOf": "…",
      "processing": { "audio_hours": 1.4, "ocr_frames": 62, "source_bytes": 734003200 } }
  ], "total": 38 }
Field Meaning
organizationId The organization the file belongs to.
deleted true for a file removed since — it still appears, because what it was charged still stands.
billed What was charged for this file in the period, by metric. 0 for a duplicate that was not charged.
duplicateOf When the file was not charged because identical content was already charged, the asset it matched; otherwise null. See Duplicate content.
processing What was done to it, charged or not.
total How many files had work or a charge in the period, before paging.

Called for your account (X-Organization-Id is the parent), this lists every file in the account — the parent and every sub-organization — newest first, each with its organizationId. Called for a sub-organization, it lists that organization's own files only. Because duplicates are recognised across the whole account, duplicateOf can name a file in another organization of the same account, which a sub-organization's own list does not contain.

A recording that declined frame extraction simply has no ocr_frames entry. extractContentFromVideoFrames is what that file was registered with — null means your organization's setting decided.

Neither this nor /usage is ever gated: whatever your plan includes, you can always see what your own content went through.

29Reading text shown on screen

Recordings are transcribed from their audio. Where on-screen reading is enabled for your account, we also take still frames at slide changes and read the text on them, so what was shown is searchable alongside what was said.

You decide whether that happens, in three places, most specific first:

POST /api/content/v1/assets
{ "externalId": "lecture-04", "filename": "lecture-04.mp4", "kind": "video",
  "sourceUrl": "https://storage.example.com/lecture-04.mp4", "sourceVersion": "\"etag\"",
  "extractContentFromVideoFrames": false }
Set it on Effect
An asset, at registration Decides for that recording. null inherits your organization's setting.
Your organization, under settings The default for every file that does not say for itself.
A connector The default for everything that source brings in. null inherits the organization.

false means audio only: no frames are taken from the recording at all. Omit it and nothing changes from how your integration behaves today. The setting is returned on the asset, so what a recording was registered with can always be checked afterwards, and it cannot buy a capability your plan excludes — a plan without on-screen reading reads no frames whatever you send.

30Storage and delivery

A separate capability from indexing: buckets hold files and serve them, whether or not those files are also registered with Content Search. Full walkthroughs and SDK usage are in Storage and delivery; this is the wire contract. The same buckets are also reachable with S3 and Azure tools (S3 and Azure compatibility). All of it is under the same base address and authentication as the rest of this guide.

Regions

GET /api/content/v1/regions
{ "items": [
    { "code": "mumbai",    "name": "Mumbai, India",    "strictCapable": true,  "country": "IN",
      "endpoints": { "s3": "https://s3.in-mum.plycdn.net",
                     "s3VirtualHost": "https://{bucket}.s3.in-mum.plycdn.net",
                     "blob": "https://{account}.blob.in-mum.plycdn.net",
                     "blobGlobal": "https://{account}.blob.plycdn.net",
                     "upload": "https://in-mum.upload.plycdn.com" } }
  ] }

The list is the regions open for new buckets today, the same for every account; new regions are added over time. Treat region codes as strings and read the list rather than hard-coding it.

country is the ISO 3166-1 country the region is in; strict residency (strictResidency) is judged by country. A region code that does not exist, or is not open yet, fails with region_not_available.

endpoints are the region's hosts for S3 and Azure tools and for part uploads, as patterns: replace {bucket} with a bucket name and {account} with your storage account name. blobGlobal is the same for every region (it reaches your account's home region). Read hosts from here rather than building them, and treat the list of regions as data: regions are added over time. S3 and Azure compatibility explains each host.

Buckets

POST /api/content/v1/buckets          # 202
Idempotency-Key: <your own unique value>
{ "name": "course-media", "region": "mumbai", "visibility": "private", "strictResidency": false }

Idempotency-Key is required on this call (400 idempotency_key_required without it): repeating the call with the same key and body returns the first answer instead of creating a second bucket, and the same key with a different body is 409 idempotency_conflict. Video libraries, videos, custom domains and webhooks need one too.

{ "id": "…", "name": "course-media", "kind": "files", "region": "mumbai", "strictResidency": false,
  "visibility": "private", "state": "active", "failureReason": null, "suspended": false,
  "delivery": { "allowedDomains": ["learn.example.com"], "blockNoReferrer": false,
                "countriesAllow": [], "countriesBlock": [], "linkTtlSeconds": 900,
                "ipBinding": false, "rateLimitKbps": null },
  "uploadRules": { "maxObjectBytes": null, "allowedTypes": ["video/*", "application/pdf"],
                   "allowOverwrite": false },
  "cache": { "edgeTtlSeconds": null, "browserCacheControl": null, "rules": [] },
  "cors": { "allowedOrigins": [], "allowedMethods": ["GET", "HEAD"], "allowedHeaders": [],
            "exposeHeaders": [], "maxAgeSeconds": 600, "allowCredentials": false },
  "defaultHostname": "course-media.plycdn.net",
  "deliveryBaseUrl": "https://course-media.plycdn.net",
  "storedBytes": 0, "objectCount": 0, "createdAt": "…", "updatedAt": "…",
  "deletedAt": null, "purgeAfter": null }

name is 3 to 63 characters, unique across plycdn (see Bucket names); it is the bucket's hostname label: defaultHostname is {name}.plycdn.net.

Bucket names

GET /api/content/v1/bucket-names?name=course-media
{ "name": "course-media", "available": true, "reason": null, "hostname": "course-media.plycdn.net" }

hostname is the address a bucket of that name gets (so a form can show it before the bucket exists), whether the name is available or taken; null for an invalid name. Works with any API key, standard or admin, answered for the organization named in X-Organization-Id. reason is null, "invalid" (3 to 63 characters of a-z, 0-9 and -, starting and ending with a letter or digit, no -- in characters 3–4) or "taken" (in use anywhere on plycdn, held after a purge, retired, or not available). A deleted bucket that was ever public has its name retired: "taken" for every other account, for good; your own account and its sub-organizations still see "available" and may create it (see Bucket names). Advisory: POST /buckets decides, with 409 bucket_name_taken. More than 120 checks a minute for your account answers 429 rate_limited with Retry-After. A missing or longer than 100-character name is 422 validation_error.

GET  /api/content/v1/buckets
GET  /api/content/v1/buckets/{bucketId}
PATCH /api/content/v1/buckets/{bucketId}
DELETE /api/content/v1/buckets/{bucketId}
POST /api/content/v1/buckets/{bucketId}/restore
POST /api/content/v1/buckets/{bucketId}/retry

kind is "files", or "video" for a video library (see Video and webhooks); more kinds may follow, so treat any value your integration doesn't recognise as one to ignore rather than reject. state is creating, active, failed, deleting or deleted. A new bucket stays creating while it is provisioned, including a bucket that is only waiting on its TLS certificate — that is not itself a failure. If provisioning cannot finish before its attempts run out, the bucket becomes failed with a failureReason and retry re-attempts it from where it left off:

failureReason Meaning What to do
certificate_pending Provisioning ran out of attempts still waiting on the bucket's TLS certificate retry — usually succeeds once a certificate is available
no_storage_capacity No storage capacity could be reserved for the bucket's region retry, or try again later; consider another region if it persists
no_cdn_capacity No delivery capacity could be reserved retry, or try again later
provider_unavailable A transient fault outlasted every attempt retry
provisioning_failed Provisioning could not complete for any other reason retry; if it keeps failing, contact us

failureReason is null except in the case above and one more: a PATCH that changes delivery settings can fail to reach the edge after every attempt. The bucket then stays in whatever state it was in (usually active, or deleting if a delete is in progress — either way it keeps serving, or not serving, exactly as it did before the change) with "failureReason": "configuration_failed". Nothing to retry yourself: the change stays queued, and the next delivery-settings change (from you, or plycdn's own retry) that reaches the edge clears it. Treat any failureReason value you don't recognise the same as provisioning_failed. PATCH updates visibility, delivery, uploadRules, cache and cors (see Cache and CORS settings); DELETE soft-deletes with a 7-day restore window, after which restore fails with restore_window_passed — deletion stops serving immediately, even if the delivery settings behind it could not be fully applied. retry re-attempts provisioning for a failed bucket. Names are unique across plycdn (bucket_name_taken); an operation on a bucket that is not active (other than restore/retry/reading it) fails with bucket_not_active — retryable while the bucket is still creating, since it becomes active on its own. A bucket plycdn has taken down (see Takedowns in Storage and delivery) shows "suspended": true: it delivers nothing, and starting, renewing or completing an upload, or signing a link, fails with 403 bucket_suspended until plycdn reinstates it; listing, reading and deleting still work, so you can remove what was reported. Creation itself can fail with bucket_hostname_unavailable if no default hostname could be allocated — rare, and safe to retry. More than 20 bucket creations a day for your account answers 429 rate_limited (creating a video library counts as one). DELETE and retry answer 202; restore answers the bucket.

Limits on the settings: delivery.linkTtlSeconds 60 to 604,800; delivery.rateLimitKbps 64 to 10,000,000; delivery.allowedDomains at most 50 entries and each country list at most 250; uploadRules.allowedTypes at most 50; uploadRules.maxObjectBytes 1 to 1,342,177,280,000.

Changing a bucket. In a PATCH, a field you leave out is unchanged, and an explicit null removes a setting, returning it to its default:

Setting null means
delivery.rateLimitKbps no speed limit
delivery.linkTtlSeconds plycdn's default link lifetime (15 minutes)
delivery.allowedDomains, delivery.countriesAllow, delivery.countriesBlock no restriction (the same as [])
uploadRules.maxObjectBytes your plan's largest file
uploadRules.allowedTypes any type (the same as [])
cache.edgeTtlSeconds, cache.browserCacheControl plycdn's default
cache.rules no rules (the same as [])
cors.allowedOrigins CORS off (the same as [])
cors.allowedMethods, cors.allowedHeaders, cors.exposeHeaders, cors.maxAgeSeconds their defaults (GET and HEAD, none, none, 600)
PATCH /api/content/v1/buckets/{bucketId}
{ "delivery": { "rateLimitKbps": null }, "uploadRules": { "maxObjectBytes": null } }

The switches (delivery.blockNoReferrer, delivery.ipBinding, uploadRules.allowOverwrite, cors.allowCredentials), visibility, and delivery, uploadRules, cache or cors as a whole always have a value, so null on any of them is refused with 422 validation_error, fields naming it (for example [["body","delivery","ipBinding"]]): send false, or the value you want. The audit entry (bucket.updated) records a removal as that field set to null in detail, and lists what was removed in detail.cleared (for example ["delivery.rateLimitKbps"]). Removing a setting that was never set changes nothing ("outcome": "unchanged"). In the .NET package, BucketPatch's ClearRateLimitKbps, ClearLinkTtlSeconds and ClearMaxObjectBytes send the null; a property left unset is not sent.

Objects

GET /api/content/v1/buckets/{bucketId}/objects?prefix=week-1/&cursor=…&limit=100
GET /api/content/v1/objects/{objectId}
PATCH /api/content/v1/objects/{objectId}
DELETE /api/content/v1/objects/{objectId}
{ "id": "…", "bucketId": "…", "key": "week-1/intro.mp4", "size": 123, "contentType": "video/mp4",
  "sha256": "…|null", "checksumState": "verified|unverified|pending|mismatch|not_requested", "etag": "…",
  "state": "stored|disabled", "metadata": {}, "url": "https://…|null",
  "createdAt": "…", "updatedAt": "…", "createdVia": "plycdn|s3|azure" }

createdVia says how the object was written: plycdn through this API, an SDK or the dashboard; s3 or azure through the S3- or Azure Blob-compatible endpoint (S3 and Azure compatibility). It describes the latest write of the key.

url is present only where an object can be reached without signing (a stored object in a public bucket). An unknown id fails with object_not_found. A disabled object ("state": "disabled", after a checksum mismatch or a takedown) is still listed and returned, so you can see and delete it, but it is never delivered: /sign answers object_not_found for it, as it does for a key or id of another bucket.

Listing. Objects come back in byte order of the key's UTF-8 (as S3 lists: B before a, é after z), disabled ones included. prefix is a literal key prefix, not a pattern: week-1/ lists every key that starts with it, at any depth. There is no delimiter, so group keys into folders yourself. limit is 1–1000 (100 when omitted). nextCursor is opaque: pass it back unchanged as cursor for the next page and stop when it is null; never build or parse one, since its format can change.

Metadata. PATCH replaces the whole metadata object, so send every label you want to keep: at most 64 keys, each value a string, number, boolean or null, and at most 8 KiB as JSON; anything else is validation_error. It answers the updated object.

Deleting. DELETE answers 204 with no body. The object stops being listed at once and its key can be uploaded again straight away; its bytes and any copies cached at the edge are removed shortly after.

PATCH and DELETE can answer object_busy (409, retryable) while another change to the same file is in progress, such as an upload landing on its key; nothing was changed, so retry shortly. A key is 1 to 1,024 bytes of UTF-8 with no leading or trailing /, no //, no \, no . or .. segment and no control characters; one beginning .plycdn- (in any case or spelling) is reserved. A key that breaks these is refused with 422 validation_error at upload time.

Content types. Every delivery response carries X-Content-Type-Options: nosniff: a browser uses the contentType the object was uploaded with and never guesses one from its bytes, so declare the right type when you start the upload.

A type that can run script in a browser is always delivered as a download, in a sandbox (Content-Disposition: attachment, Content-Security-Policy: sandbox), even if inline is asked for: HTML, XML (text/xml, application/xml, text/xsl and every …+xml type, SVG and XHTML included), multipart/x-mixed-replace, multipart/related and message/rfc822. Every other type is served as declared.

uploadRules.allowedTypes entries may end in /*, and image/* admits image/svg+xml, which is such a type. For files your users upload, list the image types you want (image/png, image/jpeg, image/webp, …) rather than image/*, so an SVG is refused. On a bucket with allowedTypes, a file whose first bytes are markup (HTML, SVG or XML) declared as another type (say image/png) is refused with file_type_not_allowed unless the bucket allows that markup type.

Delivery URLs and their query. A delivery request whose query string contains response, partNumber, versionId, uploadId or X-Amz- (written as here, in lower case, in upper case or with a capital first letter), or a percent-escape of any letter of those names, is refused with 403 at the edge: such parameters would make the storage behind the edge answer something other than the file. A query of your own that happens to contain one is refused too, for example ?download=Response.pdf: choose another parameter value. Other parameters pass (a signed link must still be used exactly as issued).

Uploads

POST /api/content/v1/buckets/{bucketId}/uploads   # 201
{ "key": "week-1/intro.mp4", "contentType": "video/mp4", "size": 123, "sha256": "…" }

contentType is one media type in lower case (type/subtype, optionally ; and printable parameters). A comma may appear only inside a quoted parameter value (text/plain; x="a,b"): a value such as video/mp4, text/html is 422 validation_error with fields [["body","contentType"]].

{ "id": "…", "bucketId": "…", "key": "week-1/intro.mp4", "size": 123, "contentType": "video/mp4",
  "partSize": 8388608, "partCount": 1, "state": "open", "expiresAt": "…", "ticketExpiresAt": "…",
  "ticketTtlSeconds": 86400,
  "parts": [ { "number": 1, "size": 123, "url": "https://in-mum.upload.plycdn.com/v1/<ticket>/1" } ],
  "objectId": null }
GET    /api/content/v1/uploads/{uploadId}
DELETE /api/content/v1/uploads/{uploadId}
POST   /api/content/v1/uploads/{uploadId}/renew
POST   /api/content/v1/uploads/{uploadId}/complete

objectId is null until the upload completes, then holds the id of the object it produced — a stable reference you can keep before complete's response arrives (a retried or resumed complete call, for instance, can look the object up by it directly). GET carries received (parts already accepted) instead of parts; renew returns fresh parts, ticketExpiresAt and ticketTtlSeconds for the same session. ticketTtlSeconds (returned by service 1.8.0 and later, on create and renew only) is how long the part URLs' ticket lasts from the moment you receive the response: time the ticket by it rather than by comparing ticketExpiresAt with your own clock; complete returns the finished object (the Object shape above) instead of parts/received. Part uploads themselves go directly to https://{label}.upload.plycdn.com/v1/{ticket}/{n} — never to api.plycdn.com and never to a hostname you configure.

An organization can hold at most 1,000 open uploads at once (each sub-organization counts its own); starting one more answers plan_limit_reached (403) until one completes, is aborted or expires.

A part upload is a PUT of exactly that part's bytes, with its Content-Length declared up front (no chunked bodies), and answers 200 {"part": n, "etag": "…"}. Sending the same part again replaces it. Its failures are part-sized and none of them loses the parts already accepted:

  • upload_too_slow (408, retryable): the body stopped arriving or arrived below the minimum rate; send the part again.
  • provider_unavailable (503, retryable): the upload server is busy or storage is briefly unavailable; wait for the Retry-After header (in seconds), then send the part again.
  • invalid_content_length (400): the length is missing or not a number, or the body is chunked. part_size_mismatch (400): the declared length is not this part's size, or the bytes that arrived did not total the declared length.
  • checksum_mismatch (422): the part's bytes did not match its Content-MD5; send it again.
  • ticket_expired / ticket_invalid (401): renew the upload for fresh part URLs.

A refusal that comes before the body has been read is answered and the connection closed. That covers an expired ticket, but also a busy upload server and a body cut for being too slow, and many HTTP clients, browsers among them, report any of these as a connection reset while still sending rather than as the answer. So a reset alone does not mean the ticket expired:

  • if the part URL's ticket is at or near its end (within a minute of it, judged by how long ago you received it against ticketTtlSeconds rather than by comparing your clock with ticketExpiresAt), renew the upload once — one renewal serves every part — and send the part on the fresh URL;
  • otherwise wait at least 5 seconds, a little more at random, and send the part again on the same URL.

Renewing on every reset turns a busy moment into a stream of needless renewals; cap how many times a single upload renews because of resets. The plycdn uploaders (@plycdn/angular/storage, @plycdn/react/storage and PlycdnUploader in Plycdn.AspNetCore.Storage) do all of this for you (three reset-driven renewals per upload, then ticket_expired), and send a part that meets checksum_mismatch once more.

An upload not in the open state fails with upload_not_open; past its 7-day limit, upload_expired. While another complete (or an abort) of the same upload is running, complete and DELETE answer upload_not_open marked "retryable": true: ask again shortly and you get the finished upload. DELETE on an upload that has already completed changes nothing and answers 200 with the completed upload and its object (and an abort that finds the upload's bytes already in place finishes it the same way); on an aborted or expired upload it answers that upload as it stands. Abort therefore never removes a file that finished uploading. complete before every part has arrived fails with upload_incomplete; a total that doesn't match the declared size fails with size_mismatch; a contentType outside the bucket's uploadRules.allowedTypes fails with file_type_not_allowed. A part number outside 1..partCount fails with part_out_of_range; a part whose size doesn't match what that part number requires fails with part_size_mismatch; an invalid or expired ticket on the part URL fails with ticket_invalid or ticket_expired.

A declared sha256 is verified after complete returns — always asynchronously, never as part of that response, since hashing a multi-gigabyte upload cannot hold completion open. checksumState starts unverified and later becomes verified or, if the stored bytes don't match, mismatch, at which point the object's state becomes disabled: it stops being retrievable or servable, and its bytes are quarantined. complete itself never fails with checksum_mismatch — for a declared sha256 that code describes the outcome, not a direct response (a part upload can answer it, but only about that part's own bytes, and sending the part again fixes it) — so poll GET /objects/{objectId} or watch the audit log if your integration needs to know before serving a file that was just uploaded.

Files plycdn brings into a bucket for you (an assisted migration, arranged with us) arrive as ordinary objects, with no upload session and no sha256. They start with checksumState pending while plycdn reads each one end to end and records its sha256, then become verified; a file whose bytes changed after it was brought in becomes mismatch and disabled, like a failed upload check. The audit log records the migration as bucket.adopted (one row per run) and object.adopted (one row per batch of files, with how many and their first and last keys), with plycdn as the actor. The files are stored and billed like any other from the day they arrive; bringing them in is not counted as upload volume.

Signing

GET  /api/content/v1/buckets/{bucketId}/signing-profile
POST /api/content/v1/buckets/{bucketId}/signing-key/rotate
POST /api/content/v1/sign
// signing-profile
{ "bucketId": "…", "scheme": "pl1", "keyId": "k1", "key": "…", "hostname": "course-media.plycdn.net",
  "hostnames": ["course-media.plycdn.net", "cdn.example.com"], "defaultTtlSeconds": 900, "ipBinding": false,
  "directoryScheme": null, "pathTokens": false, "tagsEnabled": false }
// POST /sign { "bucketId": "…", "key": "week-1/intro.mp4", "ttlSeconds": 900, "ip": null, "hostname": null }
{ "url": "https://course-media.plycdn.net/week-1/intro.mp4?token=…&expires=1790000000",
  "expiresAt": "…" }

scheme is "pl1" (plycdn links: used by new private buckets) or "ps2" (buckets made before, and buckets that allow credentialed cross-origin requests, see CORS); directoryScheme is "pl1d" or "ps3" on a video library and null on a files bucket. Rotating the signing key invalidates every link already issued with the old key. On a pl1 bucket this takes effect within about a minute: links signed with the new key start working, and links signed with the old key stop, within that minute, so wait a minute (or retry) before handing out links signed with the new key. On a ps2 bucket it is at once. More than 60 rotations an hour for your account (across its organizations and buckets; refused ones count) answer 429 rate_limited with Retry-After, and the key is not changed. Storage and delivery documents both schemes (an HMAC-SHA256 token), with worked examples, for anyone signing links without calling /sign. An IP-bound link must bind the viewer's IPv4 address — IP-bound delivery serves IPv4 only. On a bucket with ipBinding, ip is required and must be IPv4 (an IPv4-mapped address such as ::ffff:1.2.3.4 counts as its IPv4 form); a missing or IPv6 ip is refused with 422 validation_error, fields [["body","ip"]], and so is any ip on a private bucket without ipBinding. On a public bucket /sign answers the object's plain URL with "expiresAt": null; ip and ttlSeconds play no part. The object must be stored and in the named bucket: a disabled object, or a key or id of another bucket, is object_not_found. A rotation that answers provider_unavailable may already have changed the key at the edge; plycdn stores the edge's key within a few minutes, so fetch the signing profile again before relying on the old one. A signed link must be used exactly as issued: appending, removing or reordering query parameters invalidates it.

ttlSeconds on /sign is 60 to 604,800. hostname on /sign is optional: leave it out (or null) for the bucket's default hostname, or name one of the bucket's active custom domains (see Custom domains below). hostnames in the profile lists every hostname a link may be signed on, the default first. A hostname that is not a hostname at all (empty, an IP address, * or _, one label) is 422 validation_error with fields [["body","hostname"]]; a well-formed one that is not the bucket's is 409 domain_not_active.

Custom domains

Serve a bucket on your own hostname (cdn.example.com) as well as its default one. Administrative: call these from your server with your API key; adding and removing a domain need an admin key (admin_key_required otherwise), while listing, reading and checking work with either. Walkthrough and SDK usage: Your own domain in Storage and delivery.

Route What it does
POST /domains Add a hostname to a bucket (202). Requires Idempotency-Key.
GET /domains?bucketId=&cursor=&limit= Your domains, oldest first, 100 a page (limit 1–100); nextCursor continues. bucketId is optional.
GET /domains/{domainId} One domain: its records, checks and state.
DELETE /domains/{domainId} Remove it (204). It disappears at once; we stop serving it within minutes.
POST /domains/{domainId}/check Check now (202). A failed or detached domain starts again with a new TXT value. At most once a minute per domain.
POST /api/content/v1/domains
{ "bucketId": "2f0c…", "hostname": "cdn.example.com" }
{ "id": "7d1e…", "bucketId": "2f0c…", "hostname": "cdn.example.com", "unicodeHostname": "cdn.example.com",
  "state": "pending_dns", "failureReason": null, "apex": null,
  "records": [
    { "type": "TXT",   "name": "_plycdn.cdn.example.com", "value": "plycdn-verify=k3x7…" },
    { "type": "CNAME", "name": "cdn.example.com",         "value": "q4n2w7t5c6aa.domains.plycdn.net" } ],
  "checks": { "ownership": "missing", "routing": "missing", "claim": "unknown", "certificate": "none" },
  "lastCheckedAt": null, "nextCheckAt": "…", "activeAt": null, "createdAt": "…", "updatedAt": "…" }

GET /domains answers { "items": [ … ], "nextCursor": "…" }, nextCursor being null on the last page.

hostname is accepted in any case, with or without a trailing dot, and in Unicode or its xn-- form; it is stored and returned in lowercase xn-- form as hostname, with unicodeHostname for display (media.例え.jp is hostname media.xn--r8jz45g.jp). Every spelling of one hostname is the same domain. A hostname must have at least two labels and at most 245 characters, and cannot be an IP address or contain * or _; anything else is 422 validation_error with fields [["body","hostname"]] and a detail that says what is wrong with its form. plycdn's own hostnames and reserved names (.local, .localhost, .test, .example, .internal and the like) are 422 hostname_not_allowed. The bucket must be active (bucket_not_active) and not suspended (bucket_suspended), both for adding a domain and for checking one now. A bucket takes at most 8 domains in any state (domain_limit_reached); your plan may set a lower ceiling across your organization (plan_limit_reached). Additions are limited per account per hour and per day: 20 an hour and 100 a day across all your organizations, counting every attempt, including domains you have since deleted; beyond that, 429 domain_rate_limited with Retry-After. Deleting a domain does not give an addition back.

Records. Create both exactly as given, at your DNS host:

  • TXT _plycdn.{hostname} proves the hostname is yours. Keep it for as long as you use the domain: we check it daily.
  • CNAME {hostname} routes it to us. It must be DNS-only: if your DNS host offers a proxy in front of the hostname, turn it off for this record, or routing shows as elsewhere.

Checks. ownership is found, missing or unknown (the TXT record); routing is found, missing, elsewhere (the hostname points somewhere else) or unknown; claim is free, held_elsewhere (another account serves this hostname) or unknown; certificate is none, pending or active. unknown means DNS could not give a definite answer this time; it never changes the state on its own.

States. pending_dns (waiting for both records) → verifying (records found; attaching and routing) → issuing_certificate → active. Or failed with a failureReason, or detached when a daily check found your records gone (confirmed by a second check an hour later). nextCheckAt is when we look next: every minute for the first ten minutes, every ten minutes up to an hour, hourly after that, and daily once active. A verifying domain whose TXT record is definitely gone goes back to pending_dns (it does not fail) until the record is back. A hostname added again while its previous domain is still being removed waits in verifying; if that removal has not finished within the verification window (about an hour), the new domain fails with hostname_in_use: check it again once the old one is gone.

failureReason Meaning What to do
dns_not_configured The records were not found within 72 hours, or the hostname did not route to us within an hour of attaching. Create both records exactly as given, then POST /domains/{domainId}/check.
hostname_in_use Another account serves this hostname and your records have not replaced theirs, or you added the hostname again and its previous domain was still being removed an hour later. Point the records at the values in this domain; the other account loses the hostname at its next daily check. After a re-add, check again once the previous domain is gone.
caa_blocks_certificate Your CAA records do not allow our certificate authority. Add the CAA record shown in records, then check again.
certificate_failed No certificate within 24 hours. Make sure nothing proxies the hostname, then check again.
dns_record_removed Your CNAME (or ALIAS) no longer points at the target. Restore it and check again.
ownership_record_removed Your TXT record is gone. It must stay for as long as you use the domain. Restore it and check again.
provider_unavailable We could not complete a step. Check again.

Checking again (POST /domains/{domainId}/check) a failed or detached domain returns it to pending_dns with a new TXT value in records: replace the old TXT record with the new one. The CNAME target does not change. While we are still removing the domain from our network (shortly after it failed or was detached), checking it again answers 202 with the domain still failed or detached; it moves to pending_dns with its new TXT value on its own once the removal finishes, so read it again (GET /domains/{domainId}) some minutes later rather than checking again.

A domain that fails caa_blocks_certificate carries a third record, the one to add:

{ "type": "CAA", "name": "cdn.example.com", "value": "0 issue \"…\"" }

Its value names the certificate authority that issues certificates for custom domains; add it exactly as given. Without CAA records at all, nothing needs to be added.

Apex domains (example.com) cannot have a CNAME: use your DNS host's ALIAS, ANAME or CNAME flattening to the same target. apex is null until we have seen your zone, then true for an apex and false otherwise.

Signing on your domain. POST /sign accepts "hostname": "cdn.example.com"; it must be the bucket's default hostname or one of its active domains, otherwise 409 domain_not_active. The signing profile (GET /buckets/{bucketId}/signing-profile) lists them in hostnames, default first. A link signed on one hostname has the same token on every other: the token covers the path and expiry, not the host. A public bucket serves its files at the same path on every active hostname; an object's url stays on the default hostname.

Usage by resource

GET /api/content/v1/usage/resources?from=2026-09-01&to=2026-09-30&metric=delivery_gb&bucketId=…&cursor=…&limit=200
{ "items": [ { "resourceId": "…", "resourceName": "course-media", "metric": "delivery_gb",
               "day": "2026-09-01", "dimension": "india-asia", "quantity": 1.25, "unit": "GB",
               "provisional": false } ],
  "nextCursor": "…" }

A paged, per-bucket, per-day breakdown of the storage metrics (storage_gb_days, delivery_gb, requests_millions, upload_gb, and custom_domain_days, one per active custom domain of the bucket that day, invoiced as Custom domains in domain-months — see Storage and delivery for units and how each is measured), of encoding_minutes (unit minutes) against the video library that holds each video (see Videos), and of the storage API's gateway_egress_gb, gateway_write_requests_millions and gateway_read_requests_millions (S3 and Azure tools; see Usage and pricing in S3 and Azure compatibility). from and to are UTC days, both counted, so a range spans at most 93 days — UTC days, except that storage_gb_days and custom_domain_days are measured on your account's own calendar day; leave out metric for all of them. dimension is the region (mumbai) for storage, uploads, encoding minutes and the storage API, and the region group (india-asia) for delivery. Storage API requests that name no bucket (listing buckets or containers, for example) are reported with your organization's ID as resourceId and "resourceName": null; a storage API day stays provisional until it settles after the UTC day ends. Days with nothing to report have no row. limit is 1–5000 (500 when omitted). cursor is a keyset cursor: follow it until the response carries none, rather than assuming a page count. The last three days of delivery figures are provisional ("provisional": true) until settlement; a settled figure is final and is what your invoices bill. A figure that settles after its month's invoice was closed is shown on the day it happened and billed on a later invoice, as late usage (see Late usage lines); so is a video that becomes ready after its month was closed.

Cache and CORS settings

Two more sections of the bucket body, on POST /buckets and PATCH /buckets/{bucketId}, and in every bucket response (defaults filled in). Caching and CORS in Storage and delivery explain them with examples.

PATCH /api/content/v1/buckets/{bucketId}
{ "cache": { "edgeTtlSeconds": 86400, "browserCacheControl": "public, max-age=3600",
             "rules": [ { "pathPrefix": "video/", "edgeTtlSeconds": 31536000,
                          "browserCacheControl": "public, max-age=31536000, immutable" },
                        { "extensions": ["m3u8"], "edgeTtlSeconds": 5, "browserCacheControl": "no-cache" } ] },
  "cors": { "allowedOrigins": ["https://learn.example.com"], "allowedMethods": ["GET", "HEAD"],
            "allowedHeaders": ["range"], "exposeHeaders": ["content-length", "content-range"],
            "maxAgeSeconds": 3600, "allowCredentials": false } }

cache:

Field Values null / not set
edgeTtlSeconds 0–31,536,000 seconds the edge keeps a copy; 0 keeps none plycdn's default
browserCacheControl a Cache-Control value of public, private, no-cache, no-store, must-revalidate, proxy-revalidate, immutable, no-transform, max-age=, s-maxage=, stale-while-revalidate=, stale-if-error= (0–31,536,000); each once, at most 200 characters; not public with private, not no-store with max-age/s-maxage. Returned lower case, , -joined plycdn's default header
rules at most 20, first match wins; replaced as a whole when sent []

A rule is {"pathPrefix": "…"} (a key prefix, following the same rules as a purge prefix below) or {"extensions": ["…"]} (1–20 of 1–16 lower-case letters or digits, without the dot; a leading dot is dropped and case folded), plus edgeTtlSeconds, browserCacheControl or both. A value a rule leaves out comes from the bucket level; a request no rule matches uses the bucket level.

cors:

Field Values null / not set
allowedOrigins 1–20 exact origins https://host[:port] (http:// only for localhost and 127.0.0.1), no path, trailing /, userinfo or wildcard label; or exactly ["*"]. Returned lower case, default port dropped, internationalised hosts as xn-- []: CORS off
allowedMethods GET, HEAD ["GET", "HEAD"]
allowedHeaders ≤ 20 HTTP token names, lower case; or exactly ["*"] []
exposeHeaders ≤ 20 HTTP token names, lower case; or exactly ["*"] []
maxAgeSeconds 0–86,400 600
allowCredentials true / false; null refused false

Refused with 422 validation_error: duplicates, * mixed with other entries, any other method, and allowCredentials: true with * as the origin, the allowed headers or the exposed headers. That last rule is judged on the settings the bucket would have after the change, so a PATCH turning credentials on while * is stored is refused with fields [["body","cors","allowCredentials"]] and a detail. A bucket that allows credentials keeps the earlier link scheme (ps2): a plycdn link (pl1) is answered with a redirect, and a browser will not carry credentials across one on a request from another origin. A bucket made with allowCredentials: true is therefore a ps2 bucket, and a PATCH turning credentials on for a bucket with plycdn links is refused with the same fields and a detail that says to use a separate bucket; see CORS. CORS settings other than origins, saved while no origins are set, are kept and apply once origins are added. CORS decides which pages' scripts may read a response; it never decides who is served.

A change to either section is a delivery-settings change: applied at the edge shortly after, with configuration_failed as for any other (see Buckets). Copies already at the edge keep the lifetime they were cached with until they expire or are purged.

Cache purges

POST /api/content/v1/buckets/{bucketId}/purges   # 202
{ "urls": ["https://course-media.plycdn.net/week-1/intro.mp4"] }

The body is exactly one of {"urls": [...]} (1–100 absolute https:// URLs, each at most 4,096 characters, on the bucket's default hostname or one of its active custom domains), {"prefixes": [...]} (1–20 key prefixes: no leading /, 1–1,024 bytes, no control or invisible characters, no \, no //, no . or .. segment such as ./a/ or a/../b/, not in the reserved .plycdn- space in any case or spelling, and not a beginning of it in any case — ., .p up to .plycdn; a cache rule's pathPrefix follows the same rule) or {"all": true}; anything else is 422 validation_error. A URL on any other host (another bucket's, a custom domain that is not active, or with a port or userinfo) is 422 purge_url_not_in_bucket. A URL's query and fragment are ignored — a purge covers every cached variant of the path — and duplicates are merged.

{ "id": "…", "bucketId": "…", "bucketName": "course-media", "scope": "urls",
  "targets": ["week-1/intro.mp4"], "state": "queued", "failureReason": null,
  "requestedBy": "api_key:…", "createdAt": "…", "startedAt": null, "completedAt": null }
GET  /api/content/v1/purges?bucketId=…&cursor=…&limit=50
GET  /api/content/v1/purges/{purgeId}

The listing answers {"items": [ purge, … ], "nextCursor": "…" | null}, newest first, optionally for one bucket; limit is 1–200 (50 when omitted). An unknown purge id, or another organization's, is 404 purge_not_found.

  • scope: urls, prefixes or all. targets: the object paths or prefixes, percent-encoded as in a URL, without the leading / ([] for all).
  • state: queued → running → succeeded or failed. A purge made right after a change to the bucket's settings stays queued until that change has reached the edge (a few minutes at most), so nothing is cached again under the old settings. failureReason is null unless failed: purge_failed (the edge did not complete it after every attempt; purge again) or bucket_not_active (the bucket was deleted before the purge ran).
  • requestedBy: api_key:<id> or user:<id>.

The bucket must be active (409 bucket_not_active otherwise, retryable while creating); a bucket plycdn has taken down ("suspended": true) can still be purged. Purges are free. Per account, at most 60 purge requests an hour, and at most 10 of them with all; past either, 429 rate_limited with Retry-After. Every request with a valid body counts, including one then refused. Each request writes one cache.purged audit entry (resourceKind bucket, detail {outcome, purgeId, scope, count}), or a refused one with its code.

Delivery analytics

GET /api/content/v1/delivery/analytics?from=2026-09-01&to=2026-09-27&bucketId=…
{ "from": "2026-09-01", "to": "2026-09-27", "bucketId": null,
  "totals": { "gb": 12.345678, "requests": 1234567 },
  "days": [ { "day": "2026-09-01", "gb": 0.5, "requests": 1200, "provisional": false,
              "regionGroups": { "india-asia": { "gb": 0.4, "requests": 1000 },
                                "europe-north-america": { "gb": 0.1, "requests": 200 },
                                "latin-america": { "gb": 0.0, "requests": 0 },
                                "middle-east-africa": { "gb": 0.0, "requests": 0 } } } ],
  "regionGroups": [ { "regionGroup": "india-asia", "label": "India & Asia", "gb": 10.1, "requests": 1000000 } ],
  "buckets": [ { "bucketId": "…", "bucketName": "course-media", "gb": 12.3, "requests": 1234567 } ] }

from and to are required UTC days, both counted, so a range spans at most 93 days; a longer range or to before from is 422 validation_error. Every day of the range is listed, zero-filled. gb is GB of 2³⁰ bytes to 6 decimals; requests a whole number. regionGroups always lists the four region groups (india-asia, europe-north-america, latin-america, middle-east-africa) in that order, with their labels. Without bucketId, buckets lists up to 100 of your organization's buckets, largest first (a deleted bucket keeps its name); with it, buckets is [], and a bucket your organization never had is 404 bucket_not_found. provisional is true on a day with any figure not yet settled (the last three days or so, as in Usage by resource); settled days are final. The figures are the same metering data as delivery_gb and requests_millions in usage, in bandwidth and requests only. There is no breakdown by HTTP status code: it is not collected.

Traffic analytics

The daily figures above need nothing switched on. For breakdowns by the labels you choose, switch analytics on for a bucket or video library (the Storage guide explains it with examples). It is on every plan; there is no plan gate. These routes need an admin key and read the organization named in X-Organization-Id; an account (the parent organization) also sees its sub-organizations when it groups by organization or names a sub-organization's bucket.

The analytics section of a bucket or library, on PATCH /buckets/{bucketId} and PATCH /libraries/{libraryId} and in every response:

PATCH /api/content/v1/buckets/{bucketId}
{ "analytics": { "enabled": true, "ceiling": 50000,
                 "dimensions": [ { "name": "course", "type": "pathSegment", "index": 1 },
                                 { "name": "customer", "type": "tag", "tag": "customer" },
                                 { "name": "campaign", "type": "queryParam", "param": "campaign" },
                                 { "name": "folder", "type": "pattern", "pattern": "^/([a-z0-9-]+)/" } ] } }
{ "analytics": { "enabled": true, "dimensions": [ … ], "ceiling": 50000, "effectiveCeiling": 50000,
                 "since": "2026-10-02T14:00:00+00:00" } }
Field Values Not set / null
enabled true / false; null refused. Counting starts at the next full hour after it is switched on false
dimensions Up to 3 rules, replaced as a whole when sent; [] removes them; null refused []
ceiling 100 to 10,000,000 distinct values per breakdown per bucket per day; null returns to your plan's your plan's

A rule is name (a lower-case letter, then lower-case letters, digits and underscores, 32 characters at most, unique in the bucket, not organization or bucket) and type with the one field it uses: pathSegment with index (1 to 8), queryParam with param (1 to 64 letters, digits, _, ., -), tag with tag (a tag name), or pattern with pattern (a regular expression of at most 200 characters with exactly one capture group). It is searched for in the path after the path is decoded once (%20 is a space), reading at most its first 2,048 bytes (so an expression anchored with $ can miss on a very long path). Plain expressions only: look-ahead, look-behind and back-references are refused. A rule with another type's field, a bad name or a bad value is 422 validation_error with fields naming it; more than 3 rules names ["body","analytics","dimensions"]. A value is cut to 128 bytes. effectiveCeiling is the figure in force (the plan's: Starter 1,000, Growth 10,000, Enterprise 100,000, unless you set one) and since is the hour counting (re)started: in the future until that hour arrives, and moved each time you switch analytics off and on (null until first switched on). Changes apply to hours counted afterwards.

Values. A request with nothing to read has the value (none); a tag that fails its check, (unverified); and when a breakdown has more distinct values in a day than its ceiling, the rest are added together as other. They are ordinary rows in every answer below.

Signed-link tags. POST /sign takes "tags": { "customer": "acme" } (at most 8; name as a rule's tag; value 1 to 128 bytes, no control characters and none of &, =, ?, #) and returns the link carrying them, once link tags are enabled for your account (the signing profile's tagsEnabled). Tags are for private buckets and never on video playback links. A refused tag is 422 validation_error with fields ["body","tags"] (or ["body","tags",<tag name>] for one tag) and a detail sentence saying which rule: tags need a private bucket, tags are not available on video playback links, link tags are not available yet, a link carries at most 8 tags, a tag name is invalid, or a tag value is invalid (as for a custom domain's hostname, detail states the rule and never repeats the value). Public links label traffic with a query parameter and a queryParam rule instead.

GET /api/content/v1/analytics/delivery?from=2026-09-01&to=2026-09-27&bucketId=…&dimension=customer&groupBy=value&top=10
Parameter Meaning
from, to UTC days, both counted. Up to 403 days (13 months); longer or to before from is 422 validation_error
bucketId One bucket or library. Without it, every bucket of the organization (and, for an account, see below). A bucket you never had is 404 bucket_not_found; a deleted bucket's history stays readable
dimension One of the bucket's breakdowns by name. Left out: the totals
groupBy value (the breakdown's values; default), bucket, or organization
top 1 to 50 (10 when omitted): the largest series are kept, the rest are added together as other
{ "from": "2026-09-01", "to": "2026-09-27", "resolution": "day", "timezone": "UTC",
  "bucketId": "…", "dimension": "customer", "groupBy": "value",
  "series": [ { "key": "acme",
                "points": [ [1788220800, 5120000, 41, 30, 11, 40, 0, 1, 0], … ] },
              { "key": "other", "points": [ … ] } ],
  "totals": { "bytes": 1234567890, "requests": 9876, "cacheHits": 8000, "cacheMisses": 1876,
              "status": { "2xx": 9800, "3xx": 20, "4xx": 50, "5xx": 6 } },
  "topCountries": [ { "country": "IN", "requests": 6000 } ],
  "notices": [ { "kind": "ceiling_reached", "organization": "…", "dimension": "customer",
                 "from": "2026-09-10T00:00:00+00:00", "to": "2026-09-12T00:00:00+00:00" } ],
  "provisional": false }
  • Points are columnar. Each point is a flat list: [epochSeconds, bytes, requests, cacheHits, cacheMisses, status2xx, status3xx, status4xx, status5xx], epochSeconds being the start of the hour, day or month (UTC). Every series has a point for every time step of the range, zero where nothing was served.
  • resolution is hour for a range of up to 14 days, day up to 400 days, and month beyond that.
  • Without a dimension and with groupBy=value, the one series has the key total.
  • groupBy=bucket series have a label with the bucket's name; groupBy=organization has one series per organization (account only). other is always last.
  • topCountries is the ten countries with the most requests, as {country, requests}.
  • provisional is true while any figure in the range can still change (the most recent hours). Read the range again later.
  • notices (at most 200) each have kind, organization, dimension, from and to: ceiling_reached (values were grouped as other, for that breakdown, those days), hours_missing (hours that could not be counted yet or at all) and logging_disabled (hours when analytics was off, with dimension null).
GET /api/content/v1/analytics/delivery/breakdown?month=2026-09&by=dimension&dimension=customer&bucketId=…&format=json

One UTC calendar month. by is organization (the default: one row per organization and bucket) or dimension (one row per value of the breakdown named by dimension, which is then required, and always including other and (none) rows even when zero). Largest first. With by=organization, dimension and value are empty strings.

{ "month": "2026-09", "by": "dimension", "timezone": "UTC", "bucketId": "…", "dimension": "customer",
  "rows": [ { "organization": "…", "bucketId": "…", "bucket": "course-media", "dimension": "customer",
              "value": "acme", "bytes": 5120000, "requests": 41, "cacheHits": 30, "cacheMisses": 11 } ],
  "provisional": false }
  • JSON holds at most 50,000 rows; a month with more is 422 too_many_rows: ask for format=csv.
  • format=csv downloads delivery-<month>.csv with the columns organization, bucket, dimension, value, bytes, requests, cache_hits, cache_misses, up to 1,000,000 rows (more is too_many_rows). A cell that begins with =, +, - or @ (or their full-width forms, or a tab or return), even after spaces, is written with a leading apostrophe so a spreadsheet shows text; strip it if you read the file with a program. A plain -5 is written '-5. One download at a time per account, and the service allows only a few at once overall; a download refused for either reason is 429 export_busy (retryable, Retry-After 5 seconds): wait and ask again. If a download stops early, request it again.
  • Accounts. The parent organization's groupBy=organization and by=organization read every organization that has a bucket under the account (one row per organization and bucket in the month); naming a sub-organization's bucketId reads that organization. For an account, either of those (and any read naming a bucketId) is 422 too_many_organizations when more than 500 organizations have buckets under it. Read one sub-organization at a time by sending its id as X-Organization-Id (it sees only itself), or read your own organization without bucketId.
POST /api/content/v1/buckets/{bucketId}/analytics/preview
{ "rule": { "name": "course", "type": "pathSegment", "index": 1 } }
{ "sampleSize": 2000, "distinctValues": 14, "basis": "traffic", "note": null,
  "examples": [ { "value": "cs101", "count": 640 } ],
  "estimatedValuesPerMonth": 14, "estimatedMonthlyPrice": { "currency": "INR", "amount": "…" } }

Tries one rule on the bucket's recent requests, or on its file names when it has had no traffic (basis: "files"), and changes nothing. examples are the commonest values (at most 10). estimatedValuesPerMonth is at least that many a month, and estimatedMonthlyPrice is the list price of that many tracked values in your account's currency (null when none is set). note says why a preview is limited: a tag rule cannot be previewed from recent traffic (tags are not kept in it), and tag and queryParam rules cannot be tried on file names. Needs the bucket (404 bucket_not_found) and a valid rule (422 validation_error).

If the figures cannot be read at that moment, any of these routes answers 503 analytics_unavailable (retryable): try again shortly. Your delivery is never affected.

What is kept. Individual requests only for a few days; hourly and daily totals by breakdown value for 13 months. Analytics adds two lines to your statement on every plan, Requests analysed (each 100,000 requests) and Tracked values (each 100 distinct values per bucket per month), at the prices on your price list; see Plans and prices.

Audit

GET /api/content/v1/audit?from=2026-09-01&to=2026-09-30&action=bucket.deleted&resourceId=…&cursor=…
{ "items": [ { "at": "…", "actor": "user:…", "action": "bucket.deleted", "resourceKind": "bucket",
               "resourceId": "…", "detail": {}, "ip": "203.0.113.7" } ], "nextCursor": null }

Newest first. from and to are UTC days, both inclusive; limit is 1–500 (100 when omitted). actor is user:<id> for a dashboard user, api_key:<id> for a key, or plycdn for plycdn itself (provisioning, purges, checksum checks, domain checks, takedowns and migrations). Settings changes, key rotations, upload completions, deletions, cache purge requests (cache.purged) and takedowns (takedown.object, takedown.bucket, takedown.reinstated; for a video, video.disabled and video.reinstated) are all recorded here, and so are files plycdn brings into a bucket for you (bucket.adopted, object.adopted) and every custom domain's life (resourceKind domain): domain.added, domain.check_requested, domain.verified, domain.active, domain.failed, domain.detached, domain.deleted and domain.released (when we have stopped serving a deleted domain). Reads through a signed or public link are counted in usage, not logged per view.

Storage credentials

Keys for S3 and Azure tools (S3 and Azure compatibility). Like API keys, they are managed by people in the dashboard only (Developers → Storage credentials). Listing answers 403 dashboard_session_required to every API key. Create, rotate and revoke answer 403 admin_key_required to a standard key and 403 dashboard_session_required to an admin key. No SDK has a method for them. They are documented so you know what the dashboard does and what it returns.

GET    /api/content/v1/storage-credentials
POST   /api/content/v1/storage-credentials                            # 201
POST   /api/content/v1/storage-credentials/{credentialId}/rotate
DELETE /api/content/v1/storage-credentials/{credentialId}
// POST body
{ "name": "nightly backup", "kind": "s3", "permissions": ["read", "write"],
  "bucketIds": ["…"], "expiresAt": null }
// A credential
{ "id": "…", "name": "nightly backup", "kind": "s3", "accessKeyId": "PLYEXAMPLEKEYID23456",
  "slot": null, "secretHint": "…abcd", "permissions": ["read", "write"], "bucketIds": ["…"],
  "state": "active", "expiresAt": null, "createdBy": "user:…", "createdAt": "…",
  "lastUsedAt": null, "revokedAt": null }

kind is s3 or azure. An S3 credential has an accessKeyId; an Azure one has a slot (1 or 2, Azure's key1 and key2) and needs the storage account to exist first (storage_account_not_found). permissions are any of read, write, delete and manage. bucketIds is null for every bucket of the organization, including later ones, or up to 100 of its buckets (another organization's bucket is bucket_not_found); manage is allowed only with null (validation_error otherwise). expiresAt is null (no expiry) or a future time with its timezone offset (2027-01-31T00:00:00Z, 2027-01-31T00:00:00+05:30); a time without an offset, or one in the past, is validation_error. state is active or revoked; once expiresAt has passed, the credential is refused on every request although its state stays active.

Creating and rotating add "secret" to the response: the secret access key (S3) or account key (Azure), shown this once and never again. rotate creates a second credential with the same name, permissions and buckets (an Azure key goes into the other slot) and leaves the old one working until you revoke it; DELETE revokes it (every new request signed with it is refused within 30 seconds), and revoking a revoked credential changes nothing. At most 20 credentials can be active per organization, and an Azure account holds two keys at a time (credential_limit_reached); revoked and expired credentials do not count. An unknown id, another organization's, or rotating a revoked or expired one is credential_not_found.

Storage account

Your organization's name and home region for Azure tools (S3 and Azure compatibility). The account is configuration, not a credential, so it is also available from your backend (GetStorageAccountAsync, CreateStorageAccountAsync, UpdateStorageAccountAsync in Plycdn.AspNetCore.Storage). Creating it and changing it need an admin key (admin_key_required otherwise); reading it works with either key.

GET   /api/content/v1/storage-account
POST  /api/content/v1/storage-account            # 201
{ "name": "acmelearning", "homeRegion": "mumbai" }
PATCH /api/content/v1/storage-account
{ "homeRegion": "mumbai" }
{ "name": "acmelearning", "homeRegion": "mumbai",
  "blobEndpoint": "https://acmelearning.blob.plycdn.net", "dnsState": "active", "createdAt": "…" }

name is 3 to 24 lower-case letters and digits, unique across plycdn, and cannot be changed (a PATCH carrying name is validation_error). Reserved names and region codes are validation_error; a name already taken is storage_account_name_taken; a second account for the same organization is storage_account_exists; GET or PATCH before one exists is storage_account_not_found. homeRegion is a region code (region_not_available otherwise). blobEndpoint is the account's global host; dnsState is pending until that host resolves after a create or a home-region change, then active, or failed if it could not be set up (contact us). The regional hosts (endpoints.blob on GET /regions) work from the start.

31Video and webhooks

Video libraries, their videos, playback links and webhooks. Walkthroughs, the player and SDK usage are in Video and webhooks; this is the wire contract, under the same base address and authentication as the rest of this guide. Video is a plan feature: without it, creating a library or a video answers not_included_in_plan.

The three creating calls, POST /libraries, POST /libraries/{libraryId}/videos and POST /webhooks, require an Idempotency-Key header (idempotency_key_required without one): repeating one with the same key and body returns the first answer, so a timeout never creates a second library, video or endpoint, and the same key with a different body is idempotency_conflict. The .NET package sends a fresh key for each call unless you pass your own.

Video libraries

POST /api/content/v1/libraries          # 202
{ "name": "acme-lectures", "region": "mumbai", "strictResidency": false,
  "delivery": { "allowedDomains": ["learn.example.com"] },
  "processing": { "ladder": [240, 360, 480, 720, 1080], "keepOriginal": true, "download": false, "thumbnails": true },
  "maxObjectBytes": null }
{ "id": "…", "name": "acme-lectures", "kind": "video", "region": "mumbai", "strictResidency": false,
  "visibility": "private", "state": "creating", "failureReason": null, "suspended": false,
  "delivery": { "allowedDomains": ["learn.example.com"], "blockNoReferrer": false,
                "countriesAllow": [], "countriesBlock": [], "linkTtlSeconds": 14400,
                "ipBinding": false, "rateLimitKbps": null },
  "uploadRules": { "maxObjectBytes": null, "allowedTypes": ["video/*"], "allowOverwrite": false },
  "defaultHostname": "acme-lectures.plycdn.net",
  "deliveryBaseUrl": "https://acme-lectures.plycdn.net",
  "storedBytes": 0, "objectCount": 0, "createdAt": "…", "updatedAt": "…",
  "deletedAt": null, "purgeAfter": null,
  "processing": { "ladder": [240, 360, 480, 720, 1080], "keepOriginal": true, "download": false, "thumbnails": true },
  "processingOrigins": { "ladder": "parent", "keepOriginal": "default", "download": "default", "thumbnails": "default" },
  "usesOrganizationProcessing": true,
  "videoCount": 12 }

A library is the Bucket shape with kind: "video", plus processing (what it encodes with now), processingOrigins (for each setting, library, organization, parent or default), usesOrganizationProcessing and videoCount. Only name and region are required; the name rules are a bucket's. A library is always private (sending visibility is 422 validation_error, fields [["body","visibility"]]), takes video/* uploads only and never overwrites (uploadRules.allowedTypes and allowOverwrite cannot be changed, here or through PATCH /buckets/{bucketId}), and its links last 4 hours (linkTtlSeconds: 14400) unless you set delivery.linkTtlSeconds. Leave processing out and the library uses its organization's video settings (videoDefaults, under Video settings in the settings section); given, it is the library's own, and a field you leave out takes the organization's value as it is when saved. ladder is one or more of 240, 360, 480, 720, 1080, 1440, 2160, each once, returned in ascending order; a height that is not one of those is validation_error with its index ([["body","processing","ladder",0]]), and a repeated one names the list ([["body","processing","ladder"]]).

GET    /api/content/v1/libraries
GET    /api/content/v1/libraries/{libraryId}
PATCH  /api/content/v1/libraries/{libraryId}
DELETE /api/content/v1/libraries/{libraryId}    # 202

GET /libraries answers {"items": [ … ]}: your libraries only, never your files buckets (which GET /buckets still lists, along with libraries). An unknown id, a files bucket's id or another organization's library answers 404 library_not_found. PATCH takes delivery, processing and maxObjectBytes: a field left out is unchanged; processing merges field by field, gives the library its own settings and applies to videos encoded afterwards; "processing": null makes the library use its organization's settings again; null on one of its fields is refused (validation_error, fields naming it), because each always has a value; null on a delivery setting removes it exactly as on a bucket, except that "linkTtlSeconds": null returns to the library default of 4 hours. maxObjectBytes: null returns to your plan's largest file. DELETE is the bucket's soft delete ("state": "deleting", purgeAfter), and deletes the library's videos. Restore, retry, the signing profile, key rotation and listing objects are the /buckets/{bucketId} routes, called with the library's id. Libraries count towards your plan's bucket limit (plan_limit_reached). A suspended library ("suspended": true) still lists, reads, changes and deletes, and refuses new videos and playback links with 403 bucket_suspended.

Videos

POST /api/content/v1/libraries/{libraryId}/videos    # 201
{ "title": "Week 1: Introduction", "size": 734003200, "contentType": "video/mp4",
  "sha256": null, "fileName": "week-1.mp4", "metadata": { "course": "physics-101" } }
{ "id": "…", "libraryId": "…", "title": "Week 1: Introduction", "status": "ready", "errorCode": null,
  "durationSeconds": 612.48, "width": 1920, "height": 1080, "hasAudio": true,
  "renditions": [ { "height": 240, "width": 426, "bandwidth": 524000 },
                  { "height": 1080, "width": 1920, "bandwidth": 5478000 } ],
  "captions": [], "poster": true, "thumbnails": true, "download": false,
  "encoding": { "ladder": [240, 360, 480, 720, 1080], "keepOriginal": true, "download": false, "thumbnails": true,
                "origins": { "ladder": "parent", "keepOriginal": "default", "download": "default", "thumbnails": "default" } },
  "sourceKey": "sources/week-1.mp4", "originalKept": true, "storedBytes": 734003200, "encodingMinutes": 10.208,
  "disabled": false, "metadata": { "course": "physics-101" }, "uploadId": "…",
  "createdAt": "…", "updatedAt": "…", "playableAt": "…", "readyAt": "…" }

That is the Video shape. The 201 from creating one is the Video shape ("status": "uploading") plus "upload": an Upload session (see "Uploads" above) with "videoId" set to this video and the key sources/{videoId}{extension}. Send its parts and complete it as any upload; completion moves the video to queued. title is 1 to 200 characters, size at least 1, fileName at most 200 characters, contentType a video/… type, metadata up to 50 keys of strings, numbers or booleans and at most 8 KiB as JSON. An Idempotency-Key header is required (see below). A library that is not active answers bucket_not_active.

A browser upload directly under sources/ in a library (POST /buckets/{libraryId}/uploads with "key": "sources/week-2.mp4") creates a video when it completes, titled after the file name; the completion answer and GET /uploads/{uploadId} carry its "videoId" (null on any other upload). A library upload whose key is not directly under sources/ is refused with 422 validation_error, fields [["body","key"]].

GET    /api/content/v1/libraries/{libraryId}/videos?status=ready&cursor=…&limit=50
GET    /api/content/v1/videos/{videoId}
PATCH  /api/content/v1/videos/{videoId}
DELETE /api/content/v1/videos/{videoId}    # 202
POST   /api/content/v1/videos/{videoId}/reencode    # 202

POST .../reencode encodes a ready video again with the settings that apply now (admin key). It answers the video, now queued; it keeps playing from its current qualities until the first new one is made, and the usual events follow. A video that is not ready, or is taken down, answers 409 video_not_ready; a video whose original was not kept answers 409 original_not_kept. Each re-encode is charged encoding minutes like the first encode, as its own usage entry. A video's encoding (the four settings used and where each came from, or null) is part of the Video shape.

The list answers {"items": [ …Video… ], "nextCursor": "…"|null}, newest first; status filters, limit is 1 to 200 (50 when omitted), and cursor is followed until nextCursor is null. PATCH takes title and metadata (which replaces the whole object); a field left out is unchanged. DELETE answers {"id": "…", "status": "deleted"} and removes every file of the video at once, its source file under sources/ included: there is no restore. While the video's upload is being completed, DELETE answers 409 upload_not_open marked "retryable": true: ask again shortly. An unknown, deleted or another organization's video answers 404 video_not_found.

status is uploading, queued, encoding, playable (the lowest quality plays), ready or failed. A failed video's errorCode is one of:

errorCode Meaning
video_unreadable The file could not be read as video (damaged, truncated, zero length, or not the format its name says)
video_no_video_stream The file has no picture (audio only)
video_too_long Longer than your plan allows
video_resolution_unsupported Smaller than 16 or larger than 8192 pixels on a side
video_source_missing The uploaded file was deleted before encoding finished
upload_abandoned The upload created with the video was aborted or expired before completing
encoding_failed Encoding failed on our side after every retry. Upload the file again

encodingMinutes is the source's duration in seconds ÷ 60, to six decimal places, counted when the video becomes ready (and again, as its own entry, each time it is encoded again); it is null until then, and a failed video is never counted. It appears in /usage as encoding_minutes (unit minutes) and in "Usage by resource" against the library. storedBytes is what the video holds in its library (qualities, images, download, kept original), which counts as that library's storage. A video plycdn has taken down shows "disabled": true. captions lists the video's caption tracks once captions are added to it.

POST /api/content/v1/sign
{ "bucketId": "<libraryId>", "videoId": "<videoId>", "ttlSeconds": 14400, "ip": null }
{ "url": "https://acme-lectures.plycdn.net/v/<videoId>/master.m3u8?token=…&expires=1790000000&token_path=%2Fv%2F<videoId>%2F",
  "nativeUrl": null, "expiresAt": "…",
  "posterUrl": "https://acme-lectures.plycdn.net/v/<videoId>/poster.jpg?token=…",
  "thumbnailsUrl": "https://acme-lectures.plycdn.net/v/<videoId>/thumbnails.vtt?token=…",
  "downloadUrl": null }

Send exactly one of key, objectId and videoId (more than one, or none, is 422 validation_error). With videoId, the link covers every file under that one video (scheme pl1d, or ps3 on an earlier library, documented under Signing playback yourself in Video and webhooks) and nothing else. url is the stream's master playlist. nativeUrl is the same with the token in the path, for browsers that play the stream natively, and is null wherever the signing profile's pathTokens is false. posterUrl, thumbnailsUrl and downloadUrl are null when the video has no such file. ttlSeconds defaults to the library's linkTtlSeconds; ip works as for a file. A video that is not playable or ready answers 409 video_not_ready; a video from another library, or one taken down, answers 404 video_not_found; a suspended library answers 403 bucket_suspended.

A library's signing profile (GET /buckets/{bucketId}/signing-profile) carries two more fields, "directoryScheme": "pl1d" (or "ps3" on a library made before plycdn links) and "pathTokens": false|true (on a files bucket, null and false).

Webhooks

POST /api/content/v1/webhooks    # 201
{ "url": "https://api.customer.example/plycdn", "events": ["video.ready", "video.failed"], "description": "LMS" }
{ "id": "…", "url": "https://api.customer.example/plycdn", "events": ["video.ready", "video.failed"],
  "description": "LMS", "active": true, "secretRotatedAt": null, "previousSecretExpiresAt": null,
  "createdAt": "…", "updatedAt": "…",
  "secret": "pwhs_…" }

That is the Webhook shape; secret is in the create answer only, shown once. url is https:// only, at most 2048 characters (anything else is 422 validation_error, fields [["body","url"]]). It is 400 webhook_url_invalid unless it is printable ASCII (write an international name in its xn-- form and percent-encode the path), on port 443, without credentials (user:password@), and its name resolves to public addresses only (not private, local, link-local, multicast or otherwise non-public); the answer never says which. events is one to 20 of video.playable, video.ready and video.failed; an unknown event, or webhook.ping, is validation_error with its index. description is optional, up to 200 characters. An organization has at most 10 endpoints (409 webhook_limit_reached). Endpoints belong to one organization: a sub-organization's events go to its own endpoints only.

Repeating the create with the same Idempotency-Key answers the same endpoint with the same secret while that secret is still the endpoint's current one. After a rotation the repeat answers the endpoint as it is now, without secret (the rotation's answer carried the new one); after a delete it is 404 webhook_not_found.

GET    /api/content/v1/webhooks
GET    /api/content/v1/webhooks/{webhookId}
PATCH  /api/content/v1/webhooks/{webhookId}
DELETE /api/content/v1/webhooks/{webhookId}               # 204
POST   /api/content/v1/webhooks/{webhookId}/secret/rotate
POST   /api/content/v1/webhooks/{webhookId}/ping           # 202
GET    /api/content/v1/webhooks/{webhookId}/deliveries?state=failed&cursor=…&limit=50
POST   /api/content/v1/webhook-deliveries/{deliveryId}/redeliver    # 202

GET /webhooks answers {"items": [ …Webhook… ]}. PATCH takes url, events, description and active: a field left out is unchanged, and "description": null removes the description. "active": false pauses the endpoint: nothing is sent to it, and a delivery due while it is paused (a ping included) ends failed with lastError null and no lastStatus, since no request was made. An unknown, deleted or another organization's endpoint answers 404 webhook_not_found.

secret/rotate answers the new secret, shown once, and when the old one stops signing:

{ "secret": "pwhs_…", "previousSecretExpiresAt": "…" }

For 24 hours every delivery is signed with both secrets.

ping queues a real, signed webhook.ping to the endpoint ("data": {"webhookId": "…"}) and answers the delivery. deliveries lists an endpoint's deliveries newest first (state is pending, succeeded or failed; limit 1 to 200, 50 when omitted; follow nextCursor). The log keeps the last 90 days: a finished delivery (succeeded or failed) is removed 90 days after its createdAt. A page:

{ "items": [
  { "id": "…", "webhookId": "…", "eventId": "…", "event": "video.ready", "state": "pending",
    "attempts": 2, "nextAttemptAt": "…", "lastStatus": 503, "lastError": "http_error", "lastDurationMs": 412,
    "lastAttemptAt": "…", "deliveredAt": null, "createdAt": "…",
    "payload": { "id": "…", "type": "video.ready", "createdAt": "…", "organizationId": "…", "data": { "video": { "…": "…" } } } } ],
  "nextCursor": null }

nextAttemptAt is null unless the delivery is pending. redeliver sends a finished (succeeded or failed) delivery once more with the same id and body, and answers it pending. A delivery still pending, or one whose endpoint is paused, is answered as it is (202, unchanged): resume the endpoint first. An unknown delivery, one whose endpoint is deleted, or another organization's answers 404 webhook_delivery_not_found.

ping, redeliver and secret/rotate together are limited to 60 an hour for your account (across its organizations; refused calls count): past that, 429 rate_limited with Retry-After, and nothing is sent or changed.

What we send. POST <url> with a compact JSON body {"id": <event id>, "type": <event>, "createdAt": <ISO 8601>, "organizationId": <uuid>, "data": {"video": <Video shape>}} and the headers Content-Type: application/json, User-Agent: plycdn-webhooks/1, Plycdn-Event, Plycdn-Delivery (the delivery id, the same on every retry), Plycdn-Webhook (the endpoint id) and Plycdn-Signature: t=<unix seconds>,v1=<hex>, where v1 = hex(HMAC-SHA256(key = utf8(secret), msg = utf8(t) + "." + body)) over the exact body bytes sent. During a rotation's 24 hours a second v1, signed with the previous secret, follows the first. Verify before trusting a request, and refuse a t more than 5 minutes from your clock (see Webhooks in Video and webhooks).

Answers and retries. Any 2xx within 10 seconds is success. Anything else is retried after 1, 5, 15 and 30 minutes, then 1, 2, 4, 8, 12, 24 and 24 hours: 12 attempts over about 3.1 days, then failed. 410 Gone fails the delivery at once. Redirects are not followed, and your response body is never read or kept. Each attempt's outcome is in lastError (with the answer's status in lastStatus):

lastError Meaning
timeout No answer within 10 seconds
connection_failed The connection was refused or dropped
tls_failed The TLS handshake failed (an invalid or untrusted certificate)
dns_failed The endpoint's name did not resolve
address_forbidden The name resolved to a private, local or otherwise non-public address; nothing was sent
redirect_not_followed The endpoint answered with a redirect
http_error The endpoint answered with a status other than 2xx (lastStatus)

32Match navigation in your own interface

A grouped search (group: 'file') returns one result per file, with every place it matched in moments. The Angular preview moves between them itself ("Moving between matches in the preview" in the Integration guide). An interface of your own can use the same helpers, exported by both browser packages, @plycdn/content-search and @plycdn/content-search-react:

Helper Returns
matchesInOrder(result) The result's moments in file order: by time (playFromSeconds, else startSeconds), else page, slide, sheet and row, line, then paragraph. Matches at the same place keep the order they came in. A result without moments is a single match: the result itself.
matchIndex(ordered, location) The position of the match at location, or -1.
adjacentMatch(ordered, index, step) The match at index + step (step is 1 or -1), or undefined past either end. From -1, the next match is the first.
markerPositions(ordered, duration) { index, fraction, seconds } for each timed match within 0..duration; none until duration is finite and above zero.
matchPointer(moment, labels) The short name a button shows — 7:55, page 4, slide 3, Sheet1 row 12, line 40 — from the pointer* labels; a match with none of those is named by its title or heading, else "Passage n".

Two matches can share a location (two passages in the same second): after the first lookup, keep the position you moved to rather than looking it up again by location.

The labels' defaults are in DEFAULT_LABELS: matchPrevious Previous: {pointer}, matchNext Next: {pointer}, matchPosition Match {n} of {total}, matchAnnounce Match {n} of {total}, {pointer}, pointerTime {time}, pointerPage page {page}, pointerSlide slide {slide}, pointerRow row {row}, pointerSheetRow {sheet} row {row}, pointerLine line {line}, and names for player controls (play, pause, mute, unmute, volume, seek, back10, forward10, speed, quality, qualityAuto, captions, captionsShort, captionsOff, fullscreen, exitFullscreen). Translate by spreading your own over them.

Previous / Next and seek-bar markers in React (position the track relative and its buttons absolute):

import { useState } from 'react';
import { adjacentMatch, DEFAULT_LABELS as L, markerPositions, matchesInOrder, matchIndex, matchPointer,
  type SearchResult } from '@plycdn/content-search-react';

const fill = (t: string, v: Record<string, string | number>) => t.replace(/\{(\w+)\}/g, (_, k: string) => String(v[k] ?? ''));

export function Matches({ result, duration, seek }: { result: SearchResult; duration: number; seek: (seconds: number) => void }) {
  const ordered = matchesInOrder(result);
  const [at, setAt] = useState(() => matchIndex(ordered, result.location));
  const go = (i: number) => { const m = ordered[i]; if (!m) return; setAt(i); seek(m.location.playFromSeconds ?? m.location.startSeconds ?? 0); };
  const prev = adjacentMatch(ordered, at, -1), next = adjacentMatch(ordered, at, 1);
  return (<div>
    <div className="track">{markerPositions(ordered, duration).map(m => (
      <button key={m.index} style={{ left: `${m.fraction * 100}%` }} onClick={() => go(m.index)}
        aria-label={fill(L.matchAnnounce, { n: m.index + 1, total: ordered.length, pointer: matchPointer(ordered[m.index]!, L) })} />))}</div>
    <button aria-disabled={!prev} onClick={() => go(at - 1)}>{fill(L.matchPrevious, { pointer: prev ? matchPointer(prev, L) : '—' })}</button>
    <span>{fill(L.matchPosition, { n: at + 1, total: ordered.length })}</span>
    <button aria-disabled={!next} onClick={() => go(at + 1)}>{fill(L.matchNext, { pointer: next ? matchPointer(next, L) : '—' })}</button>
  </div>);
}

33Public pricing

Our list prices and the regions we offer are published without signing in, so a page of your own can show them without copying numbers that may change:

GET https://api.plycdn.com/api/public/pricing

No key or session is needed, and nothing about your account is in the answer. It is read-only, cached for a few minutes, and limited per address. If it cannot be produced right now it answers 503 with the code pricing_unavailable; try again shortly.

{
  "asOf": "2026-10-01",
  "currencies": ["INR", "USD"],
  "plans": [
    { "code": "starter", "name": "Starter",
      "features": [{ "code": "analytics", "label": "Search analytics" }],
      "limits": { "maxAssets": 2000, "storageRequestsPerMinute": 36000 },
      "dashboardSearchAllowance": 100 }
  ],
  "metrics": [{ "code": "searches", "label": "Searches", "unit": "searches", "dimension": null }],
  "prices": {
    "starter": { "INR": { "searches": [
      { "dimension": null, "tiers": [{ "from": 0, "unitPrice": 0.5, "included": 0 }] } ] } }
  },
  "regions": [
    { "code": "mumbai", "name": "Mumbai, India", "country": "IN", "strictResidency": true,
      "services": { "storage": true, "video": true, "search": true } }
  ]
}
  • prices is keyed by plan code, then currency, then metric code. Each metric lists one entry per dimension (a region or delivery area, with its key and display label; null when the price does not depend on one), and each entry lists its tiers: from from units a month, each unit costs unitPrice (in the currency's main unit), after the first included units.
  • Only list prices in force today are shown, for plans you can choose, and only for regions you can use. Enterprise terms are agreed with us.
  • strictResidency says whether a region can keep a bucket's processing inside its country.

34Errors

Errors are JSON with a stable code. Match on the code, never on the message. The one exception is a rate-limit rejection at the gateway, which carries a plain error message instead; creating keys and sub-organizations too fast answers rate_limited. Routes under /api (keys, sub-organizations, the audit) also carry a readable error: match on code.

Request bodies. The account routes (/api/sub-organizations, /api/account-audit) and the dashboard's own account and sign-in routes read at most 100 KB (413 payload_too_large). Request bodies are not accepted compressed (Content-Encoding) except on /api/content/v1 and the dashboard's content routes (/api/content-settings, /api/content-activity, /api/content-add, /api/content-admin, /api/content-connectors), and there only once the Authorization credential has verified: anywhere else, the dashboard's storage relay (/api/content-media) included, a compressed body is 415 unsupported_media_type. None of the SDKs compresses a request.

{ "code": "source_host_not_allowed", "retryable": false }

retryable: true means the same request may succeed later. Back off; do not retry in a tight loop. Validation errors (422, which add fields) and the refusals made at the gateway before a request reaches the service (a missing or invalid key, an organization your key does not cover, a rate limit) carry code without retryable: treat a missing retryable as false. Responses to requests that reached the API carry an X-Request-Id header; a refusal made before that point may not.

Code Status What to do
invalid_api_key 401 Check the key and that it has not been revoked or expired
api_key_required 401 No key in the Authorization: ApiKey header, or a key sent in the query string (refused on every route) or in a JSON request body (never read as a key). Send it in the header only
asset_not_found, job_not_found, import_not_found 404 Unknown id, or it belongs to another organization
validation_error 422 The body, path or query does not match the shape of the request. fields lists where each fault is — [["body","query"]] means query is missing, empty or too long. Where the value itself is safe to repeat - a storage host that is not a bare hostname - detail says what is wrong with it; for a custom domain's hostname, detail says what is wrong with its form (never repeating the value). The sub-organization routes answer fields with the first field at fault; their older field (that field's name) is still sent for older integrations: read fields
empty_query 400 A search needs a non-blank query. A query of only spaces passes the schema and is refused here
payload_too_large 413 The body is too large: over 10 MiB on the content API (register a source URL rather than posting content), over 64 KiB on the dashboard's storage relay (/api/content-media), whose requests are all small JSON, or over 100 KB on the account routes (/api/sub-organizations, /api/account-audit) and the dashboard's own account and sign-in routes
invalid_content_length 400 The Content-Length header is missing or not a number. On a storage part upload, also a chunked body (Transfer-Encoding): a part must declare its exact length up front
invalid_source_url 400 A sourceUrl must be https, on port 443, with no embedded credentials and no #fragment
ambiguous_source_credentials 400 The URL carries a query string and a sasToken was supplied. Send the credential once, in one place
organization_required 400 The X-Organization-Id header is missing or not a UUID
organization_forbidden 403 The key does not cover that organization, or the sub-organization has been deactivated
source_host_not_allowed 400 Add the host under settings, then register again
source_url_must_be_unsigned 400 Put credentials in sourceAccess, not in sourceUrl
source_access_mismatch 400 The access URL points at a different file
source_version_already_registered 409 That externalId and sourceVersion already exist
source_version_changed 409 The file changed in your storage; register the new version
source_version_unverifiable 409 Your storage returned no ETag; supply a sha256: version
source_access_expired 403 A stored link is no longer valid. Supply fresh access, or move the file to a callbackKey so it is renewed for you
source_callback_refused 403 Your access callback did not answer 200. Nothing needs to be sent to us — check that it is up and that it resolves that file's externalId. Retried automatically
callback_not_configured 400 At registration: you sent a callbackKey but no callbackUrl is set in your settings. Set it, then register again. (On a job it is 503: the settings no longer carry the callback)
organization_daily_limit 429 A protective daily limit on processing was reached; work resumes tomorrow. Contact us if you expect this volume
quota_exceeded 429 Not retryable until your quota is raised or the month resets. detail names the metric, used, quota and the reset date
file_too_large 413 Beyond the configured limit; GET /capabilities gives the sizes
unsupported_document_format 400 Convert to a supported format
ocr_required 400 The document is images with no text layer
asset_busy 409 A job is already running for that asset; wait for it to finish
not_included_in_plan 403 That feature is not part of your plan — GET /plan says what is (the feature names are listed under What your plan includes)
plan_limit_reached 403 Your plan's ceiling for that resource is reached
idempotency_key_required 400 Send an Idempotency-Key header on that write (see Retries under Practical notes for which writes need one)
idempotency_conflict 409 The same key was used for a different request
revision_conflict 412 The If-Match revision is stale; read it again and retry
collection_not_found 400 / 404 400 when registering a file against a collection that does not exist (create the collection first); 404 when you address a collection that does not exist
external_id_mismatch, name_mismatch 400 The body contradicts the id in the path
job_not_cancellable 409 The job already finished
transcript_not_ready 409 Indexing has not produced a transcript yet
invalid_segment_ids 400 A correction referred to segments that are not in that transcript
too_many_principals, too_many_synonyms, filter_too_many_values, too_many_pages, package_too_many_entries 400 Beyond a documented ceiling — the detail says which
filter_value_not_scalar 400 A filter value must be a string, number or boolean
invalid_period 400 month is not YYYY-MM, a date is not YYYY-MM-DD, to is before from, or both month and from/to were given. detail says which
period_too_long 400 Usage covers at most 400 days per request
usage_unavailable 503 Retryable. Your usage figures cannot be computed right now — on /usage, /usage/daily, /usage/assets or /usage/quotas. Nothing about your request is wrong, and your searches, answers and indexing carry on as normal. Try again later, and contact us if it persists — we are alerted to it
processing_temporarily_unavailable 503 Retryable. A temporary problem on our side — on a search, an answer or any other call. Nothing about your request is wrong. Back off and try again; contact us if it persists — we are alerted to it. The same code on a job is explained under Why a file failed to index
processing_failed 502 Not retryable. A problem on our side that repeating the same request will not fix — nothing about your request is wrong. Contact us with the x-request-id; we are alerted to it
statement_not_found 404 That month has never been closed into a statement
invalid_callback 401 A connector's notifyUrl was altered or has been retired (see Instant sync from storage events)
invalid_metric 400 /usage/daily's metric is not one of your catalogue's metrics
invalid_breakdown 400 /usage/daily's breakdown is not organization
answers_not_configured 503 Written answers are not enabled for your account
credential_required 400 A connector needs an access token to read the source
connector_name_taken 409 Another connector already has that name
connector_not_found 404 Unknown, disabled, or another organization's
connector_kind_unsupported 400 That connector type is not available here
region_not_available 400 Storage — the region code doesn't exist, or isn't open yet; read GET /regions
bucket_not_found 404 Storage — unknown id, or it belongs to another organization
bucket_name_taken 409 Storage — bucket names are unique across plycdn; held for 90 days after a purge, and for good (for other accounts) if the bucket was ever public
bucket_not_active 409 Storage — the bucket is creating, failed, deleting or deleted; check state. Retryable only while creating (it becomes active on its own); otherwise retry, restore or recreate it
bucket_suspended 403 Storage — plycdn has taken the bucket down ("suspended": true): no uploads and no signed links until it is reinstated; listing, reading and deleting still work. Contact [email protected]
bucket_hostname_unavailable 503 Storage — could not allocate a default hostname for the bucket; retry the create. Retryable
certificate_pending — Storage — a bucket failureReason: provisioning ran out of attempts still waiting on the bucket's TLS certificate; retry
configuration_failed — Storage — a bucket failureReason: a delivery-settings change did not reach the edge after every attempt; the bucket keeps serving (or not) as before, and the next successful change clears it
no_cdn_capacity — Storage — a bucket failureReason: no delivery capacity could be reserved for provisioning; retry, or try again later
no_storage_capacity — Storage — a bucket failureReason: no storage capacity could be reserved for the bucket's region; retry, or try again later
domain_not_found 404 Storage — unknown domain id, or another organization's (a deleted domain is not found either)
domain_exists 409 Storage — this organization already has that hostname, in any spelling; use the existing domain (GET /domains) or delete it first
domain_limit_reached 409 Storage — the bucket already has 8 domains; delete one first
domain_rate_limited 429 Storage — too many domain additions for your account (20 an hour and 100 a day, across your organizations), or a check-now within a minute of the last one on that domain. Retryable: wait for the Retry-After seconds
hostname_not_allowed 422 Storage — a plycdn hostname, or a reserved name (such as .local, .localhost, .test, .internal), cannot be added as a custom domain
domain_not_active 409 Storage — POST /sign named a hostname that is neither the bucket's default hostname nor one of that bucket's active domains
validation_error (tags) 422 Storage — POST /sign refused tags. fields is ["body","tags"] or ["body","tags",<tag name>] and detail says why: "Tags need a private bucket; on public links use a query parameter instead", "Tags are not available on video playback links", "Link tags are not available yet; sign the link without tags" (tags are not yet switched on for your account), "A link can carry at most 8 tags", "A tag name starts with a lowercase letter and uses only lowercase letters, digits and underscores, up to 32 characters", or "A tag value cannot be empty or contain control characters or any of & = ? #" / "can be at most 128 bytes long". Nothing is signed
too_many_rows 422 Delivery analytics — a monthly breakdown has more rows than the response allows (50,000 as JSON, 1,000,000 as CSV); narrow it to one bucket or breakdown, or use format=csv
too_many_organizations 422 Delivery analytics — an account read would span more than 500 organizations; read one sub-organization at a time (send its id as X-Organization-Id), or read your own organization without bucketId
export_busy 429 Delivery analytics — another CSV download is running, yours or the service is at its limit of simultaneous downloads; wait the Retry-After seconds and ask again. Retryable
analytics_unavailable 503 Delivery analytics — the figures cannot be read right now; try again shortly. Retryable. Delivery is not affected
restore_window_passed 409 Storage — more than 7 days since deletion; the bucket cannot be recovered
purge_not_found 404 Storage — unknown cache purge id, or another organization's
purge_url_not_in_bucket 422 Storage — a purge URL is not on the bucket's default hostname or one of its active custom domains (another bucket's host, a domain not active, or one with a port or userinfo)
purge_failed — Storage — a cache purge's failureReason: the edge did not complete it after every attempt; purge again. A purge can also fail with bucket_not_active when its bucket was deleted before it ran
object_not_found 404 Storage — unknown key, or it was deleted; /sign also answers it for a disabled object and for an objectId from another bucket
object_exists 409 Storage — the key is taken and allowOverwrite is false
object_busy 409 Storage — another change to this file is in progress (an upload landing on its key, or another delete or metadata change); nothing was changed. Retry shortly. Retryable
upload_not_found 404 Storage — unknown upload id
upload_not_open 409 Storage — the upload already completed, was aborted, or expired. Also answered to a part sent for such an upload, and (marked retryable) to deleting a video while its upload is being completed
upload_expired 410 Storage — past the 7-day session limit; start a new upload
upload_incomplete 409 Storage — complete called before every declared part arrived
size_mismatch 422 Storage — the bytes received don't total the declared size
checksum_mismatch 422 Storage — on an object, a declared sha256 did not match and the object is disabled. On a part upload, the part's bytes did not match its own Content-MD5 (sent by you, or computed as it arrived): send that part again
file_type_not_allowed 415 Storage — not in the bucket's uploadRules.allowedTypes
ticket_invalid 401 Storage — the part upload ticket doesn't verify; copy it again rather than editing it
ticket_expired 401 Storage — renew the upload for a fresh ticket
part_out_of_range 400 Storage — the part number isn't between 1 and partCount
part_size_mismatch 400 Storage — a part's declared size doesn't match what the plan requires for that number, or the bytes that arrived didn't total it
provider_unavailable 503 Storage — transient; retry with backoff. From the upload server it also means it is busy: wait for the Retry-After it sends, then send the part again. Retryable
upload_too_slow 408 Storage — a part's body arrived too slowly (a stalled or very slow connection) and was cut; the connection is closed. Send the same part again, nothing else is lost. Retryable
forbidden 403 Storage — your IPlycdnStorageAuthorizer refused the operation; the default refuses everything until you implement one
library_not_found 404 Video — unknown library id, a files bucket's id, or another organization's library
video_not_found 404 Video — unknown or deleted video, or another organization's; on /sign, also a video from another library or one plycdn has taken down
original_not_kept 409 Video — encoding a video again when its original was not kept (keepOriginal was false when it was made)
video_not_ready 409 Video — /sign for a video that is not playable or ready yet; wait for video.playable or check status
webhook_not_found 404 Webhooks — unknown or deleted endpoint, or another organization's
webhook_delivery_not_found 404 Webhooks — unknown delivery, or another organization's
webhook_url_invalid 400 Webhooks — the endpoint's URL is not printable ASCII, not on port 443, carries credentials, or its name resolves to a private, local or otherwise non-public address; publish it on a public one
webhook_limit_reached 409 Webhooks — the organization already has 10 endpoints; delete one first
playback_unsupported — Video — shown by the plycdn player, never answered by the API: the browser can play the stream in neither form
playback_link_failed — Video — shown by the plycdn player, never answered by the API: three playback-link renewals in a row failed or were refused, or the stream's address could not be reached; check your backend's sign route and the viewer's connection
admin_key_required 403 Sub-organizations, the account audit, and the storage operations listed under "Storage operations that need an admin key" (creating, changing or removing a bucket, a video library, a custom domain, a webhook endpoint or the storage account; rotating a signing key or webhook secret; and the three storage-credential writes, which an admin key then refuses with dashboard_session_required) need an admin key, created in the dashboard
not_found 404 The path is not a route of this API. The API at /api/content/v1 answers 404; the dashboard's own routes answer most paths they do not know with 405 method_not_allowed instead
api_key_limit_reached 409 Two keys are already active. Deactivate one first
api_key_not_found 404 Unknown key id, or another organization's
api_key_already_active 409 That key is already active
api_key_inactive 409 A deactivated key cannot be rotated. Create a new key
sub_organization_not_found 404 Unknown sub-organization id, or another account's
dashboard_session_required 403 API keys, storage credentials, people, invitations and the organization profile are managed in the dashboard; every API key is refused there (a standard key is stopped earlier, on the storage-credential writes, with admin_key_required)
organization_suspended 403 This organization is suspended. Contact plycdn support. Your API keys, dashboard sign-ins, storage credentials, and new upload and playback links are refused with this code. Once it is reactivated, every key and credential works again. Delivery stops as well: public files and video, links you already issued and custom domains are not served while the organization is suspended, and are served again, with your settings as you left them, once it is reactivated (this can take a few minutes to reach every location). Queued processing, connector syncs and notifications pause and resume when it is reactivated. Not retryable
credential_not_found 404 Storage credentials — unknown id, another organization's, or already revoked or expired (on rotate)
credential_limit_reached 409 Storage credentials — 20 are already active (revoked and expired ones do not count), or both Azure key slots are in use. Revoke one first
storage_account_not_found 404 Storage account — none exists yet for this organization. Create it first (also answered when creating an Azure credential)
storage_account_exists 409 Storage account — this organization already has one. Its name cannot be changed
storage_account_name_taken 409 Storage account — account names are unique across plycdn; choose another
rate_limited 429 Your account created more than 10 keys (in the dashboard), or 60 sub-organizations, in an hour, or more than 120 bucket-name checks a minute, 20 bucket creations a day, 60 cache purges (10 whole-bucket purges) or 60 signing-key rotations an hour, or 60 webhook pings, redeliveries and secret rotations (together) an hour. Wait for the Retry-After seconds, then try again
unauthorized 401 A dashboard route under /api was called with neither a session nor a key
invalid_organization 400 The organization chosen on a dashboard route under /api (X-Organization-Id or organizationId) is not a UUID
invalid_asset, invalid_job, invalid_connector 400 The id in the path of a dashboard route under /api is not a UUID. Nothing was sent on
invalid_path 400 The path has a segment the gateway will not pass on: . or .. (encoded or not), or a character other than letters, digits, _, ., ~ and -
unsupported_media_type 415 A write whose body is not application/json, or a compressed body (Content-Encoding) where none is accepted. Send JSON, uncompressed
method_not_allowed 405 That route does not take that method
organization_lookup_unavailable 503 Retryable. We could not check which organizations your key covers just then. Nothing about your request is wrong
no_urls, too_many_urls 400 Adding files by address in the dashboard: paste at least one address, and at most 20 at a time
unsupported_format per address Adding files by address: that file is not a format we index (see What can be indexed)
internal_error per address Adding files by address: that one address failed on our side. Try it again
audit_unavailable 503 Retryable. A dashboard "search as" could not be recorded in the account audit, so it was not run

A failed video's errorCode values are listed under Videos, and a webhook delivery's lastError values under Webhooks; neither comes back from a call.

Why a file failed to index

These never come back from a call. They arrive later, on the job — state: "failed" with the code in errorCode, and on a connector as lastError. Show them to whoever uploads content: every one of them is something only they can fix. (The one exception is adding files by address in the dashboard, which checks each address before registering it and can report source_unavailable, source_temporarily_unavailable, source_dns_unavailable or source_address_forbidden for it at once.)

Code What went wrong
ocr_required A document of page images with no text layer. Run OCR on it, or enable slide reading
text_encoding_must_be_utf8 A .txt, .md or .csv file that is not UTF-8. Save it again as UTF-8
media_duration_limit The recording is longer than we index in one file; maxAudioSeconds in GET /capabilities is the limit
document_empty The file parsed and contained no text at all
encrypted_document Password-protected. We do not ask for the password
malformed_document The file is damaged, or is not the format its extension claims
no_speech_detected The recording contains no speech — silence, or music only
audio_track_missing A video with no audio track. Slide reading may still find text on screen
invalid_media The container or codec could not be read
html_could_not_be_parsed, manifest_could_not_be_parsed The file is not the format its name or manifest claims
document_resource_limit The document needs more memory or time to extract than we allow one file. Usually a very large scan or a deeply nested spreadsheet
range_unavailable Your storage refused a range request while we were reading the file in parts
artifact_expired Our working copy expired mid-job. Retried automatically from your source
extracted_text_limit, document_expansion_limit, media_transfer_limit Beyond a size ceiling. The detail says which and what it is
document_processing_timeout, media_processing_timeout The file took longer than we allow. Usually a very large scan
source_unavailable Your storage refused or returned nothing
source_temporarily_unavailable, source_dns_unavailable Your storage is down or unresolvable. Retried automatically
source_address_forbidden The address resolved somewhere we will not fetch from
source_host_outside_ceiling The host is not one plycdn permits at all
connector_unauthorized The source rejected the connector's access token, without saying why. Issue a new one
connector_credential_missing No credential is stored for that connector
connector_credential_expired The SAS has passed its expiry. Issue a new one — against a stored access policy, so the next extension needs no change here
connector_credential_cannot_list The credential can read single files but not list the container, so we see nothing to index. Needs Read and List
connector_credential_cannot_read The credential can list the container but not download from it
connector_credential_scoped_to_one_blob The SAS is signed for a single file (sr=b) rather than the container. Generate it from the container's own page
connector_credential_is_a_url The whole SAS URL was saved where the token goes. Save only the part after the ?
connector_credential_malformed Not a readable SAS. Copy it again without editing it
connector_unavailable The source could not be reached. Retried on the next run
transcription_failed, ocr_failed Processing failed on our side. Reindex; contact us if it repeats
processing_temporarily_unavailable Retryable. A temporary problem on our side, not in your file. The job is retried automatically (retry_scheduled, with retryAt); nothing needs to change on yours. If it ends failed, reindex, and contact us if it repeats — we are alerted to it. It can also be a connector item's errorCode, a connector's lastError or a per-item /imports result
processing_failed A problem on our side, not in your file, that retrying the same work would not fix, so the job ends failed at once. A job whose processing stopped before finishing several times running ends the same way. Reindex it later, and contact us if it repeats — we are alerted to it. It can also be a connector item's errorCode, a connector's lastError or a per-item /imports result

The connector_credential_* codes are read from the token itself before it is used, so they arrive immediately and name the one thing to change. Only the parameters Azure publishes in the clear are inspected — the signature is never read. A SAS backed by a stored access policy is not second-guessed at all, because the policy carries the permissions and the window.

Anything not listed here is a fault on our side rather than something in your content. Report it with the x-request-id from the response (when it has one) and the time, and we can trace the exact run.

35Practical notes

Retries. These writes require an Idempotency-Key header and refuse without one with idempotency_key_required:

POST  /assets                          POST  /assets/{assetId}/reindex
POST  /imports                         PATCH /assets/{assetId}/transcript
POST  /buckets                         POST  /domains
POST  /libraries                       POST  /libraries/{libraryId}/videos
POST  /webhooks

Repeating any of them with the same key returns the original result rather than doing the work twice — which is the point on a batch of two thousand files, where a timeout otherwise leaves you unable to tell what registered. Everything else is safe to repeat without one: reads are reads, and cancel and delete are idempotent in effect. A key longer than 200 characters counts as missing; use a new one for each new piece of work.

When we are the problem. content_service_unavailable and content_service_timeout are returned when plycdn cannot reach the content service or it does not answer in time. Both carry retryable: true. They say nothing about your request — retry with backoff, and if they persist, contact us rather than changing your integration.

Rate limits. Requests are rate limited per API key, by plan: 36,000 requests a minute (600 a second) per key on Starter, 120,000 (2,000 a second) on Growth and 300,000 (5,000 a second) on Enterprise. That one allowance covers every route under /api/content/v1, storage included. Sub-organizations have their parent organization's limits unless we set their own. If your workload needs more, ask us: we raise it for your organization without any change on your side; the limits exist so that one workload cannot slow down another, not to meter you. Separately, each client address may make 72,000 requests a minute that are not served (that fail authentication, or are refused for exceeding a key's limit); requests that are served never count against it. Once an address has sent more than 60 failed credentials in a minute, requests from it with a missing or malformed credential are refused (429) for the rest of that minute, and any one credential that fails 5 times in a minute is refused for the rest of that minute. A key that has used its minute's allowance is refused until the minute ends. A throttled request returns 429 with Retry-After and {"error": "Too many requests, please try again later"} — a plain message rather than a code, because it is refused before reaching the content service. Back off for Retry-After seconds; do not spin.

Versions. A new sourceVersion for the same externalId creates a new revision. The previous one stays searchable until the new one is ready, so re-uploading a corrected file does not create a gap.

Sub-organizations. Send the sub-organization's UUID in X-Organization-Id to register and search within it. Each can hold its own storage hosts and limits, inheriting yours where it sets nothing.

Daily limits. dailyAudioHours caps how many hours of audio/video an organization can start indexing in a day; work above the cap is refused with organization_daily_limit — a protective daily limit, not a reflection of what you owe — rather than silently queued, and it resumes the next day, or when you raise it under settings.

dailyBudgetUsd is deprecated since 1.7.0: accepted and ignored, always returned as 0. What your organization can use is controlled by your plan's quotas — see What you have used.

36Support

Give the operation, the time, the error code and the asset or job id. Never include API keys, storage connection strings, or signed URLs — a signed URL grants access to the file it points at.