plycdn Storage — buckets, uploads, delivery and usage

Audience: developers storing and serving files (video, audio, documents, anything else) through plycdn, with or without Content Search. Storage is a separate capability from Content Search: a bucket does not index anything, and Content Search does not require a plycdn bucket — you can keep registering assets from your own storage as described in the API reference and the Integration guide.

Three packages carry Storage. Install them beside your existing Content Search packages, or on their own if you only need storage:

dotnet add package Plycdn.AspNetCore.Storage --version 1.4.0 --source YOUR_PACKAGE_FEED_OR_FOLDER
npm install ./vendor/plycdn-angular-1.4.0.tgz     # @plycdn/angular/storage, for an Angular frontend
npm install ./vendor/plycdn-react-1.4.0.tgz       # @plycdn/react/storage, for a React frontend

Plycdn.AspNetCore.Storage is independent of Plycdn.ContentSearch.AspNetCore — register both if you use both. Video has its own package, Plycdn.AspNetCore.Video, and its own entry points (@plycdn/angular/video, @plycdn/react/video); see the Video guide. Step-by-step setup for each platform: Storage & video with ASP.NET Core, with Angular and with React. Everything in this guide is called from your backend except uploading bytes and reading signed or public links, which happen from the browser.

Which key. A standard API key does everything in this guide except what changes a bucket's existence, delivery settings or signing key: creating, changing (PATCH), deleting, restoring or retrying a bucket, rotating its signing key, and adding or removing a custom domain (see Your own domain). Those need an admin key, created in the dashboard, and a standard key is refused with admin_key_required (403); the same holds for video libraries and webhook endpoints (Video guide) and the storage account (S3 and Azure compatibility). The full list is in the API reference, "Storage operations that need an admin key". Keep the admin key in the automation that sets buckets up, and a standard key in the service your users reach. Name checks (GET /bucket-names), checking a domain, cache purges (see Purging the cache) and delivery analytics work with either, and so does reading a bucket's signing profile, which holds its signing key, so that your service can sign links itself; only rotating that key needs an admin key. In the dashboard every member of your organization can make these changes.

1What a bucket is

A bucket is a named container for files, created in one region:

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" } }
  ] }

Treat region codes as strings: new regions are added over time, and the list above is whatever GET /regions returns today, the regions currently open for new buckets. Everyone sees the same list. Each region also carries its endpoints: the hosts for S3 and Azure tools and for uploads, with the region's short host label (in-mum for mumbai) inside them. The host label is not the region code; the code is what you send when you create a bucket, and the label appears only in host names. The hosts are explained in S3 and Azure compatibility.

Bucket names

A bucket's name is its identity and its address: bucket course-media is served at https://course-media.plycdn.net. Names are unique across plycdn — like S3 bucket names, not just unique in your account — so choose one that is yours (acme-course-media rather than media).

  • 3 to 63 characters: lower-case letters a–z, digits and hyphens; starting and ending with a letter or digit. No dots and no upper case (Course-Media is refused, not lower-cased for you).
  • Characters 3 and 4 cannot both be hyphens, so internationalised (xn--) names are refused.
  • A name can't be changed after the bucket is created.
  • Some names are not available to anyone (our own service names, for example).
  • When a bucket is deleted and then purged (7 days later), its name stays held for 90 days: only your account can create a bucket with it again during that time. This protects your old links — nobody else can start serving files at an address your pages, apps or e-mails still point to.
  • A bucket that was public at any time protects its links for good: once it is purged, its name is retired — no other account can ever create a bucket with it. Making the bucket private before you delete it does not change this. Your own account (the organization that had it, or any other organization under the same billing account) can still create a bucket with that name again, at any time; it is new and empty, and public only if you make it so.

Check a name before you create it (the create call is still the authority: someone may take the name in between):

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

reason is null when the name is available, "invalid" when it breaks a rule above, and "taken" when it is in use, held or not available — the answer never says which. hostname is the address a bucket of that name gets (Hostname in .NET), null for an invalid name.

var regions = await plycdn.ListRegionsAsync(organizationId);
var check = await plycdn.CheckBucketNameAsync(organizationId, "course-media");
if (check.Available)
{
    var bucket = await plycdn.CreateBucketAsync(organizationId, new BucketCreate("course-media", "mumbai")
    {
        Visibility = "private",
        StrictResidency = false,
    });
}

Creating a bucket needs an Idempotency-Key header (any unique string, such as a UUID): POST /buckets without one is refused with idempotency_key_required (400). Send the same key and the same body again after a timeout and you get the same bucket back instead of bucket_name_taken; the same key with a different body is idempotency_conflict (409). The .NET package sends a fresh key for you unless you pass your own (idempotencyKey:), so pass your own when you want a retry to be safe.

A bucket is private or public. A private bucket serves nothing without a signed link (see Delivering); a public one serves anything at its hostname to whoever has the link. strictResidency records that files in this bucket must never leave its country — stored and returned today, and enforced for processing once captions and search are available for stored files. Where a region's strictCapable is true (Mumbai, in India, is one), a strict bucket can also have captions and search, processed inside its country; in any other region a strict bucket stores and delivers files normally but cannot turn captions or search on.

Every bucket's default hostname is {name}.plycdn.net (course-media.plycdn.net) from the moment it is created, wherever the bucket's region is; deliveryBaseUrl is that hostname with https://. You never see or configure the storage behind it.

{ "id": "…", "name": "course-media", "region": "mumbai", "visibility": "private",
  "state": "active", "failureReason": null, "suspended": false,
  "defaultHostname": "course-media.plycdn.net",
  "deliveryBaseUrl": "https://course-media.plycdn.net",
  "storedBytes": 0, "objectCount": 0 }

States. A new bucket is creating while its storage and delivery are provisioned, including while it is waiting on its TLS certificate — that wait by itself is not a failure. It becomes active once ready. If provisioning exhausts its attempts before finishing — for example, still waiting on a certificate, or unable to reserve storage or delivery capacity — the bucket becomes failed with a failureReason instead of staying creating forever:

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

Retry a failed bucket rather than recreating it:

POST /api/content/v1/buckets/{bucketId}/retry

A delivery-settings change (PATCH) can fail the same way — after every attempt to apply it at the edge. There the bucket does not move to failed: it stays active (or deleting, if a delete is in progress) exactly as it was serving — or not serving — before, with "failureReason": "configuration_failed". There is nothing to retry yourself; the next delivery-settings change that reaches the edge (yours, or plycdn's own retry) clears it. Any failureReason value not listed above should be treated the same as provisioning_failed.

Deleting a bucket (DELETE /buckets/{bucketId}) is a soft delete: it stops serving immediately — even if the suspension needed to take it offline was itself refused at the edge and had to fall back to a plainer one, which can leave failureReason at configuration_failed — but the bucket can be restored to exactly the state it had — its objects, delivery settings and upload rules — for 7 days:

POST /api/content/v1/buckets/{bucketId}/restore

After 7 days the bucket and everything in it are gone for good.

Plan limits. Your plan caps how many buckets you can hold, how much storage they can hold in total, and the largest single object. Creating past the bucket or storage ceiling fails with plan_limit_reached; an object over the largest-object ceiling fails at upload completion with file_too_large instead, since that's a per-file check rather than an account-wide one. GET /plan says what your plan currently allows. Bytes of uploads in progress count toward your storage quota until the upload completes or is aborted: an open upload session counts its declared size from the moment it starts (and stops counting when it expires), and completing it counts the file once.

Limits. These hold on every plan:

Limit Value Over it
Open uploads per organization 1,000 at once (each sub-organization has its own count) plan_limit_reached (403) until one completes, is aborted or expires
Upload session lifetime 7 days from creation upload_expired (410)
Part upload ticket 24 hours; renew for fresh part URLs ticket_expired (401)
Parts per upload 10,000 (part size chosen for you) —
Object key 1–1,024 bytes (UTF-8) validation_error (422)
Object metadata 64 keys, 8 KiB as JSON validation_error (422)
Objects per listing page 1–1,000 (100 by default) validation_error (422)
Name checks / bucket creations 120 a minute / 20 a day per account rate_limited (429)

An open upload is one that has been started and not yet completed, aborted or expired. Abort uploads you no longer need (DELETE /uploads/{uploadId}) rather than leaving them to expire.

Video libraries. A bucket of kind "video" is a video library, created with POST /libraries rather than POST /buckets: always private, video/* uploads only (directly under sources/), links that last 4 hours, and every video uploaded to it encoded for streaming, with a player for your pages and signed webhooks for your backend. Everything in this guide applies to a library too (it lists, restores, rotates keys and reports usage as a bucket, and counts towards your bucket limit); what is different, including the encoding_minutes a library reports in usage, is in the Video guide.

2Uploading

Your backend starts the upload, the browser sends the bytes, your backend completes it:

your backend                          browser                         plycdn
     │  POST /buckets/{id}/uploads       │                                │
     │──────────────────────────────────────────────────────────────────>│
     │<── upload session (parts, ticketExpiresAt) ─────────────────────  │
     │─── session, incl. part URLs ─────>│                                │
     │                                   │  PUT https://{label}.upload    │
     │                                   │       .plycdn.com/v1/{ticket}/{n}
     │                                   │───────────────────────────────>│
     │                                   │<── part accepted ──────────── │
     │  POST /uploads/{id}/complete      │                                │
     │──────────────────────────────────────────────────────────────────>│
     │<── object ───────────────────────────────────────────────────────│

Your backend never touches the bytes. Part URLs point at your bucket's region (https://in-mum.upload.plycdn.com/v1/<ticket>/<n>, the region's short host label; never a hostname you have to configure), and a part upload authenticates with the ticket embedded in the URL — nothing else is needed from the browser.

var upload = await plycdn.CreateUploadAsync(organizationId, bucket.Id,
    new UploadCreate("week-1/intro.mp4", fileSize, "video/mp4", sha256Hex));
// upload.Parts: [{ Number, Size, Url }], upload.TicketExpiresAt, upload.TicketTtlSeconds (1.8.0+)
// Inside <PlycdnProvider config={{ baseUrl: "/api/plycdn-storage" }}> (your backend's relay)
const upload = useUpload();
await upload.start(file, { bucketId: bucket.id, key: "week-1/intro.mp4", sha256 });
// start plans parts, PUTs each to its ticket URL, then asks your backend to complete.
// upload.progress, upload.state, pause(), resume(), abort() and retry() follow the upload.

Part sizes. A file up to 64 MiB is a single part. Above that, plycdn picks a part size between 8 MiB and 128 MiB so the file needs no more than 10,000 parts. You never choose a part size yourself — it comes back in the upload session (partSize, partCount) and every part URL.

Resuming after a reload. GET /uploads/{uploadId} returns received: the part numbers already accepted. Re-request only the parts missing from that list. A part upload ticket is valid for 24 hours; renew it before it expires (or after a long pause) rather than re-planning the whole upload:

POST /api/content/v1/uploads/{uploadId}/renew

which returns a fresh parts array, ticketExpiresAt and ticketTtlSeconds, with the same part numbers and sizes. ticketTtlSeconds (on create and renew, from service 1.8.0) is the ticket's lifetime counted from when you receive the response — time renewals by it, not by comparing ticketExpiresAt with your own clock.

When a part fails. A part upload is a PUT of exactly that part's bytes with its Content-Length declared up front (a chunked body is refused). Sending the same part again replaces it, and no failure below loses the parts already accepted:

Answer What happened What to do
408 upload_too_slow The body stopped arriving, or arrived too slowly, and was cut Send the same part again
503 provider_unavailable The upload server is busy, or storage is briefly unavailable Wait for the Retry-After header (seconds), then send the part again
401 ticket_expired / ticket_invalid The part URL's ticket has run out, or was altered Renew the upload and use the fresh part URLs
400 invalid_content_length The length is missing or not a number, or the body is chunked Send the part with its exact Content-Length
400 part_size_mismatch The declared length isn't this part's size, or the bytes that arrived didn't total it Send exactly the part's size from the session
400 part_out_of_range The part number isn't between 1 and partCount Use the part numbers from the session
422 checksum_mismatch The part's bytes did not match its Content-MD5 Send the part again
409 upload_not_open The upload already completed, was aborted, or expired Stop sending; check the upload

A refusal that comes before the body has been read is answered and the connection closed — an expired ticket, but also a busy upload server or a body cut for being too slow — and a browser still sending the part usually reports any of them as a connection reset rather than as the answer. So a reset alone does not mean the ticket expired:

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

Cap how many times one upload renews because of resets, so a busy moment never becomes a stream of renewals. @plycdn/angular/storage, @plycdn/react/storage and PlycdnUploader do all of this for you (three reset-driven renewals per upload, then ticket_expired; a part that meets checksum_mismatch is sent once more); this table is for anyone sending parts themselves.

Server-to-server uploads. When your own backend already holds the bytes (a batch import, a migration), skip the browser entirely with PlycdnUploader, which plans parts, uploads them and completes the session in one call:

// `uploader` is the PlycdnUploader that AddPlycdnStorage registers: take it as a constructor parameter.
await using var fileStream = File.OpenRead("intro.mp4");
var obj = await uploader.UploadAsync(organizationId, bucket.Id, "week-1/intro.mp4",
    fileStream, fileStream.Length, "video/mp4");

Upload rules. A bucket's uploadRules (allowedTypes, maxObjectBytes, allowOverwrite) are enforced at completion, not before: a browser can start an upload for a file your rules will eventually refuse, so a file your rules refuse is only turned away at the end of its transfer. Completion fails with file_type_not_allowed, size_mismatch (the size declared at creation doesn't match what arrived), or object_exists when allowOverwrite is false and the key is already taken.

Checksum. Declare a sha256 when you know it, and completion returns the object with "checksumState": "unverified" immediately — verification happens afterwards, asynchronously, so that a large file does not hold the upload open while it is checked. The state then moves to "verified", or to "mismatch" if the stored bytes don't hash to what you declared, in which case the object is disabled ("state": "disabled") and stops being retrievable or servable until you delete it and upload again. Poll GET /objects/{objectId} if your integration needs to know before serving a file that was just uploaded; most integrations don't need to, since a mismatch is rare and the object is pulled from delivery the moment it's found. Omit sha256 and checksumState stays "not_requested" — nothing is verified, and nothing can mismatch.

Files we bring in for you. When plycdn moves an existing library into a bucket for you (an assisted migration), the files appear as ordinary objects without upload sessions. The same rules apply as to an upload: key names, allowedTypes (checked on the file's first bytes, too), maxObjectBytes and your storage quota. A file that breaks a rule is not added; we tell you which, and you decide. Each added file starts "checksumState": "pending" while we read it end to end and record its sha256, then becomes "verified". Your audit log shows bucket.adopted and object.adopted with plycdn as the actor. Storage is billed from the day the files arrive; the migration itself is not billed as uploads.

Keys that are refused. A key must be 1 to 1,024 bytes of UTF-8 with no control or invisible characters, no \, no // and no leading or trailing /, and none of its parts (between slashes) may be . or ..; any of these is refused with validation_error. So is a key starting with .plycdn-, in any case, percent-encoded (even more than once) or written with full-width letters: that prefix is reserved for plycdn's own bookkeeping inside your bucket's storage. Avoid, too, any part of a key (between slashes) that starts with a dot followed by a character other than a letter, a digit, -, ., _ or ~ — a space, a symbol such as + or %, or a non-ASCII letter (. draft.pdf, .%notes/a.txt, .é/a.txt): such a key can be uploaded, but delivery refuses it with 403, because its address is written with an escape where the reserved prefix could hide.

Abandoned uploads. An upload session nobody completes or aborts is aborted automatically after 7 days; the parts already sent are discarded and no object is created.

Finishing, and abandoning, an upload. complete is safe to repeat. While another completion (or an abort) of the same upload is running, complete and DELETE /uploads/{uploadId} answer upload_not_open marked retryable: ask again shortly. Aborting an upload that has already completed changes nothing and answers 200 with the completed upload and its object, so an abort never removes a file that finished uploading.

Listing objects. GET /buckets/{bucketId}/objects lists in byte order of the key's UTF-8 (as S3 lists: B before a, a-b before a_b, é after z), disabled objects included. prefix is a literal key prefix (every key under week-1/, at any depth); there is no delimiter, so group keys into folders yourself. limit is 1–1000 (100 by default). Treat nextCursor as opaque: pass it back unchanged as cursor, stop when it is null, and never build or parse one.

Changing or deleting an object. PATCH /objects/{objectId} replaces an object's metadata as a whole (at most 64 keys, each value a string, number, boolean or null, at most 8 KiB as JSON), and DELETE /objects/{objectId} deletes it, answering 204: the object stops being listed at once and its key can be uploaded again straight away, while its bytes and any copies cached at the edge are removed shortly after. Either can answer object_busy (409, retryable) while another change to the same file is in progress, such as an upload landing on its key or another delete or metadata change. Nothing was changed; retry shortly.

Content types. Every delivery response carries X-Content-Type-Options: nosniff, so browsers use the contentType you declared at upload and never guess one from the bytes. contentType is one media type: a comma may appear only inside a quoted parameter value (422 validation_error otherwise). HTML, XML and SVG (and the other types that can run script in a browser: the API reference lists them) are always delivered as downloads in a sandbox, even if inline is asked for. image/* in allowedTypes admits SVG: for files your users upload, list the image types you want instead. On a bucket with allowedTypes, markup (HTML, SVG, XML) declared as another type is refused with file_type_not_allowed. A delivery URL whose query holds response, partNumber, versionId, uploadId or X-Amz- (or a percent-escape of one of their letters) is refused with 403, a parameter of your own included (?download=Response.pdf).

3Delivering

Public buckets serve any stored object directly at its hostname — no signing, no headers:

https://course-media.plycdn.net/week-1/intro.mp4

Private buckets require a signed link. Sign it from your backend with PlycdnLinkSigner, which holds the bucket's signing profile and refreshes it in the background (inject it; AddPlycdnStorage registers it):

// `signer` is the PlycdnLinkSigner that AddPlycdnStorage registers: take it as a constructor parameter.
var url = await signer.SignAsync(organizationId, bucket.Id, "week-1/intro.mp4", ttl: TimeSpan.FromMinutes(15));

or ask plycdn to sign it for you, over HTTP:

POST /api/content/v1/sign
{ "bucketId": "…", "key": "week-1/intro.mp4" }
{ "url": "https://course-media.plycdn.net/week-1/intro.mp4?token=…&expires=1790000000",
  "expiresAt": "2026-09-26T12:15:00+00:00" }

The signing profile is cached by PlycdnLinkSigner so most signing never leaves your process. Rotating a bucket's signing key (POST /buckets/{bucketId}/signing-key/rotate, at most 60 an hour for your account, rate_limited past that) invalidates every link issued with the old key. On a bucket with plycdn links (pl1) 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. Wait a minute (or retry) before handing out links signed with the new key. On a ps2 bucket it is at once. Refresh the cached profile (or restart processes holding one) before rotating, not after, or in-flight signed links start failing. A rotation that answers provider_unavailable may already have changed the key at the edge: plycdn stores the key the edge holds within a few minutes, so fetch the signing profile again after a minute (or rotate again) rather than assuming the old key still works.

Signed links default to a 15-minute TTL (linkTtlSeconds, per bucket); every request through a signed or delivered link is subject to the bucket's delivery policy regardless of TTL:

  • IP binding — a signed link works only from the address it was signed for, enforced by the delivery network itself rather than by anything in your code. Bind the viewer's IPv4 address: IP-bound delivery serves IPv4 only, so an IPv6-only viewer cannot use an IP-bound link at all. On a bucket with ipBinding, /sign requires ip, takes an IPv4-mapped address (::ffff:1.2.3.4) as its IPv4 form, and refuses a missing or any other IPv6 ip with 422 validation_error, fields [["body","ip"]]; on any other bucket it refuses an ip the same way. PlycdnLinkSigner and the browser controller answer the same, so no unbound or IPv6-bound link is ever issued for an IP-bound bucket.
  • Allowed domains and block without referrer — refuse requests whose Referer doesn't match, or that carry none at all.
  • Countries — allow- or block-list by country.
  • Rate limit — caps throughput per link, in kbps.

Changing settings. PATCH /buckets/{bucketId} changes only the fields you send. To remove a limit, send it as null: "rateLimitKbps": null removes the speed limit, "linkTtlSeconds": null returns links to the 15-minute default, "maxObjectBytes": null returns uploads to your plan's largest file, and null on a list (allowedDomains, countriesAllow, countriesBlock, allowedTypes) removes the restriction, the same as []. A switch (ipBinding, blockNoReferrer, allowOverwrite) and visibility always have a value, so null on them is refused with validation_error. From .NET, a property you leave unset is unchanged and a Clear… flag removes it:

await plycdn.UpdateBucketAsync(organizationId, bucket.Id, new BucketPatch
{
    ClearRateLimitKbps = true,                                   // no speed limit
    UploadRules = new UploadRules { MaxObjectBytes = 2L << 30 },  // 2 GiB; everything else unchanged
});

The audit log's bucket.updated entry records each removal (detail.cleared).

How long the edge and browsers keep copies, and which other sites' pages may read your files from script, are the bucket's cache and cors settings (Caching, CORS); removing cached copies on demand is a purge (see Purging the cache).

A signed link must be used exactly as issued. Its query string is part of what's signed, so a player or app that appends its own query parameters (a cache-buster, a tracking parameter, a range hint) is refused rather than served — don't add parameters to a signed URL, and don't strip or reorder the ones it already has.

const { url, expiresAt } = await client.sign(bucket.id, "week-1/intro.mp4");
// or, reading an object your backend has already authorized:
const { url } = useSignedUrl(bucket.id, "week-1/intro.mp4");

Object reads from the browser are default-deny. Listing a bucket's objects, reading one, and signing a link are all reachable from a browser, but only through your own backend's authorization — see IPlycdnStorageAuthorizer in the Integration guide. Until you implement and register one, every one of those calls is refused with forbidden.

If you need to sign without calling plycdn or holding PlycdnLinkSigner, read the bucket's signing profile first: its scheme says how to sign. New private buckets normally use plycdn links (pl1); a plycdn link keeps working whatever changes on our side. Buckets made before, and buckets that allow credentialed cross-origin requests, use ps2; we can switch an earlier bucket for you. The two differ only in the prefix of the token.

Both are an HMAC-SHA256 token, signed with the key from the profile. Build it exactly as follows; the bytes you sign decide whether a link verifies, so every step below matters.

expires  = now + ttl                                      # Unix seconds, written as plain decimal digits
path     = "/" + percent_encode(key, safe="/-._~")        # UTF-8 bytes; "/" and - . _ ~ stay, every other byte is %XX in capitals
ip_bytes = b""                                            # no IP binding
         | the 4 bytes of the IPv4 address                # IPv4 binding
         | the first 8 bytes of the IPv6 address + 8 zero bytes   # IPv6 binding (/64)
message  = utf8(path) + utf8(str(expires)) + ip_bytes     # joined with nothing between them
token    = "PL1-" + ("1-" if ip else "") + base64url_nopad( HMAC-SHA256(key=utf8(key), msg=message) )
url      = "https://{host}{path}?token={token}&expires={expires}"

Points that trip people up:

  • path is what you put in the URL, percent-encoded, and it is the same string you hash. Do not hash the plain key and then encode the link, and do not encode twice. A space is %20 (never +), and a key such as हिंदी/a b.pdf is encoded one UTF-8 byte at a time.
  • expires is hashed as the same text you put in the link, so write it without a sign, spaces or leading zeros.
  • base64url_nopad is the URL-safe alphabet (- and _), with the = padding removed. The key is used as the text the profile gives you, not decoded first.
  • The query holds token first and expires second, and nothing else on an untagged link. Append nothing, and change nothing, after signing: a link must be used exactly as issued.
  • With IP binding the token starts PL1-1-. Bind the viewer's IPv4 address.

ps2 is the same with the prefix "HS256-" in place of "PL1-". A ps2 token never verifies as a plycdn link, nor the other way round, so sign the way the profile's scheme says.

A link with tags ("Labelling links with tags", under Traffic breakdown; only once the signing profile's tagsEnabled is true) carries them, and a tag signature, as more query parameters, and the token covers them too:

canonical = "name=value" for each tag, sorted by name, joined with "\n"     (values as they are)
pts       = "HS256-" + base64url_nopad( HMAC-SHA256(key=key, msg="pt1\n" + path + "\n" + str(expires) + "\n" + canonical) )
params    = "pt.<name>=<value>" for each tag and "pts=<pts>", sorted by name, joined with "&"   (values not encoded)
token     = "PL1-" + ("1-" if ip else "") +      # "HS256-" on a ps2 bucket
            base64url_nopad( HMAC-SHA256(key=key, msg=path + str(expires) + ip_bytes + params) )
url       = "https://{host}{path}?token={token}&expires={expires}&pt.<name>=<percent-encoded value>...&pts={pts}"

The tag signature pts always starts HS256-, on every bucket; only token changes with the scheme. In params the values are hashed as they are, while in the link they are percent-encoded (everything but letters, digits and -._~). The query lists token, expires, then the pt. parameters and pts, sorted by name. A link without tags has no params and is exactly the link above. The published test vectors include tagged links: with the test key below, the tags course=maths-101 and learner=u-42 on week-1/intro.mp4 give pts=HS256-4IpBRZUfS3aqleMcpv5-kfErXQDP9xiyoCRYLyySdCQ and the link

https://course-media.plycdn.net/week-1/intro.mp4?token=PL1-6fPz-Y2gKP29-PK5DjfK52-VLPioqW4ctjapsoIPhfE&expires=1790000000&pt.course=maths-101&pt.learner=u-42&pts=HS256-4IpBRZUfS3aqleMcpv5-kfErXQDP9xiyoCRYLyySdCQ

An IPv4-mapped IPv6 address (::ffff:1.2.3.4) signs the same as its IPv4 form. Two IPv6 addresses in the same /64 sign to the same token; a different /64 signs to a different one. The scheme defines the IPv6 form, but IP-bound delivery serves IPv4 only: bind IPv4 addresses.

key here is the signing key from GET /buckets/{bucketId}/signing-profile, never a secret you choose:

{ "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 }

hostnames lists the hostnames you may sign for, default first. tagsEnabled says whether link tags are enabled for your account; until it is true, don't sign tagged links. directoryScheme and pathTokens are for video libraries (Video guide); on a files bucket they are null and false.

Worked examples, from the published test vectors (plycdn-signing-vectors-pl1.json), signed with the test key pl1-test-key-0123456789abcdefghijklmnopqrstu: a test vector, never a real key; use your own bucket's key. All use expires = 1790000000 and no IP binding.

Object key week-1/intro.mp4:

path:    /week-1/intro.mp4
message: /week-1/intro.mp41790000000
token:   PL1-7-b88-n1Y5cuWA8EUHo-VNGiPgtaLBMOz5k0_x0yoTM
url:     https://course-media.plycdn.net/week-1/intro.mp4?token=PL1-7-b88-n1Y5cuWA8EUHo-VNGiPgtaLBMOz5k0_x0yoTM&expires=1790000000

An object key with non-ASCII characters and a space, हिंदी/a b.pdf. The path is the encoded form, and that is the text that is hashed:

path:    /%E0%A4%B9%E0%A4%BF%E0%A4%82%E0%A4%A6%E0%A5%80/a%20b.pdf
message: /%E0%A4%B9%E0%A4%BF%E0%A4%82%E0%A4%A6%E0%A5%80/a%20b.pdf1790000000
token:   PL1-CGNAYoxWuWUzHJal1-v-mnaeDJHnkFKb0lc2xj0K5r0
url:     https://course-media.plycdn.net/%E0%A4%B9%E0%A4%BF%E0%A4%82%E0%A4%A6%E0%A5%80/a%20b.pdf?token=PL1-CGNAYoxWuWUzHJal1-v-mnaeDJHnkFKb0lc2xj0K5r0&expires=1790000000

The same object bound to the IPv4 address 203.0.113.7 (the four bytes cb 00 71 07 follow expires in the message):

token:   PL1-1-lmqKITegHXUrw2YTm9ft4BC46mYSbmSkS424D9NlFFo
url:     https://course-media.plycdn.net/week-1/intro.mp4?token=PL1-1-lmqKITegHXUrw2YTm9ft4BC46mYSbmSkS424D9NlFFo&expires=1790000000

A token never depends on the host.

On a ps2 bucket

A bucket that is still on ps2 signs the same way with HS256-. Worked example, object key week-1/intro.mp4, expires = 1790000000, no IP binding:

path:  /week-1/intro.mp4
token: HS256-jirpx6RR2SPZbwYhqsJyvhPof02__XRQtAWo00p5_LY
url:   https://course-media-k3x7qa.plycdn.net/week-1/intro.mp4?token=HS256-jirpx6RR2SPZbwYhqsJyvhPof02__XRQtAWo00p5_LY&expires=1790000000

The host in this worked example is the bucket course-media-k3x7qa from the published test vectors; a token never depends on the host.

(with the published test signing key 4f1c2b7a-0d3e-4f5a-9b8c-7d6e5f4a3b2c — a test vector, never a real one; use your own bucket's key.)

4Your own domain

A bucket always serves at its default hostname (course-media.plycdn.net). It can also serve at hostnames of yours, such as cdn.example.com or example.com: up to 8 per bucket. Adding and managing domains is administrative, so it happens from your backend with your API key; browsers only ever use a domain, by asking your backend for links signed on it. The wire contract is in the API reference (Custom domains).

1. Add the domain, then create its two records.

var domain = await plycdn.CreateDomainAsync(organizationId, new DomainCreate(bucket.Id, "cdn.example.com"));
foreach (var record in domain.Records)
    Console.WriteLine(quot;{record.Type} {record.Name} {record.Value}");
// TXT   _plycdn.cdn.example.com  plycdn-verify=k3x7…
// CNAME cdn.example.com          q4n2w7t5c6aa.domains.plycdn.net

Adding a domain needs an Idempotency-Key header, as creating a bucket does (idempotency_key_required without one; the .NET package sends one for you).

At your DNS host, create both records exactly as returned:

  • the TXT record at _plycdn.cdn.example.com proves the hostname is yours;
  • the CNAME at cdn.example.com routes it to us. If your DNS host can put its own proxy in front of a hostname, make this record DNS-only: a proxied hostname does not reach us directly, shows routing: "elsewhere", and cannot get a certificate.

An apex domain (example.com, with no label in front) cannot have a CNAME. Use your DNS host's ALIAS, ANAME or CNAME flattening pointed at the same target instead; the TXT record is the same. apex becomes true once we have seen your zone.

The hostname can be given in any case, with a trailing dot, or in Unicode (media.例え.jp); it is returned in its lowercase xn-- form as hostname, with unicodeHostname for display. Your plycdn hostnames and reserved names such as .local are refused with hostname_not_allowed.

2. Wait for it to become active. We look for your records on our own; you don't need to call anything. A domain moves through:

State What is happening How long it may take
pending_dns Waiting for both records to be visible. Usually minutes after you create them; after 72 hours without them it becomes failed (dns_not_configured).
verifying Records found; we attach the hostname to your bucket and confirm it routes to us. Minutes; if it does not route to us within an hour, failed (dns_not_configured). If your TXT record is definitely gone meanwhile, the domain goes back to pending_dns (it does not fail). A hostname you added again while its previous domain is still being removed waits here; if that removal has not finished within the hour, failed (hostname_in_use): check again once the old domain is gone.
issuing_certificate The hostname's TLS certificate is being issued. Usually minutes; after 24 hours without one, failed (certificate_failed).
active Serving on your hostname over HTTPS. Until you delete it, or your records go.

We check every minute for the first ten minutes, every ten minutes up to an hour, then hourly; nextCheckAt says when the next one is. After fixing a record you can ask for a check at once (CheckDomainAsync, at most once a minute per domain). checks shows what the last check saw: ownership (the TXT), routing (the CNAME), claim (whether another account holds the hostname) and certificate.

var domain = await plycdn.GetDomainAsync(organizationId, domainId);
while (domain.State is "pending_dns" or "verifying" or "issuing_certificate")
{
    await Task.Delay(TimeSpan.FromMinutes(1));
    domain = await plycdn.GetDomainAsync(organizationId, domainId);
}
if (domain.State != "active")
    Console.WriteLine(quot;{domain.Hostname}: {domain.FailureReason}");   // see the table below

In a real application, poll from a background job or simply show state on your admin page: a domain can sit in pending_dns for as long as your DNS changes take.

failureReason What to do
dns_not_configured Create both records exactly as returned, then check again.
hostname_in_use Another account serves this hostname. Point the records at the values in your domain; the other account loses the hostname at its next daily check. If you re-added a hostname whose previous domain was still being removed, check again once that removal has finished (Your own domain: about ten minutes).
caa_blocks_certificate Your CAA records do not allow the certificate authority we use: add the CAA record now listed in records, then check again.
certificate_failed Make sure nothing proxies the hostname (DNS-only), 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: restore it and check again.
provider_unavailable We could not complete a step: check again.

Checking again a failed or detached domain starts it over at pending_dns with a new TXT value: replace your TXT record with the one now in records. The CNAME target stays the same.

domain = await plycdn.CheckDomainAsync(organizationId, domainId);

3. Keep the TXT record. It is not a one-time check: we check both records daily for as long as the domain exists. If a daily check finds either record gone or pointing elsewhere, we check again an hour later; if it is still missing, the domain becomes detached (dns_record_removed or ownership_record_removed) and stops serving. A DNS lookup that merely fails or times out never detaches a domain.

4. Moving a hostname to another bucket. Delete the domain (DeleteDomainAsync), wait ten minutes for us to stop serving it, then add it to the other bucket. The new domain has a new TXT value and a new CNAME target: update both records. A deleted domain disappears from the API at once.

await plycdn.DeleteDomainAsync(organizationId, domainId);

5. CAA records. If your domain publishes CAA records (they restrict which certificate authorities may issue for it), they must allow the authority we use. If they don't, the domain fails caa_blocks_certificate and its records gain the exact CAA record to add ({ "type": "CAA", "name": "cdn.example.com", "value": "0 issue \"…\"" }). Add it beside your existing CAA records, then check again. Without any CAA records, there is nothing to do.

6. Links on your domain. A public bucket serves every file at the same path on each of its active hostnames: https://cdn.example.com/week-1/intro.mp4 is the same file as https://course-media.plycdn.net/week-1/intro.mp4. An object's url stays on the default hostname; build your own-domain URL from the key. For a private bucket, sign the link on your hostname:

var url = await signer.SignAsync(organizationId, bucket.Id, "week-1/intro.mp4",
    new PlycdnSignOptions { Hostname = "cdn.example.com", Ttl = TimeSpan.FromMinutes(15) });
// @plycdn/react/storage
const { url } = await client.sign(bucket.id, "week-1/intro.mp4", { hostname: "cdn.example.com" });
// React hook: const { url } = useSignedUrl(bucket.id, "week-1/intro.mp4", { hostname: "cdn.example.com" });
// @plycdn/angular/storage: storage.sign({ bucketId: bucket.id, key: "week-1/intro.mp4", hostname: "cdn.example.com" })

The hostname must be the bucket's default hostname or one of its active domains; any other is refused with domain_not_active. The signing profile lists the ones you may use in hostnames, default first, and PlycdnLinkSigner fetches it again once before refusing (at most once a minute per bucket), so a domain that has just become active works straight away. Over HTTP, POST /sign takes the same hostname.

7. Content Security Policy. Add your hostname wherever the page uses the files: img-src https://cdn.example.com; media-src https://cdn.example.com; (see Content Security Policy).

8. Limits. At most 8 domains per bucket, in any state (domain_limit_reached); your plan's limit on domains across your organization (plan_limit_reached); and 20 additions an hour and 100 a day for your account, across all its organizations, counting every attempt and domains you have since deleted (domain_rate_limited, with Retry-After). The bucket must be active and not suspended to add a domain or check one; a suspended bucket's domains serve nothing until it is reinstated.

Deleting the bucket. While a bucket is deleting, its domains pause: they serve nothing and are not counted. Restoring the bucket (within its 7 days) resumes them as they were. When the bucket is purged, its domains are released with it: remove their DNS records, or add the hostname to another bucket (new records).

9. Usage. Each active domain is counted once a day, on your account's calendar day, as custom_domain_days; your invoice shows it as Custom domains, in domain-months (see Usage).

5Content Security Policy

Uploading and delivery both talk to plycdn hostnames, so a strict CSP needs both:

connect-src https://{label}.upload.plycdn.com;   /* e.g. https://in-mum.upload.plycdn.com (Mumbai) */
img-src https://*.plycdn.net;
media-src https://*.plycdn.net;

Narrow img-src/media-src to your bucket's exact hostname (https://course-media.plycdn.net) if you'd rather not allow every plycdn delivery hostname. connect-src needs only the region(s) your buckets are in. Files served on your own domain (see Your own domain) need that hostname too: img-src https://cdn.example.com; media-src https://cdn.example.com;.

6Usage

Storage reports these metrics, each in its own unit. Their list prices are on Plans and prices.

Metric Unit What it counts
Storage GB (2³⁰ bytes) Bytes held, averaged as GB-days over the month
Delivery GB (2³⁰ bytes) Bytes served, by region group
Delivery requests Millions of requests Requests served, by region group
Uploads GB (2³⁰ bytes) Bytes accepted at upload
Custom domains Domain-months Each active custom domain (see Your own domain), counted once a day as a domain-day (custom_domain_days) and summed into domain-months on the invoice

Storage is measured once a day and reported as GB-days: a 100 GB bucket held for the whole month reports the same as a 200 GB bucket held for half of it. Delivery and delivery requests are grouped by region group rather than by individual region:

Region group Regions
India & Asia e.g. mumbai
Europe & North America —
Latin America —
Middle East & Africa —

Figures for the last three days are provisional: delivery is reconciled against the delivery network's own count a few days after the fact, so a number you read today for yesterday can still move before it settles. Once settled, a day's figure is final and is what appears on your invoice. A figure that settles after its month's statement was closed is billed in the next open month, on a line of its own marked lateFor with the month it belongs to, and charged as that month would have charged it (so its amount is not simply billable × unit price): see Late usage lines in the API reference.

GET /api/content/v1/usage/resources?from=2026-09-01&to=2026-09-30&metric=delivery_gb&cursor=…&limit=200
string? cursor = null;
do {
    var page = await plycdn.GetUsageByResourceAsync(organizationId, from, to, metric: "delivery_gb", cursor: cursor);
    // page.Items…
    cursor = page.NextCursor;
} while (cursor is not null);

This is a paged, per-resource breakdown (which bucket, which day, which region group). To get everything for a period, keep passing the cursor back until it is null. GET /usage (see the API reference) gains storage, delivery and upload lines alongside Content Search's existing ones, so one call still tells you the whole period's total. For delivery day by day and by region group, for one bucket or all of them, read delivery analytics (see Delivery analytics). The dashboard shows usage under Account → Usage.

7Audit log

Every change to a bucket's settings, every key rotation, every upload completion, every deletion, every cache purge request (cache.purged), every takedown and every step of a custom domain (domain.added, domain.verified, domain.active, domain.failed, domain.detached, domain.deleted, domain.released, domain.check_requested) is recorded:

GET /api/content/v1/audit?from=2026-09-01&action=bucket.deleted&resourceId=…&cursor=…
var page = await plycdn.GetAuditAsync(organizationId, from: from, action: "bucket.deleted");

The dashboard shows the log under Account → Audit log. Each entry names its actor: user:<id> for a person in the dashboard, api_key:<id> for one of your keys, or plycdn for plycdn itself (setting a bucket up, purging a deleted one, checksum checks and takedowns).

Reads are not: viewing or downloading a file through a signed or public link is counted in usage (see Usage) but not written to the audit log entry by entry, so plycdn does not keep a record of individual visitors.

8Errors

Code Status What to do
region_not_available 400 That region code doesn't exist, or isn't open for new buckets yet (see GET /regions)
bucket_not_found 404 Unknown id, or it belongs to another organization
bucket_name_taken 409 Pick another name: bucket names are unique across plycdn, a name is held for 90 days after its bucket is purged, and the name of a bucket that was ever public is kept for its own account for good
rate_limited 429 Too many name checks (120 a minute), bucket creations (20 a day), cache purges (60 an hour), whole-bucket purges (10 an hour) or signing-key rotations (60 an hour) for your account; wait for Retry-After seconds
bucket_not_active 409 The bucket is creating, failed, deleting or deleted — check state first. Retryable only while creating: it becomes active on its own. Also a purge's failureReason (see Purging the cache)
bucket_suspended 403 plycdn has taken the bucket down (see Takedowns): no uploads and no signed links until it is reinstated. Listing, reading and deleting still work
bucket_hostname_unavailable 503 Could not allocate a default hostname for the bucket; retry the create. Retryable
domain_not_found 404 Unknown domain id, deleted, or another organization's
domain_exists 409 This organization already has that hostname (in any spelling)
domain_limit_reached 409 The bucket already has 8 domains (see Your own domain)
domain_rate_limited 429 Too many domain additions for your account (20 an hour, 100 a day), or a check-now within a minute of the last; wait for Retry-After seconds. Retryable
hostname_not_allowed 422 A plycdn hostname or a reserved name (such as .local) cannot be a custom domain
domain_not_active 409 A link was signed for a hostname that is neither the bucket's default hostname nor one of its active domains
restore_window_passed 409 More than 7 days since deletion; the bucket cannot be recovered
object_not_found 404 Unknown key, or it was deleted; /sign also answers it for a disabled object and for an objectId from another bucket
object_exists 409 The key is already taken and allowOverwrite is false
object_busy 409 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 Unknown upload id
upload_not_open 409 The upload already completed, was aborted, or expired (also answered to a part sent for it)
upload_expired 410 The session passed its 7-day limit; start a new upload
upload_incomplete 409 complete was called before every declared part arrived
size_mismatch 422 The bytes received don't total the size declared at creation
checksum_mismatch 422 On an object: the declared sha256 did not match after completion, and the object is disabled (see Uploading). On a part upload: that part's bytes did not match its Content-MD5; send the part again
file_type_not_allowed 415 Not in the bucket's uploadRules.allowedTypes
ticket_invalid 401 The part upload URL's ticket doesn't verify — copy it again rather than editing it
ticket_expired 401 Renew the upload (POST /uploads/{id}/renew) for a fresh ticket
part_out_of_range 400 The part number isn't between 1 and partCount
part_size_mismatch 400 A part's declared size doesn't match what the plan requires for that part number, or the bytes that arrived didn't total it
provider_unavailable 503 Transient; retry with backoff. From the upload server it can also mean busy: wait for its Retry-After, then send the part again. Retryable
upload_too_slow 408 A part's body arrived too slowly and was cut (the connection is closed); send the same part again. Retryable
invalid_content_length 400 A part's Content-Length is missing or not a number, or the body was chunked; send the part with its exact length
admin_key_required 403 Creating, changing, deleting, restoring or retrying a bucket, rotating its signing key, or adding or removing a custom domain, was sent with a standard key: use an admin key, created in the dashboard ("Which key", above)
purge_not_found 404 Unknown purge id, or it belongs to another organization (see Purging the cache)
purge_url_not_in_bucket 422 A purge URL is not on the bucket's default hostname or one of its active custom domains (see Purging the cache)
purge_failed — A purge's failureReason: the edge did not complete it after every attempt; purge again (see Purging the cache)
forbidden 403 Your IPlycdnStorageAuthorizer refused the operation — the default refuses everything until you implement one (Integration guide)

Also reused from Content Search, with the same meaning: file_too_large, plan_limit_reached, not_included_in_plan, payload_too_large, idempotency_key_required, validation_error (for a custom domain's hostname, its detail says what is wrong with the form).

A custom domain's failureReason values are listed in Your own domain.

A bucket's failureReason values are listed in What a bucket is, along with what each one means and what to do — certificate_pending, no_storage_capacity, no_cdn_capacity and provider_unavailable describe a failed bucket (retry it); configuration_failed describes an active or deleting bucket whose delivery-settings change did not apply.

9Takedowns

Report content that should not be served to [email protected], with the bucket and object key (or a signed/public link) and the reason.

Once we act on a report, the object's state becomes disabled: it stops resolving through any public or signed link immediately, and stays in the audit log under a takedown action so you can see what happened and when. A whole bucket can be disabled the same way if the takedown covers it: its delivery is suspended, so no link to it resolves, the bucket shows "suspended": true, and new uploads and signed links are refused with bucket_suspended (403) until we reinstate it. You can still list, read and delete its files, to remove what was reported. The audit log records takedown.object or takedown.bucket, and takedown.reinstated if we reverse it, with plycdn as the actor and the reason in detail.reason. Disabling is not deletion — the bytes are moved out of anywhere a link or the delivery network can reach them, not destroyed, in case a report needs to be reversed. Deleting a disabled object (or replacing it, or deleting its bucket) removes the held bytes too.

10Caching

Every bucket is delivered through plycdn's edge, which keeps copies of your files close to viewers. A bucket's cache settings say how long the edge keeps a copy and which Cache-Control header browsers receive, for the whole bucket and, through ordered rules, for parts of it:

"cache": {
  "edgeTtlSeconds": 86400,
  "browserCacheControl": "public, max-age=3600",
  "rules": [
    { "pathPrefix": "stream/", "edgeTtlSeconds": 31536000,
      "browserCacheControl": "public, max-age=31536000, immutable" },
    { "extensions": ["m3u8"], "edgeTtlSeconds": 5, "browserCacheControl": "no-cache" }
  ]
}
  • edgeTtlSeconds — how long the edge keeps a copy, 0 to 31,536,000 seconds (one year). 0 means the edge keeps no copies. Not set: plycdn's default (the edge's standard caching; nothing is overridden).
  • browserCacheControl — the Cache-Control header viewers' browsers receive. It is built only from public, private, no-cache, no-store, must-revalidate, proxy-revalidate, immutable, no-transform, and max-age, s-maxage, stale-while-revalidate, stale-if-error with 0 to 31,536,000 seconds; each directive at most once, at most 200 characters. public together with private, and no-store together with max-age or s-maxage, are refused. It is stored lower case, directives joined by , . Not set: plycdn's default header.
  • rules — at most 20 rules, and the first match wins. Each rule has exactly one matcher — pathPrefix (a key prefix, under exactly the rules of a purge prefix, Purging the cache) or extensions (1 to 20 file extensions of 1 to 16 letters or digits, without the dot; a leading dot is dropped and upper case is folded, so .M3U8 is stored as m3u8) — and at least one of edgeTtlSeconds and browserCacheControl. A request no rule matches uses the bucket-level values; a rule that leaves out one of the two values uses the bucket-level one for it.

With the settings above, stream/course-1/seg-00042.ts matches the first rule (a year, immutable), live/index.m3u8 matches the second (five seconds, no-cache), and thumbs/intro.jpg matches neither, so it gets a day at the edge and an hour in the browser. Order matters: stream/index.m3u8 matches the first rule, because its key starts with stream/ — put the narrower rule first when two can match the same file.

Two common set-ups.

  • Adaptive-streaming video. Segments never change once written, so keep them for a year and mark them immutable; playlists of a live or changing stream (m3u8 files) must stay fresh:

    "rules": [
      { "extensions": ["m3u8"], "edgeTtlSeconds": 5, "browserCacheControl": "no-cache" },
      { "extensions": ["ts", "m4s", "mp4"], "edgeTtlSeconds": 31536000,
        "browserCacheControl": "public, max-age=31536000, immutable" }
    ]
    
  • Versioned assets. Files whose names change whenever their content does (assets/app.3f9c1a.js) can be cached for good:

    "rules": [
      { "pathPrefix": "assets/", "edgeTtlSeconds": 31536000,
        "browserCacheControl": "public, max-age=31536000, immutable" }
    ]
    

    Never mark a file immutable if it can be replaced under the same key: a browser that has it will not ask again until its max-age runs out, and no purge (see Purging the cache) reaches a viewer's browser.

For a private bucket, prefer private in browserCacheControl, so shared caches between the edge and the viewer do not keep your files.

Setting it. cache is a section of POST /buckets and PATCH /buckets/{bucketId}, like delivery. In a PATCH, a field you leave out is unchanged, and null returns it to the default: "edgeTtlSeconds": null and "browserCacheControl": null to plycdn's defaults, "rules": null to no rules (the same as []). rules, when you send it, replaces the whole list — send every rule you want to keep, in order. null on cache itself is refused with validation_error.

var bucket = await plycdn.CreateBucketAsync(organizationId, new BucketCreate("course-media", "mumbai")
{
    Cache = new BucketCache
    {
        EdgeTtlSeconds = 86400,
        BrowserCacheControl = "public, max-age=3600",
        Rules = new[]
        {
            new CacheRule { Extensions = new[] { "m3u8" }, EdgeTtlSeconds = 5, BrowserCacheControl = "no-cache" },
            new CacheRule { PathPrefix = "stream/", EdgeTtlSeconds = 31536000,
                            BrowserCacheControl = "public, max-age=31536000, immutable" },
        },
    },
});

A change to cache is a delivery-settings change: it is applied at the edge shortly after you make it, the bucket's failureReason becomes configuration_failed if it cannot be (see What a bucket is), and the audit log records it as bucket.updated. Copies the edge already holds keep the lifetime they were cached with; purge them (see Purging the cache) if the new settings must apply at once. You can purge straight after the change: the purge waits for the change to reach the edge before it runs.

11CORS

A web page on another origin that reads your files with fetch or XMLHttpRequest — a JavaScript video player, a PDF viewer, a canvas — needs the delivery response to carry CORS headers. A plain <video src>, <img> or link needs none. A video library is the exception: its delivery already answers such requests for the playlists, segments, scrubbing images, captions and posters a player reads (files ending m3u8, m4s, mp4, vtt and jpg), so you do not set CORS on a library for the plycdn player or your own (Video guide). A bucket's cors settings say which origins get them:

"cors": {
  "allowedOrigins": ["https://learn.example.com", "http://localhost:4200"],
  "allowedMethods": ["GET", "HEAD"],
  "allowedHeaders": ["range"],
  "exposeHeaders": ["content-length", "content-range"],
  "maxAgeSeconds": 3600,
  "allowCredentials": false
}
Field Values Default
allowedOrigins 1 to 20 exact origins, scheme://host[:port]; or exactly ["*"] none: CORS off (a video library's player files excepted, above)
allowedMethods GET, HEAD or both ["GET", "HEAD"]
allowedHeaders up to 20 request header names the page may send; or exactly ["*"] none (only the headers every browser allows)
exposeHeaders up to 20 response header names the page may read; or exactly ["*"] none
maxAgeSeconds how long a browser may reuse a preflight answer, 0 to 86,400 600
allowCredentials true or false (never null) false

The rules are strict, and a body that breaks one is refused with 422 validation_error, fields naming the field:

  • An origin is https:// — or http:// only for localhost and 127.0.0.1, for development — followed by a host and an optional port: no path, no trailing /, no query, no user name, and no wildcard label (https://*.example.com is refused; list each origin). Origins are stored lower case, a default port (:443) is dropped, and an internationalised host is stored in its xn-- form, as a browser sends it.
  • * stands alone: ["*", "https://learn.example.com"] is refused.
  • Only GET and HEAD: delivery is read-only. Preflight OPTIONS requests are answered for you.
  • Header names are single HTTP tokens (no spaces or commas), stored lower case.
  • The same origin, method or header twice is refused.
  • allowCredentials: true never goes with * — not as the origin, nor as allowedHeaders or exposeHeaders. This is judged on the bucket's settings after your change, so a PATCH that turns credentials on while "allowedOrigins": ["*"] is stored is refused too, with fields [["body","cors","allowCredentials"]].

Credentials and plycdn links. A plycdn link (pl1, see Signing links yourself) is answered with a redirect, and a browser does not let a redirect carry cookies or other credentials on a request from another origin, so a bucket that allows credentialed cross-origin requests (allowCredentials: true) cannot use plycdn links. Such a bucket keeps the earlier link scheme, ps2, whose links go straight to the delivery network:

  • Creating a bucket with allowCredentials: true gives it ps2 links.
  • Turning allowCredentials on for a bucket that has plycdn links is refused with 422 validation_error, fields [["body","cors","allowCredentials"]], and a message that says to use a separate bucket for credentialed requests.
  • Nothing else changes for a ps2 bucket: links, rotation and the guides' ps2 sections apply as before.

If a page needs credentialed requests, serve those files from a bucket of their own with allowCredentials: true, and keep your other buckets on plycdn links.

At the edge. A request whose Origin is in allowedOrigins (any origin, with *) is answered with Access-Control-Allow-Origin set to that origin (or *), Vary: Origin (except with *), Access-Control-Expose-Headers when exposeHeaders is set and Access-Control-Allow-Credentials: true when allowCredentials is. A preflight — an OPTIONS request carrying Access-Control-Request-Method — from an allowed origin for an allowed method is answered 204 with Access-Control-Allow-Methods, Access-Control-Allow-Headers (the requested headers that are allowed) and Access-Control-Max-Age; any other preflight gets no permission to proceed.

CORS is not access control. A request from an origin that is not allowed is still served — it carries no permission for that origin, so a browser will not let that page's script read the response. A script outside a browser, a download tool or a plain <video> tag is not stopped by CORS at all. To decide who can fetch your files, use a private bucket with signed links (see Delivering), allowed domains, countries and IP binding.

Setting it. cors is a section of POST /buckets and PATCH /buckets/{bucketId}. In a PATCH, a field you leave out is unchanged and null returns it to its default — "allowedOrigins": null turns CORS off (the same as []); allowCredentials is a switch, so null on it is refused, and so is null on cors itself. Settings saved while no origins are set are kept, and apply once you add origins. Like cache, a change is a delivery-settings change (see What a bucket is) and is recorded as bucket.updated.

await plycdn.UpdateBucketAsync(organizationId, bucket.Id, new BucketPatch
{
    Cors = new BucketCors
    {
        AllowedOrigins = new[] { "https://learn.example.com" },
        AllowedHeaders = new[] { "range" },
        ExposeHeaders = new[] { "content-length", "content-range" },
    },
});

12Purging the cache

The edge keeps copies for as long as your cache settings allow (see Caching). To remove them sooner — a file changed outside its usual rhythm, or cache settings you want applied at once — purge. A purge removes cached copies only: your stored files are untouched, and the next request fetches a fresh copy. A purge made right after a change to the bucket's settings waits (queued, a few minutes at most) for that change to reach the edge, so the fresh copies are cached under the new settings.

Deleting a file, or replacing it by uploading to the same key, already removes that key's cached copies shortly afterwards; a purge is for everything else, and for when you want to follow the removal to completion.

POST /api/content/v1/buckets/{bucketId}/purges

The body is exactly one of three forms:

{ "urls": ["https://course-media.plycdn.net/week-1/intro.mp4",
           "https://course-media.plycdn.net/week-1/notes.pdf"] }
{ "prefixes": ["stream/course-1/", "thumbs/"] }
{ "all": true }
  • urls — 1 to 100 absolute https:// URLs of files in this bucket, on the bucket's default hostname (course-media.plycdn.net) or one of its active custom domains (Your own domain; write an internationalised name in its xn-- form, as the domain's hostname shows it). A URL on another host — another bucket's, a domain that is not active yet or any more, or one with a port or a user name — is refused with purge_url_not_in_bucket (422). The query string and fragment are ignored: purging a URL removes every cached copy of that path, whatever query it was requested with (a signed link's token and expires included). The same path twice counts once.

  • prefixes — 1 to 20 key prefixes: every cached file whose key starts with one. A prefix is 1 to 1,024 bytes and is refused with validation_error when it:

    • starts with /;
    • holds a control or invisible character, a \, or //;
    • has a . or .. segment (./thumbs/, a/../b/, or just . or ..);
    • is in the reserved .plycdn- space, in any case or spelling (.PLYCDN-, percent-encoded), or is a beginning of it in any case (., .p, .PL, … .plycdn).

    A cache rule's pathPrefix (see Caching) follows the same rules. To purge the whole bucket, send all, not an empty prefix.

  • all: true — every cached file of the bucket. "all": false is refused.

Anything else — none or two of the three, an empty list, more than 100 URLs or 20 prefixes, a URL that is not https:// — is 422 validation_error.

It answers 202 with the purge, already queued:

{ "id": "…", "bucketId": "…", "bucketName": "course-media", "scope": "urls",
  "targets": ["week-1/intro.mp4", "week-1/notes.pdf"], "state": "queued", "failureReason": null,
  "requestedBy": "api_key:…", "createdAt": "…", "startedAt": null, "completedAt": null }

scope is urls, prefixes or all; targets are the object paths or prefixes the purge covers, percent-encoded as they appear in a URL ([] for all). requestedBy is the key (api_key:<id>) or dashboard user (user:<id>) that asked. Read it back until it finishes:

GET /api/content/v1/purges/{purgeId}
GET /api/content/v1/purges?bucketId=…&cursor=…&limit=50

The listing is your organization's purges, newest first, optionally for one bucket; limit is 1–200 (50 by default); pass nextCursor back as cursor until it is null. An unknown id, or one of another organization, is purge_not_found (404).

state Meaning
queued Accepted; not started yet (or waiting for a settings change to reach the edge)
running The edge is removing the copies
succeeded Done: the next request for any of it fetches a fresh copy
failed Not done; failureReason says why
failureReason Meaning What to do
purge_failed The edge did not complete the purge after every attempt Purge again
bucket_not_active The bucket was deleted before the purge ran (its delivery had stopped anyway) Nothing

Which buckets. The bucket must be active (bucket_not_active, 409, otherwise; retryable while it is creating). A bucket plycdn has taken down ("suspended": true, Takedowns) can be purged: it serves nothing, and clearing its cached copies is part of dealing with a report.

Limits and price. Purges are free. Per account, at most 60 purge requests an hour, of which at most 10 whole-bucket purges (all) an hour; over either, 429 rate_limited with Retry-After in seconds. A request is counted once its body is valid — including one then refused, such as a URL on another bucket's host — so check URLs before sending them. Prefer one request with many URLs or a prefix to many requests with one URL each.

Each request is recorded in the audit log as cache.purged (kind bucket, with detail.purgeId, scope and count); a refused one is recorded with its code.

var purge = await plycdn.PurgeCacheAsync(organizationId, bucket.Id, new CachePurgeRequest
{
    Urls = new[] { "https://course-media.plycdn.net/week-1/intro.mp4" },
});
while (purge.State is "queued" or "running")
{
    await Task.Delay(TimeSpan.FromSeconds(2));
    purge = await plycdn.GetPurgeAsync(organizationId, purge.Id);
}

var recent = await plycdn.ListPurgesAsync(organizationId, bucketId: bucket.Id, cursor: null, limit: 20);

13Delivery analytics

Two views of what your buckets deliver. Daily figures are always there, with nothing to switch on: how much each bucket delivered, day by day and by region group. Traffic breakdown is what you turn on per bucket, to see requests, bytes and cache results by the labels you choose (a customer, a course, a campaign), by the hour, and to download a month of it. It is on every plan; the prices are on your price list.

Daily figures

How much each bucket delivered, day by day and by region group — the same figures as delivery usage (see Usage), arranged for reading rather than billing:

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 UTC days, both counted: a range of at most 93 days; a longer range, or to before from, is validation_error. Every day of the range appears, with zeros where nothing was delivered.
  • gb is delivered bytes in GB (2³⁰ bytes), to 6 decimals; requests is a whole number of requests.
  • regionGroups lists all four region groups, always in the same order, with their labels (see Usage).
  • buckets — without bucketId, your organization's buckets by what they delivered, largest first (at most 100); a deleted bucket keeps its name and its history. With bucketId, just that bucket, and buckets is []; a bucket your organization never had is bucket_not_found.
  • provisional is true on a day any of whose figures has not settled yet. As with usage, the last three days or so are provisional and can still move; a settled day is final, and is what your invoice bills. Read a range again later rather than adding days you already read.

Analytics show quantities only — bandwidth and requests — never prices. Daily figures carry no breakdown by HTTP status code: they count bytes and requests only. (Traffic breakdown, below, does count 2xx, 3xx, 4xx and 5xx responses.)

var analytics = await plycdn.GetDeliveryAnalyticsAsync(organizationId,
    new DateOnly(2026, 9, 1), new DateOnly(2026, 9, 27), bucketId: bucket.Id);
// Totals, Days (each with its RegionGroups), RegionGroups and Buckets, as in the JSON above

The dashboard shows the same under Delivery → Delivery analytics; cache and CORS settings are in each bucket's Delivery tab, and purges under Delivery → Purge cache.

Traffic breakdown

Switch it on for a bucket and plycdn counts every request it serves: how many, how many bytes, how many were answered from the edge cache, and how many were 2xx, 3xx, 4xx and 5xx, for each hour, and for each value of up to three breakdowns you define. Use it to see which customer, course or page is behind the traffic, to spot a spike, or to bill your own customers for what they used. Delivery is never affected: turning it on or off, or changing a breakdown, does not touch how your files are served.

PATCH /api/content/v1/buckets/{bucketId}
{ "analytics": { "enabled": true,
                 "dimensions": [ { "name": "course",   "type": "pathSegment", "index": 1 },
                                 { "name": "customer", "type": "tag",         "tag": "customer" } ] } }
await plycdn.UpdateBucketAsync(organizationId, bucket.Id, new BucketPatch
{
    Analytics = new BucketAnalyticsPatch
    {
        Enabled = true,
        Dimensions = new[]
        {
            new AnalyticsRule("course", "pathSegment", Index: 1),
            new AnalyticsRule("customer", "tag", Tag: "customer"),
        },
    },
});

In the dashboard, open the bucket's Analytics tab. Video libraries carry the same analytics setting and tab; everything here applies to a library as it does to a bucket, except that signed-link tags are not available on video playback links.

  • Starts at the next full hour. Delivery in the hour you switch it on is not counted; figures begin with the next full hour. While it is off nothing is counted, and the gap is reported (see notices below). analytics.since is the hour counting (re)started: it is in the future until that hour arrives and moves when you switch analytics off and on.
  • Changes apply going forward. Hours already counted keep the breakdowns they were counted with. dimensions, when you send it, replaces the whole list; send [] to remove them all.
  • At most 3 breakdowns. A breakdown has a 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 one of four types:
type Reads Field Example
pathSegment The n-th part of the file's path, counting from 1 (up to 8) index index: 1 on /acme/week-1/intro.mp4 gives acme
queryParam A query parameter on the link (letters, digits, _, ., -; 64 characters at most) param param: "campaign" on …intro.mp4?campaign=spring gives spring
tag A label signed into the link by your server (private buckets) tag tag: "customer" gives the customer you signed in
pattern The one captured part of a regular expression searched for in the path pattern ^/([a-z0-9-]+)/ captures the first folder name

Give a rule only the field its type uses. A pattern has exactly one capture group and is at most 200 characters. Values keep their case, are decoded once (%20 becomes a space) and are cut to 128 bytes. A pattern is searched for in the path after it is decoded once, and only its first 2,048 bytes are read. Plain expressions only: look-ahead, look-behind and back-references are refused. A rule that is wrong is refused with 422 validation_error, fields naming the part at fault.

  • Try a rule first. POST /buckets/{bucketId}/analytics/preview with { "rule": { … } } shows how the rule reads your bucket's recent traffic, or its file names if it has had none, without changing anything (see the API reference). In the dashboard, press Preview on a rule.

Labelling links with tags

A tag is a label your server signs into a link, so you can count traffic by something only you know, such as which of your customers a viewer belongs to. Tags work on private buckets. Because they are part of the signed link, a viewer who edits one makes the link's tag count as (unverified) rather than as someone else's traffic.

Uri link = await signer.SignAsync(organizationId, bucketId, "week-1/intro.mp4",
    new PlycdnSignOptions
    {
        Ttl = TimeSpan.FromMinutes(15),
        Tags = new Dictionary<string, string> { ["customer"] = "acme", ["plan"] = "gold" },
    });
  • Up to 8 tags per link. A tag name follows the same rule as a breakdown name; a value is 1 to 128 bytes without control characters or any of &, =, ? and # (so the link reads back exactly as it was signed).
  • A link signed with a tag only shows up in a breakdown whose tag rule names that tag. Add the breakdown (for example { "name": "customer", "type": "tag", "tag": "customer" }) before or after you start signing; traffic is counted by the breakdowns in force when it is collected.
  • Public buckets cannot carry tags: their links are not signed, and a request to sign one with tags is refused with 422 validation_error (fields ["body","tags"], detail "Tags need a private bucket..."). Put the label in the link instead and read it with a queryParam breakdown, for example …/intro.mp4?customer=acme with { "name": "customer", "type": "queryParam", "param": "customer" }. Anyone can write a query parameter, so use it for labels, not for billing.
  • Video playback links carry no tags (refused with 422 validation_error, detail "Tags are not available on video playback links"); the video itself is counted in the library's totals and by any breakdown that reads its path or a query parameter.
  • Refusals are 422 validation_error with fields ["body","tags",<tag name>] and a detail sentence saying which rule (too many tags, a bad name, a bad value). Until link tags are enabled for your account, a request with tags is refused with fields ["body","tags"] and detail "Link tags are not available yet; sign the link without tags", and the signer in your own process refuses them the same way (PlycdnException validation_error with that detail): it reads tagsEnabled from the bucket's signing profile. The signer in your own process refuses the same things with an ArgumentException that names the tag. It cannot tell whether a bucket is public, so a link it signs for a public bucket still works but its tags are not counted; ask for links to public buckets through the service, which refuses tags there.
  • Links your browsers ask for. When a browser asks your backend for a link (the sign route of AddPlycdnStorage), tags the browser sent are ignored by default, so a visitor cannot label their own links as another customer. Decide the tags on your server from the signed-in user:
builder.Services.AddPlycdnStorage(o =>
    o.TagsFor = (http, request) => new Dictionary<string, string>
    {
        ["customer"] = http.User.FindFirst("customer")!.Value,
    });

Only if the labels are harmless to you (a page name that nothing is billed or reported from), set AcceptBrowserTags = true to use the browser's tags when TagsFor gives none.

What the values mean

Every request has a value for each breakdown, so the values add up to the whole:

Value Meaning
a value What the rule read from the request
(none) The request had nothing there: a path with no such part, no such parameter, a tag the link did not carry, or an empty value
(unverified) The link carried a tag but its protection did not check out, for example because someone edited it
other The day already had as many distinct values as the ceiling allows; values first seen after that are grouped here

The value ceiling

A breakdown keeps at most a ceiling of distinct values per bucket per day. Once a day has that many, traffic for new values that day is counted under other, and a ceiling_reached notice says so; nothing is lost from the totals. The ceiling comes from your plan (Starter 1,000, Growth 10,000, Enterprise 100,000) and you can raise or lower it for a bucket, from 100 to 10,000,000:

PATCH /api/content/v1/buckets/{bucketId}
{ "analytics": { "ceiling": 50000 } }

Send "ceiling": null to return to your plan's (ClearAnalyticsCeiling in .NET). A higher ceiling lets a breakdown tell more values apart, and more values are tracked values on your statement, so it can raise the charge; a breakdown that never gets near its ceiling is unaffected. effectiveCeiling on the bucket is the figure in force.

What it costs

Two lines on your statement, on every plan, at the prices on your price list (not repeated here):

Line Counted in
Requests analysed Each 100,000 requests counted
Tracked values Each 100 distinct values (breakdown and value pairs that received traffic) per bucket per month; other, (none) and (unverified) count once each

An hour is billed once its figures are final, a few hours after it ends. Switching analytics off stops both lines for that bucket. Sub-organizations' buckets are on the account's one statement, like all their usage; read it by organization below. The preview estimates the monthly price of a rule at your list price, as at least that many values (a month brings more than recent traffic shows).

Reading it

In the dashboard, Delivery → Delivery breakdown charts delivery over time, by value, bucket or organization, and downloads a month as CSV. From code (an admin key):

GET /api/content/v1/analytics/delivery?from=2026-09-01&to=2026-09-27&bucketId=…&dimension=customer&groupBy=value&top=10
GET /api/content/v1/analytics/delivery/breakdown?month=2026-09&by=dimension&dimension=customer&bucketId=…
GET /api/content/v1/analytics/delivery/breakdown?month=2026-09&by=dimension&dimension=customer&format=csv
var series = await plycdn.GetDeliveryAnalyticsSeriesAsync(organizationId, new DateOnly(2026, 9, 1), new DateOnly(2026, 9, 27),
    bucketId: bucket.Id, dimension: "customer", groupBy: "value", top: 10);
var month = await plycdn.GetDeliveryBreakdownAsync(organizationId, "2026-09", by: "dimension", bucketId: bucket.Id, dimension: "customer");
await using var csv = await plycdn.GetDeliveryBreakdownCsvAsync(organizationId, "2026-09", by: "dimension", bucketId: bucket.Id, dimension: "customer");

All times are UTC. The API reference describes the answers, the resolution rule and the errors. In short:

  • Ranges up to 14 days come back by the hour, up to 400 days by the day, and longer by the month, to at most 13 months.
  • Figures for the most recent hours can still change: provisional is true while they can. Read the range again later rather than adding up what you read.
  • notices tell you when something is missing or folded: ceiling_reached, hours_missing (an hour could not be counted) and logging_disabled (analytics was off).
  • How long it is kept. Individual requests are kept for a few days, only so a rule can be tried and an hour re-counted. The hourly and daily totals by breakdown value are kept for 13 months.

Billing your own customers

Give each of your customers a tag (or a path part), add a breakdown for it, and at month end download the month by that breakdown: one row per customer with bytes and requests, plus other and (none) rows so the rows add up to the whole month. Use format=csv for a large month. If you are an account with sub-organizations, ask for the month by organization to get one row per organization and bucket, and to see what each sub-organization delivered on the account's one statement.

The CSV has the columns organization, bucket, dimension, value, bytes, requests, cache_hits, cache_misses. 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 it as text instead of running it.