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.

EventExample purpose
video.readyA video is ready to play.
video.updatedAn 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.errorVideo processing failed.
video.deletedA video was deleted.
video.visibility_changedVideo visibility changed.
video.captions.updatedA language transcript and its caption track were created, replaced, or deleted.
video.captions.readyAI generation completed successfully. The editable transcript is ready and supplies the caption track.
video.chapters.readyChapter generation or a manual chapter save completed. Includes the public video ID and chapter version.
video.overlays.updatedInteractive buttons, hotspots or Play and Replay placements were updated.
upload.completedAn upload was accepted.
member.invitedA workspace member was invited.
member.joinedAn invited member joined.
storage.threshold_crossedWorkspace storage crossed a warning threshold.
webhook.testA user requested an endpoint test.
Example event body
{
  "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.

HeaderValue
Playflow-EventThe event type.
Playflow-Delivery-IdThe UUID for this delivery.
Playflow-Signaturet=<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

verify-webhook.mjs
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

verify_webhook.py
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/webhooks and POST /api/settings/webhooks
  • PATCH /api/settings/webhooks/{id}, DELETE /api/settings/webhooks/{id}, and POST /api/settings/webhooks/{id}/rotate-secret
  • GET /api/settings/webhooks/{id}/deliveries with optional status and eventType filters
  • GET /api/settings/webhooks/{id}/deliveries/{deliveryId}
  • POST /api/settings/webhooks/{id}/deliveries/{deliveryId}/resend
  • POST /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.