Webhooks, step by step

A webhook tells your backend when something happens to a video or a live stream, so you never have to poll: publish a lesson when its video is ready, alert someone when one failed, show "we are live" when a broadcast starts. It also tells your servers when a bucket's or library's signing settings change, so the links they sign keep working. This page takes you from adding an endpoint to receiving, verifying and debugging deliveries. Every field is in the reference: Webhooks, Live webhooks and the API reference.

You need: a public https:// address on your backend that can receive a POST, a place to keep a secret, and one of plycdn's server packages (ASP.NET Core, Node.js or Python) to verify deliveries. Creating and changing endpoints needs an admin API key.

1How it works

  1. You add an endpoint: the address plycdn calls, and the events it should hear about.
  2. plycdn shows you a signing secret once. You store it next to the code that receives events.
  3. When an event happens, plycdn sends a POST with a JSON body and a signature made with the secret.
  4. Your server verifies the signature, answers 2xx within 10 seconds and does the work afterwards.
  5. If it does not answer 2xx, plycdn tries again for about three days. The delivery log shows every attempt.

2Add an endpoint

In the dashboard: Developers → Webhooks → Add endpoint. Enter the Address (for example https://api.example.com/plycdn/events), an optional Description, and choose at least one event. When you save, the page shows Copy the endpoint's signing secret now: copy it into your secret store, then choose I've stored it. It is shown once and never again.

From your code:

POST /api/content/v1/webhooks
{"url": "https://api.example.com/plycdn", "events": ["video.ready", "video.failed"], "description": "LMS"}

ASP.NET Core

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);        // shown once

Node.js

const hook = await video.createWebhook(orgId, { url: 'https://api.example.com/plycdn', events: ['video.ready', 'video.failed'], description: 'LMS' });
await secrets.save('plycdn-webhook', hook.secret);             // shown once

Python

hook = video.create_webhook(org_id, {"url": "https://api.example.com/plycdn", "events": ["video.ready", "video.failed"], "description": "LMS"})
secrets.save("plycdn-webhook", hook["secret"])                 # shown once

The address must be https://, on port 443, without credentials, and resolve to a public address (otherwise webhook_url_invalid; plain http:// is validation_error). You can have up to 10 endpoints (webhook_limit_reached). Each sub-organization has its own endpoints and hears only about its own videos. To stop deliveries without deleting an endpoint, choose Pause (active: false); Resume starts them again.

3Choose the events

Event Sent when
video.playable The video's lowest quality is ready and it plays
video.ready Every quality is ready
video.failed The video failed; data.video.errorCode says why
live.connected An encoder or browser connected and was accepted (again, with reconnected: true, when it returns)
live.started The first picture is out: viewers can watch
live.disconnected The encoder dropped; the stream waits for it
live.ended The stream ended; data.recording.id is the replay (or null)
live.failover With backup ingest, the stream switched between the primary and the backup
live.destination_failed A restream destination failed three times within five minutes
live.ad_break An ad break started
signing_profile.changed A bucket's or library's signing key, domains, link settings, serial playback or watermark requirement changed. The server packages use it to refresh their cached signing settings (below)
webhook.ping Only when you send a test event; you never subscribe to it

Video events carry the video in data.video; live events carry ids (liveInputId, streamId, and more) in data; signing_profile.changed carries bucketId (the bucket or library), kind (bucket or library) and reasons (any of key_rotated, domains_changed, delivery_changed, serial_changed, scheme_changed, watermark_changed). The fields of each are in Live webhooks. A request looks like this:

{"id":"8b9c…","type":"video.ready","createdAt":"2026-09-26T10:15:00+00:00","organizationId":"…","data":{"video":{"id":"…","title":"Week 1: Introduction","status":"ready"}}}

Its headers: Plycdn-Event (the event), Plycdn-Delivery (this delivery's id, the same on every retry), Plycdn-Webhook (the endpoint's id) and Plycdn-Signature.

4Receive and verify

Always verify before acting on a delivery: anyone can send a request to your URL. The signature covers the exact bytes of the body, so read the body raw, before any JSON parser touches it.

ASP.NET Core

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(); }

    if (!await lessons.FirstTimeAsync(request.Headers["Plycdn-Delivery"].ToString()))
        return Results.Ok();                                   // already handled: a retry of a delivery we took

    if (evt.Type == "video.ready" && evt.AsVideo() is { } video)
        await lessons.PublishAsync(video.Id);
    return Results.Ok();
});

WebhookSecrets is a list so you can hold the new and the previous secret during a rotation. PlycdnWebhookVerifier.Verify(body, signatureHeader, secrets) does the same over bytes you already hold, and PlycdnWebhookEvent.AsLive() reads a live event.

Node.js (Express)

import express from 'express';
import { createPlycdnWebhookHandler } from '@plycdn/node/express';

const app = express();
const seen = new Set<string>();

// Before express.json(): the signature covers the exact bytes that were sent.
app.post('/webhooks/plycdn', createPlycdnWebhookHandler({
  secrets: [process.env.PLYCDN_WEBHOOK_SECRET!, process.env.PLYCDN_WEBHOOK_SECRET_PREVIOUS ?? ''].filter(Boolean),
  onEvent: async (event) => {
    if (seen.has(event.id)) return;          // a delivery can arrive twice: act on each event id once
    seen.add(event.id);
    if (event.type === 'video.ready') console.log('ready', event.data);
  },
}));

app.use(express.json());

On another server framework, PlycdnWebhookVerifier.verify(rawBody, signatureHeader, secrets, { toleranceSeconds }) returns true or false, and PlycdnWebhookVerifier.read(req, secrets) throws PlycdnWebhookSignatureError. Event names are in PlycdnWebhookEvents.

Python

import os

from plycdn.fastapi import plycdn_webhook_router

seen = set()


async def on_event(event, request):
    if event["id"] in seen:           # a delivery can arrive twice: act on each event id once
        return
    seen.add(event["id"])
    if event["type"] == "video.ready":
        print("ready", event["data"])


def add_webhooks(app):
    secrets = [os.environ["PLYCDN_WEBHOOK_SECRET"], os.environ.get("PLYCDN_WEBHOOK_SECRET_PREVIOUS", "")]
    app.include_router(plycdn_webhook_router("/webhooks/plycdn", secrets=[s for s in secrets if s], on_event=on_event))

Elsewhere, PlycdnWebhookVerifier.verify(raw_body, signature_header, secrets, tolerance_seconds=300) returns True or False. Event names are in PlycdnWebhookEvents.

The Node.js and Python receivers answer 200 after your handler returns, 401 for a missing, stale or forged signature, 400 for a body over 1 MiB or not an event, and 500 when your own code fails, which makes plycdn deliver again. The timestamp must be within five minutes of your server's clock, so keep it correct.

Keep your signers current

The server packages sign links in your process from each bucket's or library's signing settings, which they keep until they change. Subscribe an endpoint to Signing settings changed (signing_profile.changed) and hand the receiver your signer: a key rotated, a domain removed or serial playback switched on in the dashboard or from another server then applies at once. Without it, the packages pick up a change within about an hour.

ASP.NET Core: with Plycdn.AspNetCore.Video, nothing to add: its PlycdnWebhookVerifier.ReadAsync refreshes the PlycdnLinkSigner that AddPlycdnStorage or AddPlycdnVideo registered whenever this event arrives. With Plycdn.AspNetCore.Storage alone (no verifier), or an event read another way, call signer.HandleWebhook(organizationId, type, data) on the registered PlycdnLinkSigner once you have verified the delivery.

Node.js: build the signer once and give the same one to your routes and to the receiver (a second signer would be refreshed while your routes kept the old settings):

import express, { type Request } from 'express';
import { PlycdnStorageApi, PlycdnLinkSigner } from '@plycdn/node/storage';
import { createPlycdnRouter, createPlycdnWebhookHandler } from '@plycdn/node/express';

const app = express();
const storage = new PlycdnStorageApi({ apiKey: process.env.PLYCDN_API_KEY! });
const signer = new PlycdnLinkSigner(storage);   // the one signer of this process

app.post('/webhooks/plycdn', createPlycdnWebhookHandler({
  secrets: [process.env.PLYCDN_WEBHOOK_SECRET!],
  signer,                                    // refreshed before onEvent runs
  onEvent: async () => {},
}));

// Your own rule for who may do what: here, only signed-in instructors.
const authorize = (req: Request) => (req as Request & { user?: { roles?: string[] } }).user?.roles?.includes('instructor') === true;

app.use('/api/plycdn/v1', createPlycdnRouter({
  storage,
  signer,                                    // the same signer
  resolveOrganization: (req) => (req as Request & { user?: { plycdnOrganizationId?: string } }).user?.plycdnOrganizationId ?? null,
  authorize,                                 // your own rule for who may do what
}));

PlycdnWebhookVerifier.read(req, secrets, { signer }) does the same on another framework, and signer.handleWebhook(event) with an event you read yourself.

Python: the same, with the AsyncPlycdnLinkSigner your routes sign with:

import os

from plycdn.fastapi import plycdn_router, plycdn_webhook_router
from plycdn.storage import AsyncPlycdnLinkSigner, AsyncPlycdnStorageApi

storage = AsyncPlycdnStorageApi(os.environ["PLYCDN_API_KEY"])
signer = AsyncPlycdnLinkSigner(storage)   # the one signer of this process


async def on_event(event, request):
    pass


def authorize(request, operation, bucket_id, key):
    # Your own rule for who may do what: here, only signed-in instructors.
    return "instructor" in getattr(getattr(request.state, "user", None), "roles", [])


def add_plycdn(app):
    app.include_router(plycdn_webhook_router("/webhooks/plycdn", secrets=[os.environ["PLYCDN_WEBHOOK_SECRET"]],
                                             on_event=on_event, signer=signer))
    app.include_router(
        plycdn_router(
            storage=storage,
            signer=signer,            # the same signer
            resolve_organization=lambda request: getattr(getattr(request.state, "user", None), "plycdn_organization_id", None),
            authorize=authorize,      # your own rule for who may do what
        ),
        prefix="/api/plycdn/v1",
    )

await PlycdnWebhookVerifier.read(request, secrets, signer=signer) does the same elsewhere, and signer.handle_webhook(event) with an event you read yourself.

Check it works: sign a link, rotate the bucket's key in the dashboard (the bucket → Signing → Rotate key), wait for the signing_profile.changed delivery in the log, and sign again: the new link works once the rotation is live (about a minute for plycdn links).

On a stack of your own

The Plycdn-Signature header is t=<unix seconds>,v1=<hex signature>, with a second v1 during a secret rotation. Compute HMAC-SHA256 with the secret as the key over t, a ., and the exact body bytes; the request is genuine when the lower-case hex equals any v1 (compare in constant time) and t is within five minutes. The full Node.js example is in Verifying the signature.

5Answer fast, and expect repeats

  • Answer any 2xx within 10 seconds, then do the work afterwards in a queue or background job. A slower answer is a failed attempt.
  • Deduplicate. A delivery can arrive more than once (your answer was lost, or you asked for a redelivery). Act once per Plycdn-Delivery (or per event id, as the Node.js and Python examples do), and keep what you have seen in storage that survives a restart, not in memory as these short examples do.
  • Do not rely on order. Events can arrive out of order: use data.video.status, or read a stream's state from the API.
  • Answer 410 Gone to stop retrying a delivery at once. Redirects are never followed.

6Retries

A delivery that is not answered with 2xx is tried again after 1 minute, 5 minutes, 15 minutes, 30 minutes, 1, 2, 4, 8 and 12 hours, then 24 hours twice: 12 attempts over about three days, after which it is failed. An endpoint is never switched off because its deliveries fail: fix it, then redeliver what it missed.

7Test it

In the dashboard: Developers → Webhooks, the Send test event button on the endpoint's row. A real, signed webhook.ping is sent, whatever the endpoint is subscribed to; follow its outcome in the delivery log. From code: PingWebhookAsync, pingWebhook or ping_webhook. A paused endpoint records the test but does not send it.

8Read the delivery log and redeliver

Select an endpoint to see its Deliveries: event, state (pending, succeeded or failed), attempts, the last answer and the payload that was sent. The log keeps 90 days. Choose Redeliver on a finished delivery to send it once more, with the same Plycdn-Delivery and body, for example after you fixed your endpoint.

From code:

var failed = await videos.ListWebhookDeliveriesAsync(orgId, hook.Id, state: "failed");
foreach (var delivery in failed.Items)
    await videos.RedeliverWebhookAsync(orgId, delivery.Id);

Node.js has listWebhookDeliveries and redeliverWebhook; Python has list_webhook_deliveries and redeliver_webhook. Pings, redeliveries and secret rotations together are limited to 60 an hour for your account (429 rate_limited, with Retry-After).

A failed attempt's lastError says what to fix:

lastError What to do
timeout No answer within 10 seconds: answer first, then do the work
connection_failed The endpoint refused or dropped the connection: check it is up and reachable from the internet
tls_failed Serve a valid certificate for the endpoint's name, from a public certificate authority
dns_failed The name did not resolve: check the URL and your DNS
address_forbidden The name resolved to a private or local address: publish the endpoint on a public address
redirect_not_followed The endpoint answered with a redirect: use the final URL
http_error The endpoint answered, but not 2xx (lastStatus has the status): check your logs for that time

9Rotate the secret

If a secret may have leaked, or on your own schedule: Developers → Webhooks → Rotate secret on the endpoint, then confirm. The new secret is shown once. For the next 24 hours every delivery carries two signatures, one with each secret, so you can deploy the new secret without missing anything:

  1. Rotate, copy the new secret into your secret store.
  2. Give your receiver both secrets (the secrets list above) and deploy.
  3. After 24 hours only the new secret signs; remove the old one.

From code: RotateWebhookSecretAsync, rotateWebhookSecret or rotate_webhook_secret. To change the address or events, use Edit. Delete endpoint removes it for good.

10Not the same as the Content Search callback

Content Search has one more signed request from plycdn to your server: the source-access callback, where plycdn asks your server for a fresh link to a file you registered. It is a different mechanism; do not mix them up.

Webhooks (this page) Content Search source-access callback
Purpose Tells you something happened (a video, a stream) Asks you for a URL to read one of your files
Where you set it Developers → Webhooks Content search → Indexing settings
Signature header Plycdn-Signature (t=…,v1=…) X-Content-Signature, with X-Content-Timestamp and X-Content-Nonce
What is signed t, a ., and the body; HMAC-SHA256, hex <timestamp>, a newline, <nonce>, a newline, and the body; HMAC-SHA256, base64
Secret The endpoint's pwhs_… secret, shown once The Content Search callback secret
You answer Any 2xx; nothing to return 200 with a JSON url and expiresAt
Verified by PlycdnWebhookVerifier (and the Node.js and Python receivers) The Content Search package's own callback controller

A verifier for one never accepts the other. The callback is explained in The callback contract and the appendix on the source-access callback.

11Check it works

  1. Add an endpoint for video.ready and video.failed, and store the secret.
  2. Choose Send test event. The delivery shows succeeded in the log, and your receiver logged a webhook.ping.
  3. Upload a short video to a library. When it is ready, a video.ready delivery arrives; your handler runs once.
  4. Change the secret in your receiver on purpose and send another test event: it shows failed with an http_error (status 401). Put the right secret back and choose Redeliver.

12Questions and problems

My receiver answers 401 to every delivery. The body was changed before the check (read it raw: mount the webhook route before express.json()), the secret is the wrong one (an old secret after a rotation, or one from another endpoint), or your server's clock is more than five minutes off.

I lost the secret. It cannot be shown again. Rotate it and store the new one.

Deliveries stop arriving. Check the endpoint is Active (not paused) and subscribed to the event. A delivery due while it was paused ends failed with no lastError; resume it, then redeliver what it missed.

The same event arrived twice. Expected: your answer was lost, or you redelivered. Deduplicate on Plycdn-Delivery.

The events arrive out of order. Expected. A video.ready can come before its video.playable is handled; rely on the status in the data.

webhook_url_invalid when I add an endpoint. The address must be https:// on port 443, printable ASCII (use the xn-- form for an international name), without credentials, and must resolve to a public address.

webhook_limit_reached. An organization can have 10 endpoints: delete one you no longer use, or send more events to one.

admin_key_required. Creating, changing and deleting endpoints needs an admin key, on your server only.

webhook_not_found. The endpoint was deleted, or belongs to another organization or sub-organization.

I need to know that nothing was missed. Read the delivery log (state: failed) and redeliver; it is kept for 90 days.