Storage & video with ASP.NET Core
Audience: .NET developers who store files, upload them, deliver them and play video through plycdn from an ASP.NET Core backend. This guide takes you from an empty project to uploads, signed links, video playback and webhooks. Your backend holds the plycdn API key and decides who may do what; browsers (Angular, React, or your own code) call your backend, never plycdn directly. The browser side is in Storage & video with Angular and Storage & video with React. What each call returns, field by field, is in the Storage and Video guides and the API reference.
1Install the packages
There is one package per purpose, so you install only what you use. All of them target .NET 6 to 10.
| Package | What it gives you |
|---|---|
Plycdn.AspNetCore.Storage |
Buckets, custom domains, uploads, signed links, cache purges, usage and audit from your server (PlycdnStorageApi, PlycdnUploader, PlycdnLinkSigner), and the routes your frontend calls (PlycdnStorageController, at api/plycdn/v1). |
Plycdn.AspNetCore.Video |
Video libraries, videos, playback links, webhooks (PlycdnVideoApi, PlycdnVideoSigner, PlycdnWebhookVerifier) and the video route (PlycdnVideoController). Builds on Storage. |
Plycdn.AspNetCore.Storage.S3 |
An S3 client configured for a plycdn region, for tools and pipelines. |
Plycdn.AspNetCore.Storage.AzureBlob |
An Azure Blob client configured for your storage account. |
dotnet add package Plycdn.AspNetCore.Storage --version 1.4.0 --source ./vendor/nuget
dotnet add package Plycdn.AspNetCore.Video --version 1.4.0 --source ./vendor/nuget # only if you use video
Plycdn.AspNetCore.Video depends on Plycdn.AspNetCore.Storage, so installing Video alone is enough
for video. These packages are independent of Plycdn.ContentSearch.AspNetCore: use either, or both in one
application.
2Configure
Add a Plycdn section to your server configuration. Keep the API key in your secret store (user
secrets, environment variables, your host's secret manager), never in source control and never in a
browser bundle:
{
"Plycdn": {
"ApiKey": "<server API key>",
"OrganizationClaimType": "plycdn_organization_id"
}
}
| Setting | Default | Meaning |
|---|---|---|
ApiBaseUrl |
https://api.plycdn.com/ |
The address of the API. Leave it out unless your plycdn contact gives you another HTTPS address. |
ApiKey |
none | Your server's API key, from the dashboard. A standard key does everything a running service needs; creating, changing and deleting buckets, libraries, domains and webhooks needs an admin key (admin_key_required otherwise). Keep the admin key in the automation that sets things up. |
OrganizationClaimType |
plycdn_organization_id |
The claim on your signed-in user that holds their plycdn organization id. The browser never chooses an organization. |
SigningProfileCacheFor |
10 minutes | How long the link signer keeps a bucket's signing profile before fetching it again. |
MaxBrowserLinkTtl |
none | The longest lifetime your browser sign route will issue (at least 60 seconds). |
CompatDomain |
plycdn.net |
The domain of the storage hosts. Leave it as it is unless plycdn tells you otherwise. |
If your servers restrict outbound traffic, allow api.plycdn.com on port 443.
3Register the services
Register storage, or video (which registers storage as well), in an authenticated host, and keep
MapControllers():
using Plycdn.AspNetCore.Storage;
using Plycdn.AspNetCore.Video;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddPlycdnVideo(builder.Configuration); // storage and video
// builder.Services.AddPlycdnStorage(builder.Configuration); // storage only
builder.Services.AddScoped<IPlycdnStorageAuthorizer, CourseFilesAuthorizer>();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
Calling AddPlycdnStorage and AddPlycdnVideo together is harmless. Both have an overload that takes
an Action<PlycdnOptions> when you configure in code. Registration adds PlycdnStorageApi,
PlycdnUploader, PlycdnLinkSigner and the browser routes, and with video PlycdnVideoApi and
PlycdnVideoSigner; inject what you need.
4Decide who may do what
The browser routes under api/plycdn/v1 are refused for everyone until you register an
IPlycdnStorageAuthorizer. Every request asks it about one operation, on one bucket, and where it
applies one key (a file's key; for video, the video id). The organization always comes from the signed-in
user's OrganizationClaimType claim.
PlycdnOperation |
Asked for | key |
|---|---|---|
ListObjects |
listing a bucket's files | the prefix, if any |
GetObject |
reading one file | the file's key |
StartUpload |
starting an upload | the key it will have |
ContinueUpload |
renewing, completing, aborting or reading an upload | the upload's key |
Sign |
getting a signed link for a file | the file's key |
GetVideo |
reading a video (title, status, captions) | the video id |
WatchVideo |
getting a playback link | the video id |
using System.Security.Claims;
using Plycdn.AspNetCore.Storage;
public sealed class CourseFilesAuthorizer : IPlycdnStorageAuthorizer
{
public Task<bool> AuthorizeAsync(ClaimsPrincipal user, PlycdnOperation operation, Guid bucketId,
string? key, CancellationToken cancellationToken) =>
Task.FromResult(operation switch
{
PlycdnOperation.ListObjects or PlycdnOperation.GetObject or PlycdnOperation.Sign
or PlycdnOperation.GetVideo or PlycdnOperation.WatchVideo => user.IsInRole("learner"),
_ => user.IsInRole("instructor"),
});
}
Make the decision from your own data: which course a bucket belongs to, whether the user is enrolled, and
so on. A refusal answers 403 {"code":"forbidden"} and reveals nothing about the file.
Two things to know about proxies and viewers. For a bucket that binds links to the viewer's address, the
sign route signs for the request's remote address, which must be IPv4: behind a load balancer, configure
ASP.NET Core's forwarded headers so that address is the viewer's. With no address, or an IPv6 one, the
route answers 422 validation_error rather than issue a link the delivery network would refuse. And a
cross-origin frontend needs your own CORS policy for the plycdn routes, as for any other API of yours.
5Buckets and server uploads
From server code, inject PlycdnStorageApi (calls), PlycdnUploader (uploads) and PlycdnLinkSigner
(links):
using Plycdn.AspNetCore.Storage;
public sealed class CourseMedia
{
private readonly PlycdnStorageApi api;
private readonly PlycdnUploader uploader;
private readonly PlycdnLinkSigner signer;
public CourseMedia(PlycdnStorageApi api, PlycdnUploader uploader, PlycdnLinkSigner signer)
{ this.api = api; this.uploader = uploader; this.signer = signer; }
public async Task<Uri> PublishAsync(Guid orgId, Stream file, long length)
{
// Admin key. A bucket's name is global, like an S3 bucket's: choose one that is yours.
var bucket = await api.CreateBucketAsync(orgId, new BucketCreate("acme-course-media", "mumbai"));
// Server to server: plans the parts, uploads them with retries, completes the session.
StoredObject stored = await uploader.UploadAsync(orgId, bucket.Id, "week-1/intro.mp4", file, length, "video/mp4");
return await signer.SignAsync(orgId, bucket.Id, stored.Key, TimeSpan.FromMinutes(15));
}
}
Region codes are strings from ListRegionsAsync; treat them as data. Bucket.State is creating until the
bucket is active (poll GetBucketAsync), and BucketCreate also takes Visibility (private by
default, or public), StrictResidency, Delivery, UploadRules, Cache and Cors. Other calls on
PlycdnStorageApi include ListBucketsAsync, UpdateBucketAsync, DeleteBucketAsync,
RestoreBucketAsync, ListObjectsAsync, GetObjectAsync, DeleteObjectAsync, the domain calls
(CreateDomainAsync, CheckDomainAsync, ListDomainsAsync), PurgeCacheAsync,
GetDeliveryAnalyticsAsync, GetUsageByResourceAsync and GetAuditAsync.
When you hand the bytes of a file to a browser to upload, you do not use PlycdnUploader: the browser
packages upload straight to the bucket's region with a short-lived ticket, and your backend only relays the
control calls through the controller. Use PlycdnUploader when your own server holds the bytes (an import,
a migration, a nightly job).
An organization can hold at most 1,000 open uploads at once; one more fails with plan_limit_reached
(403) until one completes, is aborted or expires. A session left open (an interrupted UploadAsync, whose
PlycdnException carries UploadId, or one you started with CreateUploadAsync) counts until it expires
after 7 days: abort the ones you will not resume (AbortUploadAsync).
6Signed links
A public bucket serves its files at https://<bucket>.plycdn.net/<key> with no signing. A private
bucket needs a signed link, which PlycdnLinkSigner makes in your own process from the bucket's cached
signing profile, with no request to plycdn per link:
Uri link = await signer.SignAsync(orgId, bucketId, "week-1/intro.mp4", TimeSpan.FromMinutes(15));
// Write the link out with link.AbsoluteUri: it keeps the key's percent-encoding.
PlycdnLinkSigner and PlycdnVideoSigner sign plycdn links (pl1, and pl1d for a whole video) in your process from
Storage & video 1.4.0; earlier versions ask plycdn to sign them (/sign), so they keep working unchanged.
A link lasts 60 seconds to 7 days (the bucket's linkTtlSeconds when you leave the lifetime out). For a
bucket with IP binding pass the viewer's IPv4 address. To serve a bucket on your own domain, add the domain,
create the DNS records it lists at your DNS host, and sign on it:
var domain = await api.CreateDomainAsync(orgId, new DomainCreate(bucketId, "media.example.com"));
// domain.Records: the TXT (ownership) and CNAME (routing) records to create at your DNS host
domain = await api.CheckDomainAsync(orgId, domain.Id); // check now rather than wait
Uri onDomain = await signer.SignAsync(orgId, bucketId, "week-1/intro.mp4",
new PlycdnSignOptions { Ttl = TimeSpan.FromMinutes(15), Hostname = "media.example.com" });
PlycdnSignOptions carries the lifetime (Ttl), the viewer's address (Ip), the hostname and Tags. A hostname
that is neither the bucket's default nor one of its active domains is refused with domain_not_active
(409). The signer checks against its cached profile: a domain that has just become active is found by
one extra fetch (at most one a minute per bucket); after deleting a domain, call
signer.Forget(orgId, bucketId) so this process stops signing on it. Rotating a bucket's signing key
(RotateSigningKeyAsync, admin key) invalidates every link signed with the old key, so refresh the cached
profile before you rotate, not after. Under invariant globalization pass a Unicode hostname in its xn--
form (domain.Hostname). If you would rather have plycdn sign for you, api.SignAsync(orgId, new SignRequest(bucketId, Key: "week-1/intro.mp4")) does it over HTTP.
Tags and delivery analytics
PlycdnSignOptions.Tags labels a link so delivery analytics can break traffic down by it, for example by the
customer a viewer belongs to:
Uri link = await signer.SignAsync(orgId, bucketId, "week-1/intro.mp4",
new PlycdnSignOptions { Tags = new Dictionary<string, string> { ["customer"] = "acme" } });
Up to 8 tags per link; a name is a lower-case letter then lower-case letters, digits and underscores (32
characters at most), a value 1 to 128 bytes without control characters or any of &, =, ? and #. The signer
refuses anything else with an ArgumentException naming the tag. Until link tags are enabled for your account (the
signing profile's TagsEnabled), the signer refuses any tag with PlycdnException validation_error, detail "Link tags
are not available yet; sign the link without tags", exactly as the service does; links without tags are unaffected. Tags are for private buckets (a public bucket's links are not signed:
a request to sign one with tags is refused with 422 validation_error) and are not available on video playback links (also refused). A viewer who
edits a tag in a link is counted as (unverified).
For links your browsers ask for through the sign route, tags the browser sends are ignored by default, so
visitors cannot label their own links as someone else. Set them from your signed-in user with
PlycdnOptions.TagsFor, or, if the labels are harmless to you, set PlycdnOptions.AcceptBrowserTags = true:
builder.Services.AddPlycdnStorage(o =>
o.TagsFor = (http, request) => new Dictionary<string, string> { ["customer"] = http.User.FindFirst("customer")!.Value });
Turn analytics on and choose breakdowns with BucketPatch.Analytics (BucketAnalyticsPatch: Enabled,
Dimensions of AnalyticsRule, Ceiling; ClearAnalyticsCeiling returns the ceiling to your plan's), on a
library with UpdateLibraryRequest.Analytics. Read the results with an admin key:
GetDeliveryAnalyticsSeriesAsync (over time), GetDeliveryBreakdownAsync (a month, by organization or by a
breakdown's values) and GetDeliveryBreakdownCsvAsync (the same month as a stream of CSV; dispose the stream),
and try a rule on recent traffic with PreviewAnalyticsRuleAsync. A bucket's Analytics shows Enabled,
Dimensions, Ceiling, EffectiveCeiling and Since. The routes, answers and errors
(too_many_rows, export_busy, too_many_organizations, analytics_unavailable) are in the
Storage guide and the API reference.
7Video
Video builds on storage: a library is a private bucket made for video. You create a library once, then
create a video for each recording; plycdn encodes it into adaptive streaming and tells your backend when it
is ready. Register AddPlycdnVideo (above) and inject PlycdnVideoApi and PlycdnVideoSigner.
Libraries and videos
Creating a library needs an admin key. A video is created before its file is uploaded; the answer carries
the upload session to hand to the browser, and the video goes uploading → queued → encoding →
playable → ready (or failed, with an ErrorCode):
using Plycdn.AspNetCore.Video;
var library = await videos.CreateLibraryAsync(orgId, new CreateLibraryRequest("acme-lectures", "mumbai"));
var created = await videos.CreateVideoAsync(orgId, library.Id,
new CreateVideoRequest("Week 1: Introduction", Size: fileSize, ContentType: "video/mp4", FileName: "week-1.mp4"));
// created.Id is the video; created.Upload is the session. Return it to the browser, which uploads the
// parts and completes the upload through your controller. Completing it moves the video to "queued".
CreateLibraryRequest also takes Processing (the quality ladder, whether to keep the original, downloads
and thumbnails), Delivery and MaxObjectBytes. If your server holds the file instead, upload it with
PlycdnUploader to the key sources/{created.Id}.mp4 of the library, as for any bucket. Read a video with
GetVideoAsync, list a library's with ListVideosAsync(orgId, libraryId, status: "failed"), change a
title or metadata with UpdateVideoAsync and remove one with DeleteVideoAsync.
Leave Processing out and the library uses your organization's video settings; a sub-organization uses
its parent's unless it sets its own, higher or lower. Set them with VideoDefaults (Ladder,
KeepOriginal, Download, Thumbnails) when you change settings with UpdateSettingsAsync, and read
them, with where each value comes from, from GetSettingsAsync. A library's Processing is null when it
uses its organization's settings, and updating a library with Processing null returns it to them.
ReencodeVideoAsync(orgId, videoId) encodes a ready video again with the settings that apply now
(original_not_kept when its original was not kept); the video's Encoding says what it was made with.
Playback
The player in your pages asks your backend for two things, both authorized by your
IPlycdnStorageAuthorizer: GET api/plycdn/v1/videos/{id} (the video, PlycdnOperation.GetVideo) and
POST api/plycdn/v1/sign with a videoId (a playback link, PlycdnOperation.WatchVideo). Both pass the
library as bucketId and the video id as key; the browser packages make these calls for you.
To make a link yourself, sign it in your own process:
PlaybackLink link = await videoSigner.SignVideoAsync(orgId, library.Id, video.Id, TimeSpan.FromHours(4));
// link.Url: the stream, for your player. link.NativeUrl: for players that play adaptive streams natively, or null.
// link.ExpiresAt, link.PosterUrl, link.ThumbnailsUrl, link.DownloadUrl.
Signed locally, PosterUrl, ThumbnailsUrl and DownloadUrl are always set: use them only when
video.Poster, video.Thumbnails and video.Download say the file exists. Signing locally does not check
that the video exists or can play yet; the sign route does, and answers video_not_ready or
video_not_found when that matters. The expiry comes from this server's clock, so keep it
synchronised (NTP).
Webhooks
Webhooks tell your backend when a video is playable, ready or failed, so you do not poll. Create an endpoint once (admin key); the secret is shown once, so keep it in your secret store:
var hook = await videos.CreateWebhookAsync(orgId,
new CreateWebhookRequest(new Uri("https://api.example.com/plycdn"), new[] { "video.ready", "video.failed" }, "LMS"));
await secrets.SaveAsync("plycdn-webhook", hook.Secret); // your own secret store
await videos.PingWebhookAsync(orgId, hook.Id); // sends a webhook.ping to try it
Receive them with PlycdnWebhookVerifier, which reads the body, checks the signature and parses the
event. Always verify before acting: anyone can send a request to your URL.
app.MapPost("/plycdn", async (HttpRequest request, IOptions<MySecrets> secrets, Lessons lessons) =>
{
PlycdnWebhookEvent evt;
try { evt = await PlycdnWebhookVerifier.ReadAsync(request, secrets.Value.WebhookSecrets); }
catch (PlycdnWebhookSignatureException) { return Results.Unauthorized(); }
// An event can arrive more than once: act once per delivery.
if (!await lessons.FirstTimeAsync(request.Headers["Plycdn-Delivery"].ToString()))
return Results.Ok();
if (evt.Type == "video.ready" && evt.AsVideo() is { } video)
await lessons.PublishAsync(video.Id);
return Results.Ok();
});
secrets.Value.WebhookSecrets is a list so that you can hold the new and the previous secret while you
rotate (RotateWebhookSecretAsync). PlycdnWebhookVerifier.Verify(body, signatureHeader, secrets) does the
same over bytes you already hold. To see what was delivered, ListWebhookDeliveriesAsync(orgId, hook.Id, state: "failed") and RedeliverWebhookAsync(orgId, deliveryId). Return a 2xx quickly; plycdn retries
anything else.
8S3 and Azure clients
Plycdn.AspNetCore.Storage.S3 and Plycdn.AspNetCore.Storage.AzureBlob build the official S3 and Azure
Blob clients for plycdn, for migrations, backups and pipelines. They sign with a storage credential,
created in the dashboard under Developers → Storage credentials: a server-side secret like the API
key, never for a browser.
dotnet add package Plycdn.AspNetCore.Storage.S3 --version 1.4.0 --source ./vendor/nuget
dotnet add package Plycdn.AspNetCore.Storage.AzureBlob --version 1.4.0 --source ./vendor/nuget
using Amazon.S3.Model;
using Plycdn.AspNetCore.Storage.AzureBlob;
using Plycdn.AspNetCore.Storage.S3;
// The host label comes from GET /regions (the region's endpoints); signing uses the region code.
using var s3 = await PlycdnS3.CreateClientAsync(api, orgId, "mumbai", accessKeyId, secretAccessKey);
await s3.PutObjectAsync(new PutObjectRequest { BucketName = "acme-course-media", Key = "notes.txt", FilePath = "notes.txt" });
var service = await PlycdnBlob.CreateServiceClientAsync(api, orgId, "acmelearning", accountKey, "mumbai");
var blob = service.GetBlobContainerClient("acme-course-media").GetBlobClient("notes.txt");
await blob.UploadAsync("notes.txt", overwrite: true);
services.AddPlycdnS3(configuration) and services.AddPlycdnBlob(configuration) register the same clients
for dependency injection from the Plycdn:S3 section (Region, HostLabel, AccessKeyId, SecretAccessKey,
and optionally PathStyle, which puts the bucket in the path rather than the host) and the Plycdn:Blob
section (Account, AccountKey, and optionally Region with its HostLabel). You pass the region code
(mumbai) and, in configuration, the host label shown in the region's endpoints in GET /regions
(in-mum); the clients connect to the region's hosts (s3.in-mum.plycdn.net,
<account>.blob.in-mum.plycdn.net) and sign with the code. The region code is never used as a host label: a
registration without the label stops at startup with a message that names the setting, and the async
CreateClientAsync / CreateServiceClientAsync read the label from GET /regions themselves, so with those
HostLabel is only an override. Hosts, supported operations and limits are in the
Compatibility guide.
9Errors
Every refusal from plycdn is a PlycdnException with Code (the documented code, such as
bucket_not_found or admin_key_required), Status (the HTTP status), Retryable and, when plycdn said so,
RetryAfter. Match on Code, never on the message:
try { await api.DeleteBucketAsync(orgId, bucketId); }
catch (PlycdnException e) when (e.Code == "admin_key_required")
{
logger.LogError("This server holds a standard key; deleting a bucket needs the admin key.");
}
catch (PlycdnException e) when (e.Retryable)
{
await Task.Delay(e.RetryAfter ?? TimeSpan.FromSeconds(5));
// try again
}
The codes are tabulated in the Storage, Video and API guides.
The browser routes answer the same codes, plus forbidden when your authorizer refused the request.
10Troubleshooting
- Every browser call answers
403 forbidden. No authorizer is registered, or it returned false for that operation. The default refuses everything. 403 forbiddenfor a signed-in user your authorizer would allow. The user has no claim named byOrganizationClaimType: with no organization there is nobody to act for, so your authorizer is not even asked. Check the claim, and that the host authenticates beforeMapControllers().admin_key_required. The server holds a standard key for a call that needs an admin key.- A link works for a while, then stops after a key rotation. Refresh the cached profile
(
signer.Forget) before rotating, not after. - Where do I see what happened? The dashboard's bucket and library pages show state; usage and the audit log are under Account → Usage and Account → Audit log.