Skip to content

Webhooks

Webhooks let your systems react in real time when resources change in PlusPlus — no polling required.

Availability

Webhooks are enabled per tenant during rollout. If they are not enabled for your tenant, the endpoints below return 503 Service Unavailable. Contact your PlusPlus representative to have webhooks turned on.

How webhooks work

  1. You register a subscription with a target HTTPS URL and a list of event types.
  2. PlusPlus generates a signing secret (whsec_…) for the subscription and returns it. You can re-fetch or rotate it at any time.
  3. When a matching event fires, PlusPlus delivers a signed JSON POST to your URL.
  4. Your endpoint verifies the signature with the secret, processes the event, and returns any 2xx status.
  5. Failed deliveries are retried automatically with exponential backoff.

Delivery, signing, and retries are handled by Svix, so signature verification and delivery semantics follow the Standard Webhooks conventions Svix implements.

Event taxonomy

Naming follows {resource}.{action} (Stripe/GitHub-style). Discover the live catalog at runtime with GET /webhooks/event-catalog/ or see the detailed schemas in the webhooks section of the API docs.

Generic content events

Fired for every content type. data.object uses the shared content-item summary schema (the same shape as the generic GET /content-items/{public_id} response).

Event When
content_item.created Any content item is created.
content_item.updated Any content item's shared metadata changes.
content_item.deleted Any content item is deleted.
content_item.archived Any content item is archived.
content_item.unarchived Any content item is restored from archive.

Type-specific events

Fired alongside the generic event, with a richer, type-specific payload.

Type Event prefix Actions
Article article.* created, updated, deleted, archived, unarchived
Video video.* created, updated, deleted, archived, unarchived
Course course.* created, updated, deleted, archived, unarchived
Guide guide.* created, updated, deleted, archived, unarchived
Event event.* created, updated, deleted, archived, unarchived
Event Type event_type.* created, updated, deleted, archived, unarchived
Link link.* created, updated, deleted, archived, unarchived
Track track.* created, updated, deleted, archived, unarchived
Scheduled Track scheduled_track.* created, updated, deleted, archived, unarchived
Collection collection.* created, updated, deleted, archived, unarchived

When an article is created, both content_item.created and article.created fire. Subscribe to whichever fits your needs (or both — events carry a stable id you can deduplicate on).

Assignment events

The payload is the full assignment resource — the same shape as GET /assignments/{public_id}.

Event When
assignment.created A user is assigned content.
assignment.updated Assignment metadata changes.
assignment.completed A user marks an assignment complete.
assignment.dropped A user drops an assignment.
assignment.exempted A user is exempted from an assignment.
assignment.completion_undone A previously completed assignment is reopened.
assignment.exemption_undone A previously exempted assignment is reinstated.
assignment.deleted An assignment is removed.

Enrollment events

Event-attendance lifecycle. The payload is the full enrollment resource — the same shape as GET /enrollments/{public_id} — and includes the enrolled user and event references.

Event When
enrollment.enrolled A user is enrolled in an event (newly, or promoted from the wait list).
enrollment.waitlisted A user is added to an event's wait list.
enrollment.checked_in A user is checked in to an event.
enrollment.checkin_undone A user's check-in is reverted.
enrollment.dropped A user drops their event enrollment.

Media-processing events

Fired when a Mux-hosted video or recording finishes processing — so you can stop polling the upload-status endpoints and react the moment an asset is playable (or has failed). The upload itself is asynchronous; these events are how you learn the outcome.

Event When
video.ready A video's Mux asset finished processing and is ready to play.
video.errored A video's Mux asset failed during processing.
event.recording_ready An event recording finished processing and is ready to play.
event.recording_errored An event recording failed during processing.
event_type.recording_ready An event-series recording finished processing and is ready to play.
event_type.recording_errored An event-series recording failed during processing.

Unlike every other family, data.object is not the full resource — it is the lightweight MediaProcessingOut object below. Fetch the resource with content_id if you need its full representation, or send a user straight to url:

{
  "content_id": "c8a7f1e2-4a3b-4e5f-8c9d-0123456789ab",
  "content_type": "video",
  "mux_asset_id": "abc123xyz789",
  "status": "ready",
  "duration": 312.5,
  "url": "https://acme.plusplus.app/a/videos/c8a7f1e2-4a3b-4e5f-8c9d-0123456789ab_q2-all-hands"
}
Field Description
content_id public_id of the video, event, or event series the asset belongs to (per content_type).
content_type video, event, or event_type — matches the event-name prefix.
mux_asset_id The Mux asset that finished processing.
status ready or errored (redundant with the event type, for convenience).
duration Asset duration in seconds, once known. null for errored assets.
url Absolute URL to view the resource on the platform.

Subscribing to events

Each entry in a subscription's events list is either an explicit event type (video.created) or a family wildcard (video.*), which expands to every event in that family. At least one entry is required — there is no catch-all *, and an empty list is rejected. Wildcards are expanded to their concrete member names when the subscription is stored, so a subscription's events always reads back as explicit event names.

Payload format

{
  "id": "evt_article_created_d290f1ee-6c54-4b01-90e6-d701748f0851",
  "type": "article.created",
  "api_version": "2.0.0",
  "created_at": "2026-04-06T12:00:00Z",
  "data": {
    "object": { /* same shape as GET response for that resource */ }
  }
}

For every family except media-processing events, data.object is identical to the GET response for that resource — no special parsing required. Media-processing events instead carry the lightweight MediaProcessingOut object.

  • id — Stable, deterministic event identifier. Also delivered in the svix-id header and unchanged across retries of the same event. Use it to deduplicate.
  • type — The event type (e.g. article.created).
  • api_version — The payload schema version.
  • data.object — The resource payload (see above).

Signature verification

Every delivery is signed by Svix. Verify it before trusting the payload. The request includes the standard Svix headers:

svix-id: msg_2aBcDeFgHiJkLmNoPqRsTuVwXyZ
svix-timestamp: 1714473600
svix-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=

You can verify with the Svix libraries using your subscription's whsec_… secret — they check the signature and reject stale timestamps (replay protection) for you. Always verify against the raw request body, before any JSON parsing or re-serialization.

Svix provides installable libraries for a wide variety of languages. If you can't use one of these see the detailed instructions for manual verification in the Svix docs to write your own or use some code from the many reference implementations provided by the Standard Webhooks project.

from svix.webhooks import Webhook, WebhookVerificationError

def verify(payload_body: bytes, headers: dict, secret: str) -> dict:
    wh = Webhook(secret)  # the subscription's whsec_... secret
    # Raises WebhookVerificationError on a bad signature or stale timestamp.
    return wh.verify(payload_body, headers)
const { Webhook } = require('svix');

function verify(payloadBody, headers, secret) {
  const wh = new Webhook(secret); // the subscription's whsec_... secret
  // Throws on a bad signature or stale timestamp.
  return wh.verify(payloadBody, headers);
}

Delivery semantics

  • At-least-once. Your endpoint may receive the same event more than once. Use id (or the svix-id header) to deduplicate.
  • Retries. Failed deliveries are retried automatically with exponential backoff over several hours; persistently failing endpoints are eventually disabled.
  • Order. Deliveries are not strictly ordered. Use created_at if order matters.
  • Timeout. Return a 2xx quickly (within a few seconds) and do heavy processing asynchronously.
  • Inspecting deliveries. GET /webhooks/{webhook_id}/deliveries/ lists recent delivery attempts (one row per attempt) with their HTTP response status, so you can audit and debug failures.

Security

  • Target URLs must be https://.
  • Private/internal and loopback addresses (10.x, 172.16–31.x, 192.168.x, 127.x, localhost) are rejected at registration to prevent SSRF.
  • The signing secret can be re-fetched from the subscription at any time and rotated via POST /webhooks/{webhook_id}/rotate-secret/. Rotating invalidates the previous secret immediately.

Subscription API

All routes are under /public_api/v2/webhooks/. {webhook_id} is the subscription id returned at creation (a Svix endpoint id, e.g. ep_2aBcDeFgHiJkLmNoPqRsTuVwXyZ).

Method Path Description
GET /webhooks/event-catalog/ List all subscribable event types, grouped by family.
GET /webhooks/ List subscriptions (without secrets).
POST /webhooks/ Create a subscription (returns the signing secret).
GET /webhooks/{webhook_id}/ Get a subscription, including its signing secret.
PATCH /webhooks/{webhook_id}/ Update url, events, description, or is_active.
DELETE /webhooks/{webhook_id}/ Delete a subscription.
POST /webhooks/{webhook_id}/rotate-secret/ Generate a new signing secret.
POST /webhooks/{webhook_id}/test/ Send a ping event to verify your endpoint.
GET /webhooks/{webhook_id}/deliveries/ List recent delivery attempts (cursor-paginated).

See the API reference for full request/response schemas.