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).

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_failed and calendar.owner_sync_restored events of one window carry the same failed_at.
  • Idempotency. The event id is built from the owner (or, for calendar.owner_transferred, the event and timeslot) and the window's failed_at. Within one failure window, a repeated send of the same event reuses the same id, so you can deduplicate on it. If the same owner fails again later, the new window has a new failed_at and its events have new ids.
  • One per timeslot. calendar.owner_transferred is sent for each timeslot that moves. An event with three timeslots on the failing owner's calendar sends three events. Group them by event_id if 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 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.