Delivering files and video, step by step
This page is for the day your files and videos have to reach people: choosing how a file is shared, putting a private document behind your own sign-in, serving from your own domain, keeping caches honest when a file changes, taking something down in a hurry, and seeing (and billing) who used how much. Every field and rule is in the reference: Storage, Video, Integration and the API reference.
You need: a storage bucket (Storage → Storage buckets → New bucket) or a video library, and for most steps your own backend running one of plycdn's server packages (ASP.NET Core, Node.js or Python) with an admin API key for set-up and a standard key for the service your users reach.
1How it works
- Your files live in a bucket, in one region, and every bucket has an address:
acme-media.plycdn.net. - Delivery serves them from copies kept close to the viewer. How long those copies live is your choice (Caching and CORS).
- A public bucket serves any file to anyone with its address. A private bucket serves only through a signed link: an address with a signature and an expiry, made by your backend, that stops working on its own.
- Browsers never hold your API key. They ask your backend for a link, your backend decides, and the file comes from plycdn.
2Choose how to share
| You want | Use | Why |
|---|---|---|
| Images, scripts, downloads anyone may see | A public bucket | The address is the link. Nothing to sign, and it can be cached for as long as you like |
| Files only signed-in people may open | A private bucket and a link your backend makes | Each link expires; your own rules decide who gets one |
| The same, from a web page or app that talks to your backend | A private bucket and your backend's sign route (POST api/plycdn/v1/sign) |
Plycdn's browser packages call it for you; your authorizer is asked first |
| Video | A video library (always private) | Links open one video and renew themselves in the player: see Playing video |
Choose when you create the bucket (New bucket → Private or Public), or later under the bucket's Settings. A bucket that was ever public can never have its name used by another account, even after it is deleted, so decide with care.
3Manage files in a bucket
In the dashboard: Storage → Storage buckets → open the bucket → Files. The list is a file browser: the path
above it (lectures › week-1 › slides) shows where you are, and each part of it takes you back up. On a phone the middle of
a long path shows as …, which goes up one folder. The folder you are in is in the page address (?prefix=week-1/), so
you can share a link to it.
Upload files or a whole folder
- Open the folder the files belong in, then choose Upload. You can also drag files or folders onto the list: it shows Drop to upload to and the folder, and the Upload files panel opens with them already chosen. Nothing is sent until you confirm.
- Destination folder starts as the folder you are in. Change it to send the files somewhere else, such as
week-1/slides; a folder that doesn't exist yet is made by the upload. - Choose files adds files. Choose a folder adds a folder with everything in it, and keeps its structure: choosing
the folder
slideswithintro.pdfinside it, intoweek-1/, storesweek-1/slides/intro.pdf. A dropped folder works the same way. The panel lists each file with its size, and the total; remove one with its ×. - Under If a file with the same name already exists, choose:
- Skip it (the default): a file whose name is already in the bucket isn't sent, and the rest are. A message says how many were skipped, such as 2 skipped, already there.
- Replace it: the new file is uploaded over the old one. This is offered only when the bucket allows it: otherwise the panel says This bucket keeps existing files. Change it in Settings. The setting is the bucket's Settings → Upload rules → Allow replacing files.
- Choose Upload N files. Progress shows in the uploads panel, and uploads carry on while you move around the dashboard. Large files are sent in parts, so a dropped connection doesn't lose what was already sent. While files are on their way, N uploading shows beside Upload.
From your code (orgId and bucketId are shown with Copy in Storage → Storage buckets; see
Find your IDs): your server uploads with PlycdnUploader: UploadAsync(orgId, bucketId, key, stream, length, contentType)
(ASP.NET Core), upload(orgId, bucketId, key, content, size, contentType) (Node.js) or
upload(org_id, bucket_id, key, content, size, content_type) (Python). To skip names that are taken, keep Allow replacing
files off: an upload to a taken name is then refused with object_exists (409) and the stored file is kept. Browsers
upload through your backend with the browser packages: see Storage.
Select several files and delete them
- Tick the box beside each file, or the box in the heading to select every file shown. A bar shows N selected, with Delete and Clear selection.
- Choose Delete. The question names the files; choose Delete N files to go ahead.
- Deleted files stop being listed at once and their links stop working. This can't be undone. If some can't be deleted (for example, one that is being uploaded to at that moment), the message says how many were deleted and why the rest weren't; the ones left stay selected, so Delete tries them again.
Delete a folder
Choose the bin icon at the end of the folder's row (Delete folder). The dashboard counts what is in it first, and the
question shows the folder, how many files it holds and their size, and the first names. Delete N files deletes every
file whose name starts with that folder: week-1/ deletes week-1/a.mp4 and week-1/slides/s1.pdf, never
week-10/x.mp4. A folder exists only while files are in it, so it disappears from the list once they are gone.
From your code: list the folder page by page with ListObjectsAsync(orgId, bucketId, prefix: "week-1/"),
listObjects(orgId, bucketId, { prefix: 'week-1/' }) or list_objects(org_id, bucket_id, prefix="week-1/"), passing
nextCursor back until it is empty, and delete each file with DeleteObjectAsync(orgId, objectId),
deleteObject(orgId, objectId) or delete_object(org_id, object_id). End the prefix with /, so that week-1/ never
matches week-10/.
Make a link to a private file
Choose a file's name, or its Details icon, to open its details. In a private bucket, Link offers a lifetime (Works for 15 minutes, 1 hour, 1 day or 7 days) and Create link. Copy the link it makes; the panel says when it stops working. Anyone with the link can open the file until then. A public bucket shows each file's permanent link here instead.
A bucket set to Bind links to the viewer's address makes links that work only from one viewer's address, so the
dashboard can't make one for you: your backend makes them, as in A private PDF behind your login.
From your code, the lifetime is ttl, ttlSeconds or ttl_seconds (60 seconds to 7 days), as shown there.
Find and sort files
- Name starts with narrows the list to names in this folder that start with what you type. The filter shows as a chip, Starts with: and your text, with × to clear it; opening another folder clears it too.
- The order list sorts the files: Name A to Z (the default), Name Z to A, Largest first, Smallest first, Newest first and Oldest first. Folders stay above the files.
- Both are kept in the page address (
?q=week&sort=size-desc), so a link opens the same view. - A large folder loads 100 entries at a time. The count beside the filter says so far until everything is loaded, and sorting applies to what is loaded: choose Load more at the foot of the list to bring in the rest.
From your code, prefix on the list call does the same narrowing; the list comes back in name order.
Check it works
Upload two files into a new folder with Choose a folder: the folder appears in the list with its file count and size. Upload the same folder again with Skip it: the message says 2 skipped, already there. Open a file's details and Create link; open the link in a private window. Then delete the folder and see it leave the list.
If something goes wrong
| What you see | Why, and what to do |
|---|---|
| Replace it can't be chosen | The bucket keeps existing files. Turn on Settings → Upload rules → Allow replacing files, or upload under another name |
| N not uploaded: we couldn't check whether they are already there | The check for existing names didn't answer, so those files weren't sent, to be sure nothing was replaced. Upload them again |
| … is already uploading | That file is on its way already; its progress is in the uploads panel |
| A file in the list shows a reason under its name, and Upload counts fewer files | That name can't be used for a file (for example, it is too long or has a character that isn't allowed). Rename the file and choose it again |
| Deleted 2 of 3 files | The one left was busy, usually because an upload to it was finishing. It stays selected: choose Delete again |
| No Create link on a private file | The bucket binds links to the viewer's address, so links come from your backend |
4A private PDF behind your login, end to end
A signed-in person opens a page with a "Download contract" button. Your server checks they may see the file, signs a short-lived link and puts it in the page. The person's browser goes straight to the file: nothing on the way redirects.
1. Make the bucket private and put the PDF in it. In the dashboard: New bucket, Private, then open the bucket →
Files → upload. Keep each file's key (its path, such as contracts/2026/acme.pdf) in your own database next to the
record it belongs to.
2. Add one route to your backend that checks sign-in and ownership and answers the signed link:
ASP.NET Core
// PlycdnLinkSigner comes with AddPlycdnStorage / AddPlycdnVideo
app.MapGet("/contracts/{id}/pdf-link", [Authorize] async (string id, ClaimsPrincipal user,
Contracts contracts, PlycdnLinkSigner signer) =>
{
var contract = await contracts.FindForAsync(user, id); // your own rule
if (contract is null) return Results.NotFound();
var url = await signer.SignAsync(contract.OrganizationId, contract.BucketId, contract.Key,
ttl: TimeSpan.FromMinutes(15));
return Results.Ok(new { url = url.ToString() });
});
Node.js
const signer = new PlycdnLinkSigner(storage);
app.get('/contracts/:id/pdf-link', requireLogin, async (req, res) => {
const contract = await contracts.findFor(req.user, req.params.id); // your own rule
if (!contract) return res.sendStatus(404);
const url = await signer.sign(contract.organizationId, contract.bucketId, contract.key, { ttlSeconds: 900 });
res.json({ url });
});
Python
signer = AsyncPlycdnLinkSigner(storage)
@app.get("/contracts/{contract_id}/pdf-link")
async def contract_pdf_link(contract_id: str, user=Depends(current_user)):
contract = await contracts.find_for(user, contract_id) # your own rule
if contract is None:
raise HTTPException(404)
url = await signer.sign(contract.organization_id, contract.bucket_id, contract.key, ttl_seconds=900)
return {"url": url}
The link is signed in your process from the bucket's signing
settings, so there is no call to plycdn per link: the signer fetches each bucket's settings once and keeps them until they
change. It learns of a change from your signing_profile.changed webhook (the packages' webhook handlers refresh the signer
for you: see Webhooks), from our players when a link is refused, and from a check at most once an
hour that downloads nothing when nothing changed (SigningProfileCacheFor in .NET, signingProfileCacheMs in Node.js,
signing_profile_cache in Python set how often). Register it once (AddPlycdnStorage does in .NET) so every request shares
those settings. If you sign links yourself from GetSigningProfileAsync, keep the profile in your own cache the same way and
fetch it again when signing_profile.changed arrives or after you rotate the key.
3. Put the link in your page. The page asks your route when it opens and uses the answer as the link itself:
<a id="contract-pdf" target="_blank" rel="noopener">Download contract</a>
<script>
fetch('/contracts/42/pdf-link')
.then((answer) => answer.json())
.then(({ url }) => { document.getElementById('contract-pdf').href = url; });
</script>
A page your server renders can put the signed link straight into it instead: sign it while you build the page and write
it into the href (or a <video src>, an <img src>, an <iframe src>). Either way the link in the page is the file's
own address, so the browser opens it directly. The link lasts 15 minutes here: a page kept open longer asks your route
again before the link is used, or the person reloads it.
Showing the PDF inside your page with a script viewer? A viewer that reads the file with fetch runs on your origin, so
the bucket must allow it: see Caching and CORS. A plain link,
<img> or <video src> needs nothing.
Letting the browser ask for links itself. Instead of your own route, the browser packages can call your backend's sign
route: useSignedUrl(bucketId, key) (React) or storage.sign({ bucketId, key }) (Angular). Your authorizer is asked for
the Sign operation, on the bucket and key, before any link is issued; until you write one, everything is refused. See
Authorizing browser access to objects.
Link lifetimes
- A private bucket's links last 15 minutes unless you change Signed link lifetime in the bucket's Settings
(1 minute to 7 days). Ask for a different lifetime per link with
ttl(TimeSpan),ttlSecondsorttl_seconds(60 seconds to 7 days). - A video library's links last 4 hours, and the player renews them for you.
- Cap what the browser can ask for with
MaxBrowserLinkTtl(ASP.NET Core),maxBrowserLinkTtlSeconds(Node.js) ormax_browser_link_ttl_seconds(Python), at least 60 seconds. - A link works until it expires, whoever holds it. Make short links for downloads and put them in the page when it opens, as above.
- Use a link exactly as issued. A browser or tool that adds a parameter to it, or reorders it, is refused.
Rotating the signing key
Rotate when a key might have leaked, or on a schedule. Every link made with the old key stops working, so plan for it:
- Make sure nothing hands out links meant to last long. After a rotation, viewers simply ask your route again.
- In the dashboard: the bucket (or library) → Signing → Rotate key. From your code (admin key):
RotateSigningKeyAsync,rotateSigningKeyorrotate_signing_key. - Your servers pick up the new key by themselves. Rotated from the same server through the packages (in Python, through the same client object): nothing to do. Rotated
in the dashboard or from another server: a webhook subscribed to Signing settings changed (
signing_profile.changed) refreshes them at once; without one they pick it up within about an hour, or callsigner.Forget(organizationId, bucketId)(.NET),signer.forget(...)(Node.js and Python) or restart them. - Links signed with the new key work within about a minute, and links with the old key stop in the same minute. A page that keeps working through that minute is one that asks for a fresh link when one fails: plycdn's video player does this by itself.
Rotation is limited to 60 an hour for your account.
5Your own domain
Serve files from cdn.example.com instead of acme-media.plycdn.net. A bucket can have up to 8 domains.
1. Add it. Delivery → Custom domains → Add domain, choose the bucket and type the hostname. The domain's page
shows two DNS records. From your code (admin key): CreateDomainAsync, createDomain, create_domain; each answer
carries records.
2. Create both records at your DNS host, exactly as shown:
- a
TXTrecord at_plycdn.cdn.example.comthat proves the name is yours (keep it as long as you use the domain; we check daily); - a
CNAMEatcdn.example.compointing at the target shown. If your DNS host can proxy a name, make this record DNS-only. For a name with nothing in front (example.com) use your DNS host's ALIAS, ANAME or CNAME flattening.
If the bucket is moving from another cloud, the page shows a third record, an _acme-challenge CNAME, so the certificate is
ready before you change your address: Moving from another cloud.
3. Wait for the certificate. Nothing else to do: the page follows the domain through pending_dns, verifying and
issuing_certificate to active. It checks every minute at first, and Check now asks at once (once a minute). It
usually takes minutes; a domain without its records after 72 hours fails with dns_not_configured. If it fails, the page
says why: caa_blocks_certificate (add the CAA record it lists), certificate_failed (make sure nothing proxies the
name), hostname_in_use (another account serves the name) and the others in Your own domain.
4. Switch your links to it. A public bucket serves every file at the same path on each active hostname:
https://cdn.example.com/week-1/intro.pdf. For a private bucket sign on the hostname:
ASP.NET Core
var url = await signer.SignAsync(orgId, bucketId, "week-1/intro.pdf",
new PlycdnSignOptions { Hostname = "cdn.example.com", Ttl = TimeSpan.FromMinutes(5) });
Node.js
const url = await signer.sign(orgId, bucketId, 'week-1/intro.pdf', { hostname: 'cdn.example.com', ttlSeconds: 300 });
Python
url = await signer.sign(org_id, bucket_id, "week-1/intro.pdf", hostname="cdn.example.com", ttl_seconds=300)
From the browser packages pass hostname too: client.sign(bucketId, key, { hostname }) (React), storage.sign({ bucketId, key, hostname })
(Angular). A hostname that isn't the bucket's default or one of its active domains is refused with domain_not_active.
Add the hostname to your Content Security Policy (img-src, media-src).
5. The old addresses. The default address acme-media.plycdn.net keeps working next to your domain, so links already
sent out don't break. Move your own pages over first, then let old links expire. Removing a domain (open it → delete) stops
serving it after about ten minutes; remove its DNS records afterwards.
6Caching and CORS
Both are set per bucket: Delivery → Caching & CORS lists your buckets, and Configure opens the bucket's Delivery
tab. From code, cache and cors on the bucket's PATCH.
Two symptoms bring people here.
"My player (or viewer) can't load the file from my site." The page runs on your origin, not the bucket's, so the browser
reads the response only if it carries CORS headers for your origin. On the bucket's Delivery tab, list every origin
your pages are served from, such as https://learn.example.com, and http://localhost:4200 while you build. Reading part of a
file is then always allowed (range allowed; content-range, accept-ranges and content-length exposed), so video and
document viewers need nothing more. Exact origins only; no wildcard subdomains.
A plain <img>, <video src> or link needs no CORS. A video library already answers a player's reads, so don't set CORS on it.
CORS is not security: a request from another origin is still served, just without the permission for a script to read it.
"I replaced a file and viewers still see the old one." Three copies can hold it:
- The edge. Uploading to a key that already exists (the bucket must allow replacing files: Settings → Allow replacing files) or deleting it removes that key's edge copies shortly afterwards. For anything else (cache settings you changed, a whole folder) use Purge cache, below.
- The viewer's browser, for as long as the bucket's Browser Cache-Control said (
max-age). A purge can't reach it. Give a file whose content changes a new key (report-v2.pdf,app.3f9c1a.js) rather than marking itimmutable. - A link you handed out earlier, still in someone's page. Ask your route for a new one.
"What should my cache settings be?" For files that never change under the same name (versioned scripts, video
segments), a year and immutable is right. For everything else keep the edge time short or the default, and for a
private bucket prefer private in Browser Cache-Control. Rules are checked in order and the first match wins. The
examples are in Caching.
7Restrict where files play
Each bucket and video library has rules the delivery network enforces on every request, whoever signed the link: which sites may show your files, which countries may receive them, how fast one link may download, and whether a link works only from the viewer's own address. They are how you stop hotlinking, keep a course to the countries you sell it in, or keep one leaked link from serving the world.
In the dashboard:
- A bucket: Storage → Storage buckets → open it → Settings → Where files can be shown.
- A video library: Video streaming → Video libraries → open it → Settings → Where videos can play.
| Setting | What it does |
|---|---|
| Allowed domains | Only pages on these sites may show the files, such as learn.example.com. A request from any other site is refused |
| Block requests without a referrer | Refuses requests that don't say which page they came from, such as a link pasted into a new tab |
| Only these countries | Serves only viewers in these countries (two-letter codes, such as IN) |
| Never these countries | Serves everyone except viewers in these countries |
| Rate limit per link | The most one link may download, in kilobits a second (64 to 10,000,000), so a shared link can't be used to pull everything at once |
Nearby, under Access (a bucket) or Playback links (a library): Bind links to the viewer's address makes a signed link work only from the address it was signed for, and Signed link lifetime (a bucket) or Link lifetime (a library) sets how long each link lasts. Under Upload rules, a bucket's Largest file, Allowed file types and Allow replacing files decide what may be uploaded; a library has Largest upload under Uploads. Strict residency is chosen when you create a bucket or library (New bucket → Strict residency): its files are stored and processed only in its region's country. Delivery still reaches viewers everywhere, so pair it with Only these countries to keep viewers to that country too.
From your code (admin key):
PATCH /api/content/v1/buckets/{bucketId}
{"delivery": {"allowedDomains": ["learn.example.com"], "blockNoReferrer": true, "countriesAllow": ["IN"], "rateLimitKbps": 8000}}
The same delivery fields go in PATCH /libraries/{libraryId}. A field you leave out is unchanged; null (or []) on a
list removes the restriction, and "rateLimitKbps": null removes the speed limit.
ASP.NET Core
await plycdn.UpdateBucketAsync(orgId, bucketId, new BucketPatch
{
Delivery = new DeliverySettings
{
AllowedDomains = new[] { "learn.example.com" }, BlockNoReferrer = true, CountriesAllow = new[] { "IN" }, RateLimitKbps = 8000,
},
});
Node.js
await storage.updateBucket(orgId, bucketId, {
delivery: { allowedDomains: ['learn.example.com'], blockNoReferrer: true, countriesAllow: ['IN'], rateLimitKbps: 8000 },
});
Python
storage.update_bucket(org_id, bucket_id, {
"delivery": {"allowedDomains": ["learn.example.com"], "blockNoReferrer": True, "countriesAllow": ["IN"], "rateLimitKbps": 8000},
})
Check it works: open a file's link from a page of an allowed site: it loads. Paste the same link straight into a new tab with Block requests without a referrer on: it is refused.
Questions and problems:
- My own page is refused. The page's host isn't in Allowed domains: add every host your pages are served from. A phone app sends no referrer: leave Block requests without a referrer off for files an app plays.
- Viewers abroad are refused. Only these countries serves no one else; travellers and company networks that leave the country from elsewhere are refused too.
- Links stop working on phones. With Bind links to the viewer's address, a phone that changes networks has a new address: leave it off for mobile viewers (Mobile viewers and IP-bound links).
- A video or recording still plays somewhere it shouldn't. Rules apply to requests from now on; a copy someone already downloaded is theirs. For video that must show who watched, add a viewer watermark.
The full rules are in Delivering.
8Replacing a file and purging
In the dashboard: Delivery → Purge cache, choose the bucket and what to remove: files by address, folders by
prefix, or the whole bucket. Nothing is deleted from storage; the next request fetches a fresh copy. The History shows
each purge until it is succeeded (usually within a minute).
From your code:
ASP.NET Core
var purge = await storage.PurgeCacheAsync(orgId, bucketId, new CachePurgeRequest
{
Urls = new[] { "https://acme-media.plycdn.net/week-1/intro.pdf" },
});
Node.js
const purge = await storage.purgeCache(orgId, bucketId, { prefixes: ['stream/course-1/'] });
Python
purge = storage.purge_cache(org_id, bucket_id, {"urls": ["https://acme-media.plycdn.net/week-1/intro.pdf"]})
Limits: a purge takes 1 to 100 addresses on the bucket's default address or an active domain, 1 to 20 key prefixes, or the
whole bucket (all); purges are free, up to 60 an hour (10 of them whole-bucket), and 429 rate_limited past that. Prefer
one request with many addresses to many requests with one. A purge made right after a settings change waits for the change to
reach the edge. Details: Purging the cache.
9Taking content down fast
Do these in order, as far as you need:
- Delete the file. In the dashboard, bucket → Files → delete, or
DeleteObjectthrough your package. It stops being listed at once; its edge copies and bytes go shortly after. Existing links to it stop working. Deleting is for good for the file. - Purge its address (or folder) if you need the edge copies gone right now: Delivery → Purge cache.
- Make the bucket private under Settings if everything in it should stop being open to anyone.
- Delete the bucket (bucket page → Delete): it stops serving immediately, every domain pauses, and nothing is lost for 7 days: Restore brings back its files, settings and domains exactly as they were. After 7 days it is gone for good. A video library is the same, but its videos aren't brought back with it.
- Rotate the signing key to end every link already issued from a private bucket (see above).
A copy already in someone's browser stays until its Browser Cache-Control runs out. To report content someone else uploaded to plycdn, write to [email protected]: see Takedowns.
10See how much is delivered, and bill your own customers
Daily figures, always on: Delivery → Delivery analytics, by day and region group and by bucket, for up to 93 days. Recent days are marked provisional and can still move.
Breakdown by what you choose (a customer, a course, a campaign): turn it on per bucket or library, on its Analytics tab, and add up to three breakdowns. Figures start at the next full hour. Each breakdown reads one of: a part of the file's path, a query parameter, a tag your server signs into the link, or a pattern. Press Preview on a rule to see how it reads your recent traffic before you save it.
To bill customers, tag each link on your server with the customer, add a breakdown that reads that tag, and download the month:
ASP.NET Core
var url = await signer.SignAsync(orgId, bucketId, key, new PlycdnSignOptions
{
Ttl = TimeSpan.FromMinutes(5),
Tags = new Dictionary<string, string> { ["customer"] = "acme" },
});
Node.js
const url = await signer.sign(orgId, bucketId, key, { ttlSeconds: 300, tags: { customer: 'acme' } });
Python
url = await signer.sign(org_id, bucket_id, key, ttl_seconds=300, tags={"customer": "acme"})
Tags work on private buckets only, not on video playback links (for a library, break down by a part of the path or a query
parameter). Links your browsers ask for through your sign route carry the tags your server decides (TagsFor, tagsFor,
tags_for), never the ones a browser sends, so nobody can label their traffic as another customer's.
Then: Delivery → Delivery breakdown charts delivery by value over time and downloads a calendar month (UTC) as CSV: one
row per customer with bytes and requests, plus other and (none) rows so the rows add up to the whole month. Choose the
breakdown by the customer name. From code (admin key) it is GetDeliveryBreakdownAsync, getDeliveryBreakdownCsv and the
matching Python calls. A traffic breakdown is charged at the list prices on Plans and prices.
11Check it works
- Open a private file's plain address in a private window: it must be refused. Open a signed link: it must load.
- Wait for the link to expire (use a 60-second link while testing): it must stop working.
- For a custom domain: its page shows active, and a signed link on it loads over HTTPS.
- Replace a file under the same key, and purge it: the next request shows the new version.
- In the dashboard open Delivery → Delivery analytics: yesterday's figures appear within a few days, today's marked provisional.
12Questions and problems
403 forbidden from the sign route. Your authorizer refused Sign (or has not been written: everything is refused
until you register one).
422 validation_error when signing. On a bucket with links bound to the viewer's address, the request needs the
viewer's IPv4 address. Behind a load balancer, trust the proxy (app.set('trust proxy', 1), uvicorn --proxy-headers,
ASP.NET Core forwarded headers). Tags on a public bucket or on a playback link are refused the same way.
A signed link answers 403. It expired, was changed after signing (a parameter added or removed), is bound to another address, or was signed with a key that has since been rotated. Get a fresh one.
domain_not_active. The hostname isn't yet active on that bucket. Wait for the domain, or check its page.
A domain is stuck in pending_dns. One of the two records is missing or not visible yet. Create both exactly as shown,
then Check now.
A domain became detached. A daily check found a record gone: restore it (dns_record_removed, ownership_record_removed).
purge_url_not_in_bucket. An address isn't on the bucket's default address or an active domain of that bucket.
rate_limited on purge or rotation. 60 purges and 60 rotations an hour: wait for Retry-After.
admin_key_required. Creating or changing buckets, domains, libraries and signing keys needs an admin key. Keep it in
the automation that sets things up.
bucket_suspended. plycdn has taken the bucket down after a report. You can still list, read and delete its files.