Events & webhooks
The platform is event-driven: whenever something meaningful happens — a Resource is written, a Contract changes, a Product is published — the service responsible announces it as an event. Other services and your own integrations react to those announcements instead of polling an API on a timer. This page is the guide to consuming them: what the events are called, which ones you are allowed to build on, how they are delivered, and how they reach no-code tools such as Zapier and n8n as webhooks.
Events come in three tiers. Platform events (platform.*) are a
stable contract: any platform service may subscribe, and their
payloads only change in backwards-compatible ways within a major
version. Internal events (internal.*) are a private
implementation detail of the service that emits them — they chain
that service's own workflows, can change without notice, and are
never something you subscribe to. External events (packt.*)
are how the platform reaches tools outside it — Zapier, n8n, webhook
endpoints you host — through the Eventing service's curated
catalogue, delivered as CloudEvents (see
Webhooks and no-code tools). Inside
the platform, everything you can integrate with is on the platform
tier.
Each publishing service owns a platform bus named platform-<service>
(for example platform-content-lake). You attach a target — an AWS
Lambda, an SQS queue, another bus — to the events you care about,
filtered by name or pattern. Producers never know who is listening,
so a contract change quietly ripples out to rights across the estate
without anyone wiring services together by hand.
A quick worked example: an author's manuscript is ingested, and the
Content Lake publishes
platform.content-lake.resource.v1.created for each chapter. Your
integration is subscribed, so it registers the new content in a
downstream catalogue and pings an editor in Slack — all without the
Content Lake knowing your integration exists.
How events flow
Internally the platform speaks Amazon EventBridge. Events that reach
tools outside the platform — a webhook, Zapier, n8n — are a separate,
curated set: the Eventing service watches the platform buses and
publishes external events as
CloudEvents at the egress. An
external event has its own name (packt.*) and its own payload,
designed for workflow builders — it is not the internal event
re-wrapped. Everything inside stays EventBridge-native.
flowchart LR
subgraph inside [Inside the platform]
direction LR
SVC["A service<br/>(Content Lake, Contracts, Products)"]
SVC -->|publishes| BUS["Platform bus<br/>platform-<service>"]
BUS -->|filtered subscription| T["Your target<br/>Lambda · SQS · bus"]
end
BUS -->|watches| EV["Eventing service<br/>external catalogue"]
EV -->|CloudEvents webhook| EXT["Your no-code<br/>automation"]
Read left to right: a service publishes to its platform bus; internal consumers (including your own AWS targets) subscribe directly to the EventBridge event; and tools outside the platform receive external catalogue events, published as CloudEvents by the Eventing service.
The naming convention
Platform event names are dot-separated and versioned:
packt— the fixed prefix for platform events. Private events useinternal.instead, so the two are never confused.<service>— the publishing service, e.g.content-lake,products.<entity>— the domain entity the event is about, e.g.resource,work,product,pricing.v<major>— the major version of the payload schema. Payloads evolve backwards-compatibly within a major version; a breaking change ships under a new major.<verb>— what happened, in the past tense, e.g.created,updated,published,rights_changed.
Some examples you will meet below:
platform.content-lake.resource.v1.created
platform.content-lake.work.v1.rights_changed
platform.products.product.v1.published
platform.products.pricing.v1.updated
Contracts are a fold-in special case
Contract events drop the <entity> segment: they are named
platform.contract.v1.<action> — for example
platform.contract.v1.rights_changed — rather than
platform.contracts.contract.v1.…. This is the one deliberate
exception to the grammar above. Everything else follows the full
five-part form.
Match on the event name (the detail-type) rather than parsing it —
EventBridge pattern rules let you subscribe to, say, every
platform.products.product.v1.* transition with one filter.
Platform, internal, external
| Platform | Internal | External | |
|---|---|---|---|
| Name prefix | platform. |
internal. |
packt. |
| Bus | platform-<service> |
internal-<service> |
None — delivered to your endpoint |
| Who may subscribe | Any platform service | Only the owning service | Anyone with a subscription (webhook) |
| Stability | A contract; versioned changes only | Changes without notice | A contract, owned by the Eventing service |
| Purpose | Notify the platform something happened | Chain the service's own workflows | Feed workflows in tools outside the platform |
Inside the platform, build only on the platform column. The private buses exist so a service can trigger its own downstream processing — search indexing, entity enrichment, image optimisation — and they are not consumable contracts. The external column is a different world: its events never appear on a platform bus, and platform events never reach a webhook — the two meet only inside the Eventing service.
Ingestion publishes outcomes, never stages
Ingestion is a special case worth
calling out. It publishes no per-stage events: stage handoff
travels its internal queues, and its private
internal-content-lake-ingestion bus carries only a diagnostics
stream (observation.v1.recorded) — do not subscribe to it.
The signals that ingestion has happened are the run-outcome
events on platform-content-lake-ingestion
(resource.v1.ingested, resource.v1.failed,
job.v1.completed) and the Content Lake's own lifecycle events:
source.created, resource.created, plus work.rights_changed
once the content is placed under a Contract.
The event envelope
Every event carries the EventBridge envelope. The event name is the
detail-type; the publisher is identified by source (for example
packt/svc-content-lake); and the domain payload is the detail
object. Inside detail, three fields are always present, and enum
fields carry their full protobuf identifiers on the wire (for
example RESOURCE_TYPE_DOCUMENT, never DOCUMENT) — match those
exact strings in your filters:
| Field | Meaning |
|---|---|
tenant_id |
The Tenant the event belongs to. (Knowledge Graph events are the sole exception — see below.) |
event_id |
A unique ID for this event, used for deduplication. |
occurred_at |
When it happened, ISO 8601 GMT with a Z suffix. |
The rest of detail is specific to the event. A resource.created
looks like this:
{
"source": "packt/svc-content-lake",
"detail-type": "platform.content-lake.resource.v1.created",
"detail": {
"event_id": "e_resource_created",
"tenant_id": "packt",
"resource_id": "res_ml_go_ch3",
"work_id": "wk_ml_go",
"version": null,
"type": "RESOURCE_TYPE_DOCUMENT",
"title": "Chapter 3 — Goroutines and Channels",
"uri": "cl://packt/document/res_ml_go_ch3/latest",
"source_id": "src_ml_go_manuscript",
"generated_by": "act_ingest_ml_go",
"language": "en",
"tags": ["go", "concurrency", "chapter"],
"occurred_at": "2026-06-20T09:12:00Z",
},
}
Payloads are deliberately lean
Events tell you that something changed and give you the IDs to act on it — they are a trigger, not the transport of record. To read the full current state, call the Public API with the IDs from the payload. Contract events in particular are thin: they never carry term content, so a consumer fetches the terms it needs rather than trusting a payload snapshot. This keeps events small and means a slow consumer always catches up to the truth rather than replaying stale data.
At-least-once delivery and idempotent consumers
Delivery is at-least-once: the platform guarantees an event will reach your consumer, but it may occasionally arrive more than once — after a retry, say, or a redelivery. Your consumer must therefore be idempotent: processing the same event twice must have the same effect as processing it once.
The simple recipe is to deduplicate on event_id. Keep a record
of the event_id values you have already handled, and skip any you
have seen before:
def handle(event):
detail = event["detail"]
if already_processed(detail["event_id"]):
return # duplicate — safe to ignore
do_the_work(detail)
mark_processed(detail["event_id"])
Order is not guaranteed either
Two events for the same entity can arrive out of order. Where
ordering matters, use the version or timestamp fields in the
payload — for example, ignore a resource.updated whose version
is older than one you have already applied — rather than assuming
arrival order reflects the order things happened. Attach a
dead-letter queue to your subscription so a handler that keeps
failing parks the event for inspection instead of blocking the
stream.
The event catalogue
The tables below are the platform events you can subscribe to today, grouped by publishing service. Each is regenerated from the owning service's specification; that spec is always the source of truth for the full payload schema.
Content Lake — platform-content-lake
Published when Resources, Works, ContentUnits, Activities, or Sources change. Any service may subscribe.
| Event | detail-type |
Published when |
|---|---|---|
| Resource created | platform.content-lake.resource.v1.created |
A new Resource is written (by ingestion, derivation, or direct creation). |
| Resource updated | platform.content-lake.resource.v1.updated |
A Resource is snapshotted, re-tagged, has metadata changed, or is enriched (change_type: RESOURCE_CHANGE_TYPE_VERSION, RESOURCE_CHANGE_TYPE_METADATA, RESOURCE_CHANGE_TYPE_ENRICHMENT, RESOURCE_CHANGE_TYPE_ACCESS). |
| Resource deleted | platform.content-lake.resource.v1.deleted |
A Resource version is removed or a whole Resource is purged (reason: DELETE_REASON_COMPACTION, DELETE_REASON_EXPLICIT, DELETE_REASON_PURGED). |
| Work created | platform.content-lake.work.v1.created |
A new Work (IP entity) is registered. |
| Work rights changed | platform.content-lake.work.v1.rights_changed |
The effective rights on a Work change — contract assignment, state change, or amendment. This is the platform signal that rights moved. |
| ContentUnit created | platform.content-lake.content_unit.v1.created |
A new ContentUnit (chapter, section, segment) is registered. |
| Source created | platform.content-lake.source.v1.created |
A new Source entity is registered — the platform signal that ingestion of a new artifact has begun. |
| Source split | platform.content-lake.source.v1.split |
Ingestion split one Source into multiple Resources — the file → chapters mapping in a single event. |
| Embedding generated | platform.content-lake.embedding.v1.generated |
Embeddings were generated for a Resource in an embedding space — an announcement to reuse them (vectors are fetched by reference, never carried on the bus). |
| Entity discovered | platform.content-lake.entity.v1.discovered |
Entity extraction found a new entity instance (a previously unseen author, repository, technology) with no Knowledge Graph match yet. |
| Activity logged | platform.content-lake.activity.v1.logged |
Any Activity is recorded — the canonical stream for audit, AI-usage dashboards, and royalty triggers. |
The two rights events pair up: a change to a Contract triggers the
Content Lake to re-resolve the affected Work's rights and publish
work.rights_changed, which is your cue to recompute anything that
depends on what may be done with that Work's content.
work.rights_changed (EventBridge detail)
{
"source": "packt/svc-content-lake",
"detail-type": "platform.content-lake.work.v1.rights_changed",
"detail": {
"event_id": "e_work_rights_changed",
"tenant_id": "packt",
"work_id": "wk_ml_go",
"contract_id": "ctr_chandra_2026",
"contract_version": 3,
"change_types": ["WORK_RIGHTS_CHANGE_TYPE_TERRITORIAL"],
"occurred_at": "2026-04-01T12:00:00Z"
}
}
change_types[] lists every affected family in one event:
WORK_RIGHTS_CHANGE_TYPE_TERRITORIAL,
WORK_RIGHTS_CHANGE_TYPE_MANIFESTATION,
WORK_RIGHTS_CHANGE_TYPE_USAGE, WORK_RIGHTS_CHANGE_TYPE_ROYALTY,
WORK_RIGHTS_CHANGE_TYPE_STATE,
WORK_RIGHTS_CHANGE_TYPE_ASSIGNMENT. Recompute the resolved
rights for every Resource under the Work and re-evaluate
downstream availability.
Contracts — platform-contracts
Published by the Contracts service on every contract lifecycle event.
Their primary consumer is the Content Lake, which reprojects rights on
receipt — but any service may subscribe. Payloads are thin: IDs,
version, and change_types only, never term content. Fetch terms from
the Public API when you need them.
| Event | detail-type |
Published when |
|---|---|---|
| Created | platform.contract.v1.created |
A new contract is created (version 1). |
| Version created | platform.contract.v1.version_created |
A new version is created — an addendum, update, or automatic renewal. |
| State changed | platform.contract.v1.state_changed |
A status transition (e.g. CONTRACT_STATUS_DRAFT → CONTRACT_STATUS_ACTIVE, or the expiry sweep). |
| Rights changed | platform.contract.v1.rights_changed |
A term that affects resolved rights changes — territorial, manifestation, usage, handling, royalty, duration, or Work assignment. |
| Renewal approaching | platform.contract.v1.renewal_approaching |
An auto-renewing contract reaches its notice cutoff. Aimed at licensing-staff alerting and CRM integrations, not the Content Lake. |
Contract events carry the contract's direction
(CONTRACT_DIRECTION_INBOUND or CONTRACT_DIRECTION_OUTBOUND). The
Content Lake ignores CONTRACT_DIRECTION_OUTBOUND events entirely —
outbound contracts never affect resolved rights — and so should any
consumer computing what may be done with content.
contract.rights_changed (EventBridge detail)
{
"source": "packt/svc-contracts",
"detail-type": "platform.contract.v1.rights_changed",
"detail": {
"event_id": "e_contract_rights_changed",
"tenant_id": "packt",
"contract_id": "ctr_chandra_2026",
"version": 3,
"change_types": ["RIGHTS_CHANGE_TYPE_TERRITORIAL", "RIGHTS_CHANGE_TYPE_USAGE"],
"direction": "CONTRACT_DIRECTION_INBOUND",
"work_ids": ["wk_ml_go"],
"occurred_at": "2026-04-01T12:00:00Z"
}
}
change_types[] names every term family the amendment touched:
RIGHTS_CHANGE_TYPE_TERRITORIAL,
RIGHTS_CHANGE_TYPE_MANIFESTATION, RIGHTS_CHANGE_TYPE_USAGE,
RIGHTS_CHANGE_TYPE_HANDLING, RIGHTS_CHANGE_TYPE_ROYALTY,
RIGHTS_CHANGE_TYPE_DURATION, RIGHTS_CHANGE_TYPE_WORKS (Work
assignment changed). When it includes RIGHTS_CHANGE_TYPE_WORKS,
work_ids carries every affected Work — the union of the
assignment set before and after the change — so a Work removed
from the contract is reprojected too.
Usage — platform-usage
Consumption records, on their own high-volume bus. Every surface that
serves content — the Public API, MCP tools, assistants — emits one
platform.usage.content.v1.served event per served response,
enumerating every piece of content the response drew on: the
Resource and pinned version, its Work, whether it was shown or only
consulted, whether it was cited, and the contract basis it was
served under. This is the stream royalty attribution and AI-usage
reporting are built on. An MCP answer that drew on 37 passages is
one event with 37 items — the whole response, kept together.
The event contract is owned by the forthcoming Royalty service; treat the payload as append-only facts (what was served) rather than computed outcomes (what is owed) — the owing is derived downstream, so the division policy can be tuned without rewriting history.
Product Management — platform-products
Published on every Product lifecycle transition and on pricing, composition, and catalogue changes. The Distribution service is a major consumer, but any service may subscribe.
| Event | detail-type |
Published when |
|---|---|---|
| Product created | platform.products.product.v1.created |
A new Product is created. |
| Product updated | platform.products.product.v1.updated |
Product metadata changes. |
| Product deleted | platform.products.product.v1.deleted |
A Product is removed. |
| Drafted | platform.products.product.v1.drafted |
A Product enters DRAFT. |
| Submitted | platform.products.product.v1.submitted |
A Product enters REVIEW. |
| Approved | platform.products.product.v1.approved |
A Product enters APPROVED. |
| Published | platform.products.product.v1.published |
A Product enters PUBLISHED. |
| Retired | platform.products.product.v1.retired |
A Product enters RETIRED (terminal). |
| Withdrawn | platform.products.product.v1.withdrawn |
A Product enters WITHDRAWN (terminal). |
| Pricing updated | platform.products.pricing.v1.updated |
A price changes (base or a territory). |
| Content attached | platform.products.content.v1.attached |
A composition node referencing Content Lake content is added. |
| Content detached | platform.products.content.v1.detached |
A composition node is removed. |
| Content updated | platform.products.content.v1.updated |
Referenced Content Lake content changes, or a node is re-pinned. |
| Manifestation asset uploaded | platform.products.manifestation.v1.asset_uploaded |
An artifact is uploaded or replaced for a Manifestation's format. |
| Promotion created | platform.products.promotion.v1.created |
A new promotion is created. |
| Promotion started | platform.products.promotion.v1.started |
A promotion activates. |
| Promotion ended | platform.products.promotion.v1.ended |
A promotion expires. |
| Catalogue created | platform.products.catalogue.v1.created |
A new Catalogue is created. |
| Catalogue updated | platform.products.catalogue.v1.updated |
Catalogue rules or metadata change. |
| Catalogue archived | platform.products.catalogue.v1.archived |
A Catalogue is archived. |
| Catalogue product added | platform.products.catalogue.v1.product_added |
A Product enters a Catalogue (manual pick or rule match). |
| Catalogue product removed | platform.products.catalogue.v1.product_removed |
A Product leaves a Catalogue. |
Note the distinction between product.v1.updated (product metadata
changed) and content.v1.updated (the underlying Content Lake content
a latest-tracking node points at changed). Subscribe to both if you
need to react to any change to a Product.
Knowledge Graph — platform-content-lake-knowledge
The Knowledge Graph publishes entity lifecycle and metadata-refresh events (for example when a GitHub repository's stats or a technology's release information changes).
Knowledge Graph events carry no tenant_id
The Knowledge Graph is a global registry shared across
tenants, so its events are the one exception to the rule that every
payload carries tenant_id. A consumer that references an entity
fans the event out itself, one action per tenant that references
it. Do not expect a tenant_id field on these events.
Webhooks and no-code tools
Everything above assumes an AWS target inside the platform. To receive events outside the platform — in a webhook endpoint you host, or in a no-code automation tool — the Eventing service is the plug-in point. It watches the platform buses and publishes a curated catalogue of external events, designed for workflow builders.
The egress is upcoming
The Eventing service's external egress is specified but not yet built. What follows is the contract it will ship against; catalogue entries may still be refined until it does.
External events are not platform events re-wrapped:
- Names follow
packt.<entity>.v<major>.<verb>— for examplepackt.order.v1.placed,packt.product.v1.published,packt.contract.v1.renewal_approaching. They name business entities, not internal services, and never reuse a platform event name — internal services can be split or renamed without your workflow noticing. - Payloads are hand-authored for workflows: IDs and the facts you filter or branch on — never an internal payload passed through, and never personal data. A workflow that needs more calls back through the Public API with its own credentials.
- Versions are independent of the platform events behind an
entry: an internal
v1→v2migration never breaks your Zap.
Deliveries use the CloudEvents v1.0 envelope — the open, widely-supported format that tools like Zapier and n8n understand out of the box:
An external event as delivered to your webhook
{
"specversion": "1.0",
"id": "0197f3a2-4b1c-7d3e-9a4f-2c8d1e5b6a70",
"type": "packt.order.v1.placed",
"source": "https://events.packtpub.services",
"subject": "ord_9f4e2a71",
"time": "2026-07-17T09:41:00Z",
"datacontenttype": "application/json",
"data": {
"tenant_id": "packt",
"order_id": "ord_9f4e2a71",
"customer_id": "u_5c21d0aa",
"vertical": "gamedev",
"product_ids": ["prod_0197c1f4"],
"channel": "shopify",
"currency": "GBP",
"total": "39.99"
}
}
The prefixed IDs under data feed straight back into the
Public API. The subject names the primary entity, so
simple tools can route on it without parsing the payload.
The at-least-once and idempotency rules apply to webhook delivery
too: deduplicate on the id — it is stable across every redelivery
of the same occurrence — and return a 2xx quickly so the Eventing
service does not retry a delivery your handler already accepted.
See No-code & AI tools for connecting Zapier, n8n, and LLM agents end to end.
Verifying deliveries
Inside the platform, publishing is locked down with IAM: only the
owning service can put events onto its bus, and only under its own
source value. The source field on every event you receive is
therefore trustworthy as-is — you never need to second-guess which
service produced an event.
For webhook deliveries to your own endpoints:
- Deliveries authenticate to your endpoint with the credentials you configure on the destination — an API key header or an OAuth client. Treat possession of those credentials as proof the delivery came from the platform, and rotate them like any other secret.
- Payload signing arrives with the Eventing service: each delivery will carry a timestamped HMAC signature header computed with a per-destination secret, so you can verify origin and integrity even if your endpoint URL leaks. The scheme will be documented here when it ships.
- EventBridge itself does not sign deliveries — do not build against
an
x-eventbridge-signatureheader. Verify by credentials today, and by the platform signature once it is available.
Related pages
- The Public API — read the full state an event points at, using the IDs in the payload.
- Authentication — the WorkOS tokens your consumers and API calls carry.
- No-code & AI tools — wire events into Zapier, n8n, and AI agents as webhooks.
- Contracts & Rights — where contract events originate and how rights resolve.
- Content Lake — the estate whose lifecycle events are the platform signal of ingestion.
- Explorer — Chapter I — the service map and how events tie the platform together.