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).
Once a content item is archived, it sends only lifecycle events until it is restored or deleted. Edits made while it is archived send no updated, and a linked video or recording that finishes processing sends no media-processing event. Restoring it sends unarchived, whose data.object is the full current resource, including every change made while it was archived; deleting it sends deleted.
content_item.updated and its type-specific .updated event are never sent for a deleted content item. Moving content items with the channel Move content action or the bulk Move to channel action sends them for each live item and nothing for an archived one. Deleting a channel moves its items to the default channel without sending updated for them.
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. |
Only live content sends these events. An archived or deleted item sends none. Restoring an archived item sends unarchived, whose data.object is the full resource but has no processing status. If a video or recording finished processing while its item was archived, its upload-status endpoint has the result only until the upload session expires, about an hour after the upload started. After that, the API does not expose the processing result.
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. |
Automated rule events¶
Fired when an automated rule run reaches a final state. The payload is the full run resource — the same shape as GET /automated-rules/{public_id}/runs/{run_id}/. Both events fire for every run, whether you started it with POST /automated-rules/{public_id}/run/ or it ran on the platform's periodic sweep.
| Event | When |
|---|---|
automated_rule.run_completed |
A run finished with status ok, warning, or partially_failed. |
automated_rule.run_failed |
A run failed because of a system error (status failed). |
Calendar events¶
Fired when the calendar owner of an event timeslot cannot sync to their calendar. The calendar owner is the user whose connected Google Calendar or Outlook account holds the timeslot's calendar entry. When their connection stops working (for example, their access was revoked), PlusPlus cannot update that calendar entry until they reconnect or the timeslot moves to a new owner.
| Event | When |
|---|---|
calendar.owner_sync_failed |
A calendar owner's connection stopped working. Sent once per failure window, when PlusPlus notifies the owner. |
calendar.owner_sync_restored |
A calendar owner whose sync was failing reconnected, and sync resumed. |
calendar.owner_transferred |
PlusPlus moved an event timeslot to a new calendar owner because the previous owner did not reconnect in time. Sent once per timeslot. |
Like the media-processing events, data.object is not a full resource. The two sync events carry a CalendarOwnerSyncOut object:
{
"user_id": "7d3c9a52-1f4e-4b8a-9c6d-2e5f8a1b3c4d",
"email": "jane.doe@company.com",
"name": "Jane Doe",
"provider": "google",
"failed_at": "2026-10-05T14:02:11.482913Z",
"restored_at": null,
"auto_transfer_after": "2026-10-06T14:05:00Z",
"affected_event_ids": ["c8a7f1e2-4a3b-4e5f-8c9d-0123456789ab"]
}
| Field | Description |
|---|---|
user_id |
public_id of the calendar owner. |
email |
Email address of the calendar owner. |
name |
Full name of the calendar owner. |
provider |
google or outlook — the calendar provider your tenant syncs events to. |
failed_at |
When sync first failed for this owner. Identifies the failure window. |
restored_at |
When sync recovered. null on calendar.owner_sync_failed. |
auto_transfer_after |
Earliest time PlusPlus moves the owner's upcoming timeslots to another organizer. Timeslots that start sooner can move earlier. null when automatic transfer is off for your tenant, and on calendar.owner_sync_restored. |
affected_event_ids |
public_ids of the upcoming events with at least one timeslot on this owner's calendar. |
calendar.owner_transferred carries a CalendarOwnerTransferOut object:
{
"event_id": "c8a7f1e2-4a3b-4e5f-8c9d-0123456789ab",
"timeslot_id": 42,
"starts_at": "2026-10-07T10:00:00-07:00",
"previous_owner_id": "7d3c9a52-1f4e-4b8a-9c6d-2e5f8a1b3c4d",
"previous_owner_email": "jane.doe@company.com",
"new_owner_id": "3b2a1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d",
"new_owner_email": "sam.lee@company.com",
"reason": "owner_sync_failed",
"conference_link_changed": true,
"url": "https://acme.plusplus.app/events/c8a7f1e2-4a3b-4e5f-8c9d-0123456789ab_q2-all-hands/"
}
| Field | Description |
|---|---|
event_id |
public_id of the event the timeslot belongs to. |
timeslot_id |
Numeric identifier of the timeslot that moved. |
starts_at |
When the timeslot begins, in the event's location timezone. |
previous_owner_id, previous_owner_email |
The calendar owner whose sync was failing. |
new_owner_id, new_owner_email |
The new calendar owner. |
reason |
Why the timeslot moved. Always owner_sync_failed for now. |
conference_link_changed |
true when the timeslot's online meeting link changed with the move (for example, a Google Meet link created on the previous owner's calendar). |
url |
Absolute URL to view the event on the platform. |
How these events relate:
- Failure window. A failure window starts when sync first fails for an owner and ends when sync is restored. The
calendar.owner_sync_failedandcalendar.owner_sync_restoredevents of one window carry the samefailed_at. - Idempotency. The event
idis built from the owner (or, forcalendar.owner_transferred, the event and timeslot) and the window'sfailed_at. Within one failure window, a repeated send of the same event reuses the sameid, so you can deduplicate on it. If the same owner fails again later, the new window has a newfailed_atand its events have newids. - One per timeslot.
calendar.owner_transferredis sent for each timeslot that moves. An event with three timeslots on the failing owner's calendar sends three events. Group them byevent_idif you need one notice per event. - No credentials. The payloads never include tokens or the calendar provider's error message.
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 and calendar 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, and calendar events carry CalendarOwnerSyncOut or CalendarOwnerTransferOut.
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.