Webhooks
Playflow sends signed HTTPS requests when events occur in your workspace. A delivery can be retried, so use the event id for idempotency and do not assume delivery order.
Event catalog
Workspace endpoints can subscribe to these 13 events. The webhook.test event is sent only by the Test endpoint action and cannot be selected as a subscription.
| Event | Example purpose |
|---|---|
video.ready | A video is ready to play. |
video.updated | An edited or replaced video revision was linked. Includes its public video ID and server-issued edit_revision; the same video link and embeds remain valid. |
video.error | Video processing failed. |
video.deleted | A video was deleted. |
video.visibility_changed | Video visibility changed. |
video.captions.updated | A language transcript and its caption track were created, replaced, or deleted. |
video.captions.ready | AI generation completed successfully. The editable transcript is ready and supplies the caption track. |
video.chapters.ready | Chapter generation or a manual chapter save completed. Includes the public video ID and chapter version. |
video.overlays.updated | Interactive buttons, hotspots or Play and Replay placements were updated. |
upload.completed | An upload was accepted. |
member.invited | A workspace member was invited. |
member.joined | An invited member joined. |
storage.threshold_crossed | Workspace storage crossed a warning threshold. |
webhook.test | A user requested an endpoint test. |
{
"id": "15b49774-77f5-4fa7-bd2e-73f889a760fb",
"type": "video.ready",
"created_at": "2026-09-14T14:02:11.000Z",
"workspace": { "public_id": "ws_2Kd8" },
"data": { "video": { "public_id": "vid_2Kd8" } }
}Endpoint safety
Endpoint URLs must use HTTPS and resolve entirely to public DNS addresses both when created and before every send. Playflow rejects credentials in URLs, nonstandard ports, localhost, private and reserved addresses. At send time Playflow resolves again, pins the validated address for the connection, does not follow redirects, and applies one 10 second deadline across DNS, connection, response headers, and response body. Response bodies are capped at 1 MB. Only the first 1 KB is retained as the response excerpt.
Signing secret and rotation
The endpoint signing secret is returned once when an endpoint is created and is encrypted at rest. A rotated secret is also returned once, immediately replaces the old secret, and is not available from later list or detail responses. Store it as a UTF-8 string. Example secret literal, shown only here: whsec_demo_only_7Yp4Qm2N8v6R.
Verify every request
Playflow adds exactly three Playflow headers. Header names are case-insensitive in HTTP libraries.
| Header | Value |
|---|---|
Playflow-Event | The event type. |
Playflow-Delivery-Id | The UUID for this delivery. |
Playflow-Signature | t=<unix_seconds>,v1=<lowercase_sha256_hex> |
The signed bytes are UTF8(t + ".") + raw_request_body. Compute HMAC-SHA256 with the UTF-8 secret and compare in constant time. Read raw bytes before JSON parsing. Reject missing or malformed headers, invalid hex, wrong secrets, altered bodies, and timestamps more than 300 seconds old or more than 300 seconds in the future. Parse JSON only after verification succeeds.
Node.js verification
import { createHmac, timingSafeEqual } from "node:crypto";
const SIGNATURE_PATTERN = /^t=([0-9]+),v1=([0-9a-f]{64})$/;
const MAX_TIMESTAMP_SKEW_SECONDS = 300;
export function verifyPlayflowWebhook({
secret,
signature,
event,
deliveryId,
rawBody,
now = Date.now(),
}) {
if (typeof secret !== "string" || !Buffer.isBuffer(rawBody)) return false;
if (typeof event !== "string" || event.length === 0) return false;
if (typeof deliveryId !== "string" || deliveryId.length === 0) return false;
const match = SIGNATURE_PATTERN.exec(signature ?? "");
if (!match) return false;
const timestamp = Number(match[1]);
const nowSeconds = Math.floor(now / 1000);
if (!Number.isSafeInteger(timestamp)) return false;
if (Math.abs(nowSeconds - timestamp) > MAX_TIMESTAMP_SKEW_SECONDS) return false;
const expected = createHmac("sha256", Buffer.from(secret, "utf8"))
.update(Buffer.from(`${timestamp}.`, "utf8"))
.update(rawBody)
.digest();
const received = Buffer.from(match[2], "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
}
// In Express, use express.raw({ type: "application/json", limit: "1mb" }) on
// this route. Verify req.body before calling JSON.parse(req.body.toString("utf8")).Python verification
import hashlib
import hmac
import re
import time
SIGNATURE_PATTERN = re.compile(r"^t=([0-9]+),v1=([0-9a-f]{64})$")
MAX_TIMESTAMP_SKEW_SECONDS = 300
def verify_playflow_webhook(
*,
secret: str,
signature: str,
event: str,
delivery_id: str,
raw_body: bytes,
now: float | None = None,
) -> bool:
if not isinstance(secret, str) or not isinstance(raw_body, bytes):
return False
if not isinstance(event, str) or not event:
return False
if not isinstance(delivery_id, str) or not delivery_id:
return False
match = SIGNATURE_PATTERN.fullmatch(signature or "")
if match is None:
return False
timestamp = int(match.group(1))
now_seconds = int(time.time() if now is None else now)
if abs(now_seconds - timestamp) > MAX_TIMESTAMP_SKEW_SECONDS:
return False
signed_payload = str(timestamp).encode("utf-8") + b"." + raw_body
expected = hmac.new(
secret.encode("utf-8"), signed_payload, hashlib.sha256
).hexdigest()
return hmac.compare_digest(match.group(2), expected)
# In Flask, call request.get_data(cache=True) before request.get_json().
# Verify those raw bytes first, then parse JSON only after verification succeeds.Delivery, retries, and disabling
Attempt 1 is immediate. After a failed attempt completes, attempts 2 through 6 are scheduled from the previous completion time after 1 minute, 5 minutes, 30 minutes, 1 hour, and 2 hours. Each scheduled delay persists an additional random 0 through 10 percent. A delivery has no seventh attempt. Any 2xx response is success. A successful final delivery resets the endpoint's consecutive failure count.
After 20 consecutive final delivery failures, Playflow disables the endpoint. Exactly one email is queued for each active member with webhooks.manage. Re-enabling the endpoint resets the consecutive failure count. A manual disable uses the manual reason; operators can also disable an endpoint.
Delivery statuses are pending, delivering, delivered, and failed. Individual attempt statuses are delivering, delivered, and failed.
The endpoint delivery log shows the last 100 deliveries and supports status and event-type filters. Open a delivery to inspect its payload and attempts. Resend creates a new delivery in the same family with a fresh six-attempt budget. Test sends webhook.test and waits no more than 15 seconds; it can truthfully return pending when the worker is busy.
Management API paths
GET /api/settings/webhooksandPOST /api/settings/webhooksPATCH /api/settings/webhooks/{id},DELETE /api/settings/webhooks/{id}, andPOST /api/settings/webhooks/{id}/rotate-secretGET /api/settings/webhooks/{id}/deliverieswith optionalstatusandeventTypefiltersGET /api/settings/webhooks/{id}/deliveries/{deliveryId}POST /api/settings/webhooks/{id}/deliveries/{deliveryId}/resendPOST /api/settings/webhooks/{id}/test
Retention
Completed delivery families and their attempt records are retained for 30 days. Daily retention removes an expired family together so resend relationships are not left incomplete.
Internal provider callbacks
Playflow also receives signed Bunny encoding callbacks and Stripe billing events. Those are internal provider callbacks, not customer webhooks, and their signatures are verified separately.