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 answered401 api_key_requiredon every route, before anything else is looked at, even with a valid header beside it. - In a JSON request body (a top-level
apiKeyfield, including the older form that sent it withorganizationId): the request is answered401 api_key_requiredonce its body is read, unless an earlier check answers first (a missing or invalid header key,organization_forbidden,admin_key_requiredand 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:
- 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.
- Deploy the new key to your backend.
- 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
resourceandpage; - a package zipped inside its folder, or using
xml:basepaths.
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.
momentslists 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 itslocation.playFromSeconds, or open itslocation.page.momentCountis how many matched in total.limit,offsetandhasMorethen count files, not passages, so a second page never repeats a file from the first.groupdoes not change what is found, how it is ranked or what it costs: one search either way./answerignores 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.
Narrowing a search
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 |
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:
startSecondsandendSecondsbound 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.playFromSecondsis 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 afterstartSeconds.
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
principalsis 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. PUTreplaces 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": [...]onPOST /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": nulland 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:
includedis the part the closed month's unused allowance (or its monthly minimum) absorbed, andbillablethe rest;amountis notbillable × 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;unitPriceis the rate charged when one rate covered every billable unit, andnullotherwise.
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.
What counts as a search
/relatedcounts as a search./answeris 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
searchesquota — 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 }
quotaisnullwhere none is set — unlimited for that metric.enforcedistruefor the metrics that stop new work at 100% (media hours, document MB, searches, answers) andfalsefor 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).alertsReachedlists which of your account'salertThresholdsthis metric has crossed this month.payAsYouGo: whentrue, 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;nullwhen open) — and whether it is active now. WhileserviceActiveisfalse(before your service starts, or after your service has ended) nothing is billed, dashboard searches included; the same three fields are ondashboardSearches.- 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
usedcan pass 100% by whatever was already in flight. At 100%, withpayAsYouGo: falseand that metricenforced, new work of that kind is refused withquota_exceeded(429) until the quota is raised or the month resets. Reading, search results already returned, and playback are never affected. dashboardSearchesis your account's dashboard search allowance — one pool shared by your parent organization and every sub-organization, sousedcounts all of them, whichever calls — see Dashboard searches. In the month your service starts or ends it is the prorated allowance, andusedcounts only searches inside your service, exactly as the statement applies it. It shares thesearchesquota and its alerts.unitPriceis your list price per search past the allowance,nullwhile 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,timeZoneanddashboardSearchesarenullandmetricsis[].
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 theRetry-Afterheader (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 itsContent-MD5; send it again.ticket_expired/ticket_invalid(401):renewthe 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
ticketTtlSecondsrather than by comparing your clock withticketExpiresAt),renewthe 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 aselsewhere.
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,prefixesorall.targets: the object paths or prefixes, percent-encoded as in a URL, without the leading/([]forall).state:queued→running→succeededorfailed. A purge made right after a change to the bucket's settings staysqueueduntil that change has reached the edge (a few minutes at most), so nothing is cached again under the old settings.failureReasonisnullunlessfailed:purge_failed(the edge did not complete it after every attempt; purge again) orbucket_not_active(the bucket was deleted before the purge ran).requestedBy:api_key:<id>oruser:<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],epochSecondsbeing 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. resolutionishourfor a range of up to 14 days,dayup to 400 days, andmonthbeyond that.- Without a
dimensionand withgroupBy=value, the one series has the keytotal. groupBy=bucketseries have alabelwith the bucket's name;groupBy=organizationhas one series per organization (account only).otheris always last.topCountriesis the ten countries with the most requests, as{country, requests}.provisionalistruewhile any figure in the range can still change (the most recent hours). Read the range again later.notices(at most 200) each havekind,organization,dimension,fromandto:ceiling_reached(values were grouped asother, for that breakdown, those days),hours_missing(hours that could not be counted yet or at all) andlogging_disabled(hours when analytics was off, withdimensionnull).
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 forformat=csv. format=csvdownloadsdelivery-<month>.csvwith the columnsorganization, bucket, dimension, value, bytes, requests, cache_hits, cache_misses, up to 1,000,000 rows (more istoo_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-5is 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 is429 export_busy(retryable,Retry-After5 seconds): wait and ask again. If a download stops early, request it again.- Accounts. The parent organization's
groupBy=organizationandby=organizationread every organization that has a bucket under the account (one row per organization and bucket in the month); naming a sub-organization'sbucketIdreads that organization. For an account, either of those (and any read naming abucketId) is422 too_many_organizationswhen more than 500 organizations have buckets under it. Read one sub-organization at a time by sending its id asX-Organization-Id(it sees only itself), or read your own organization withoutbucketId.
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.
Playback links
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 } }
]
}
pricesis keyed by plan code, then currency, then metric code. Each metric lists one entry perdimension(a region or delivery area, with itskeyand displaylabel;nullwhen the price does not depend on one), and each entry lists itstiers: fromfromunits a month, each unit costsunitPrice(in the currency's main unit), after the firstincludedunits.- 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.
strictResidencysays 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.