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¶
- You register a subscription with a target HTTPS URL and a list of event types.
- PlusPlus generates a signing secret (
whsec_…) for the subscription and returns it. You can re-fetch or rotate it at any time. - When a matching event fires, PlusPlus delivers a signed JSON
POSTto your URL. - Your endpoint verifies the signature with the secret, processes the event, and returns any
2xxstatus. - 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 thesvix-idheader 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)
Delivery semantics¶
- At-least-once. Your endpoint may receive the same event more than once. Use
id(or thesvix-idheader) 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_atif order matters. - Timeout. Return a
2xxquickly (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.