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-Mediais 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 withticketExpiresAt), 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,/signrequiresip, takes an IPv4-mapped address (::ffff:1.2.3.4) as its IPv4 form, and refuses a missing or any other IPv6ipwith422 validation_error,fields [["body","ip"]]; on any other bucket it refuses anipthe same way.PlycdnLinkSignerand 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
Refererdoesn'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.
Signing links yourself
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:
pathis 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.pdfis encoded one UTF-8 byte at a time.expiresis hashed as the same text you put in the link, so write it without a sign, spaces or leading zeros.base64url_nopadis 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
tokenfirst andexpiressecond, 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
TXTrecord at_plycdn.cdn.example.comproves the hostname is yours; - the
CNAMEatcdn.example.comroutes 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, showsrouting: "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).0means the edge keeps no copies. Not set: plycdn's default (the edge's standard caching; nothing is overridden).browserCacheControl— theCache-Controlheader viewers' browsers receive. It is built only frompublic,private,no-cache,no-store,must-revalidate,proxy-revalidate,immutable,no-transform, andmax-age,s-maxage,stale-while-revalidate,stale-if-errorwith 0 to 31,536,000 seconds; each directive at most once, at most 200 characters.publictogether withprivate, andno-storetogether withmax-ageors-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) orextensions(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.M3U8is stored asm3u8) — and at least one ofedgeTtlSecondsandbrowserCacheControl. 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 (m3u8files) 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
immutableif it can be replaced under the same key: a browser that has it will not ask again until itsmax-ageruns 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://— orhttp://only forlocalhostand127.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.comis refused; list each origin). Origins are stored lower case, a default port (:443) is dropped, and an internationalised host is stored in itsxn--form, as a browser sends it. *stands alone:["*", "https://learn.example.com"]is refused.- Only
GETandHEAD: delivery is read-only. PreflightOPTIONSrequests 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: truenever goes with*— not as the origin, nor asallowedHeadersorexposeHeaders. This is judged on the bucket's settings after your change, so aPATCHthat turns credentials on while"allowedOrigins": ["*"]is stored is refused too, withfields [["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: truegives itps2links. - Turning
allowCredentialson for a bucket that has plycdn links is refused with422 validation_error,fields [["body","cors","allowCredentials"]], and a message that says to use a separate bucket for credentialed requests. - Nothing else changes for a
ps2bucket: links, rotation and the guides'ps2sections 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 absolutehttps://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 itsxn--form, as the domain'shostnameshows it). A URL on another host — another bucket's, a domain that is notactiveyet or any more, or one with a port or a user name — is refused withpurge_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'stokenandexpiresincluded). 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 withvalidation_errorwhen 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, sendall, not an empty prefix.- starts with
all: true— every cached file of the bucket."all": falseis 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 } ] }
fromandtoare UTC days, both counted: a range of at most 93 days; a longer range, ortobeforefrom, isvalidation_error. Every day of the range appears, with zeros where nothing was delivered.gbis delivered bytes in GB (2³⁰ bytes), to 6 decimals;requestsis a whole number of requests.regionGroupslists all four region groups, always in the same order, with their labels (see Usage).buckets— withoutbucketId, your organization's buckets by what they delivered, largest first (at most 100); a deleted bucket keeps its name and its history. WithbucketId, just that bucket, andbucketsis[]; a bucket your organization never had isbucket_not_found.provisionalistrueon 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.sinceis 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; notorganizationorbucket) 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/previewwith{ "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
tagrule 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 aqueryParambreakdown, for example…/intro.mp4?customer=acmewith{ "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_errorwithfields["body","tags",<tag name>]and adetailsentence 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 withfields["body","tags"]anddetail"Link tags are not available yet; sign the link without tags", and the signer in your own process refuses them the same way (PlycdnExceptionvalidation_errorwith thatdetail): it readstagsEnabledfrom the bucket's signing profile. The signer in your own process refuses the same things with anArgumentExceptionthat 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:
provisionalistruewhile they can. Read the range again later rather than adding up what you read. noticestell you when something is missing or folded:ceiling_reached,hours_missing(an hour could not be counted) andlogging_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.