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
- You add an endpoint: the address plycdn calls, and the events it should hear about.
- plycdn shows you a signing secret once. You store it next to the code that receives events.
- When an event happens, plycdn sends a
POSTwith a JSON body and a signature made with the secret. - Your server verifies the signature, answers
2xxwithin 10 seconds and does the work afterwards. - 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
2xxwithin 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 eventid, 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'sstatefrom the API. - Answer
410 Goneto 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:
- Rotate, copy the new secret into your secret store.
- Give your receiver both secrets (the
secretslist above) and deploy. - 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
- Add an endpoint for
video.readyandvideo.failed, and store the secret. - Choose Send test event. The delivery shows
succeededin the log, and your receiver logged awebhook.ping. - Upload a short video to a library. When it is ready, a
video.readydelivery arrives; your handler runs once. - Change the secret in your receiver on purpose and send another test event: it shows
failedwith anhttp_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.