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 forbidden for a signed-in user your authorizer would allow. The user has no claim named by OrganizationClaimType: with no organization there is nobody to act for, so your authorizer is not even asked. Check the claim, and that the host authenticates before MapControllers().
  • 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.