Events
Built-in Events
Reference for every domain event VitNode core, the Content Engine and the blog plugin emit, with its trigger, its payload shape and a listener use case.
Every event VitNode and its first-party plugins emit today, grouped by domain.
Listen to any of them from your own plugin with
buildEventListener - no import from the emitting plugin is
needed, because the event map is global.
Quick start
Pick a name from the tables below, write a listener for it, and register the
listener on a top-level module's events array:
import { buildEventListener } from '@vitnode/core/api/lib/events'
export const welcomeListener = buildEventListener({
event: 'user.created',
name: 'send-welcome-email',
handler: async (c, payload) => {
await c.get('queue').dispatch({
name: 'send-welcome-email',
payload: { userId: payload.userId, email: payload.email },
})
},
})Nothing else has to change: the payload type comes from the name, and the delivery guarantees are the same for a core event as for one of your own.
Core events (@vitnode/core)
Seventeen names, all declared in VitNodeEvents in
packages/vitnode/src/api/models/events.ts. Every one of them fires after
the write it describes has committed.
| Event | Payload | Fires when |
|---|---|---|
user.created | { userId, email, name, emailVerified } | A user row is inserted - public sign-up, AdminCP creation, or SSO sign-up |
user.updated | { userId, email, name } | A user is edited in the AdminCP, or a member saves their own profile or time zone in Settings → Overview |
user.deleted | { userId, email } | Never - the name is declared for plugins, core has no deletion flow |
user.email.verified | { userId, email } | An address is confirmed - by its link, a completed reset, or by staff |
user.password.updated | { userId } | A password is set - by a member completing a reset, or by staff on the AdminCP user page |
user.sessions.revoked | { userId, deviceId, sessions } | A user is signed out remotely - one device or all of them, by the member or by staff, or after a password change |
user.sso.linked | { userId, email, providerId } | An SSO identity is linked to an existing account - at login with its password, or from Settings → Connected accounts |
user.sso.unlinked | { userId, providerId } | A provider is disconnected - by the member in Settings → Connected accounts, or by staff in the AdminCP |
user.sso.preferences_updated | { userId, sources, sync } | Profile-field sources or sync-on-sign-in settings are saved - by the member or by staff |
user.sso.profile_synced | { userId, providerId, fields, trigger } | Profile fields were copied from a provider - by Sync now, Import or an opted-in sign-in |
user.passkey.created | { userId, passkeyId } | A member adds a passkey in Settings → Security |
user.passkey.updated | { userId, passkeyId, name } | A passkey is renamed - by its owner or by staff |
user.passkey.deleted | { userId, passkeyId } | A passkey is removed - by its owner or by staff |
user.avatar.updated | { userId, fileId } | An avatar is uploaded or removed - by the user on their profile, or by staff in the AdminCP |
user.cover.updated | { userId, fileId } | A profile cover is uploaded or removed - by the user on their profile, or by staff in the AdminCP |
role.created | { roleId } | A role is created in the AdminCP |
role.updated | { roleId } | A role is edited in the AdminCP |
role.deleted | { roleId } | A role is deleted in the AdminCP |
Emitted from UserModel.signUp, which is the one insert path into
core_users - so it covers the public sign-up form, user creation in the
AdminCP, and the first sign-in through an SSO provider.
Prop
Type
A listener would send a welcome or verification email (via a queue task so it retries), subscribe the address to a newsletter audience, or provision plugin-owned data such as a profile row.
export const welcomeListener = buildEventListener({
event: 'user.created',
name: 'send-welcome-email',
handler: async (c, payload) => {
await c.get('queue').dispatch({
name: 'send-welcome-email',
payload: { userId: payload.userId, email: payload.email },
})
},
})Emitted by the AdminCP user PATCH after it commits - profile fields (email,
name, name code) and/or role assignments - and when a member saves their own
profile fields or time zone. The payload carries the user's current values.
Prop
Type
A listener would sync the identity into an external system (CRM, mailing
list), invalidate a plugin-owned cache keyed by user, or audit-log the staff
edit using the envelope's actor.
Emitted after an avatar is stored and attached to the user, or after it is
removed. The same event covers the profile page and the AdminCP - the envelope's
actor tells the two apart.
Prop
Type
A listener would push the new picture to a chat integration, regenerate a
cached feed card, or audit-log a staff change with the envelope's actor.
The cover image's twin of user.avatar.updated, with the same payload and the
same two triggers.
Prop
Type
Emitted after the core_users_sso row is committed, from two places:
SSOModel.link- a social identity that arrived with an already-registered email is tied to that account once its password checks out;SsoConnectionModel.callback- a signed-in member connects a provider from Connected accounts. The provider's email may differ from the account's;emailis always the VitNode account's.
It does not fire on the first sign-in through a provider (that is a
user.created) or on a later sign-in through an identity that is already
linked.
Prop
Type
A listener would notify the account owner that a new sign-in method was
added, or audit-log the link with the envelope's actor.
Emitted by SsoConnectionModel.disconnect after the connection and its profile
sync settings are deleted. Nothing is revoked at the provider - the member still
has to remove the app there if they want to.
Prop
Type
A listener would email the member that a sign-in method was removed.
Emitted when at least one profile field actually changed because of a provider.
Fields that were missing, not allowed or already equal are left out, and an
avatar that failed to download never counts. A name change also emits
user.updated, and an avatar change emits user.avatar.updated.
Prop
Type
A listener would refresh a search index or a cached member card.
Emitted once an unconfirmed address becomes confirmed: when a member clicks the link from their sign-up email, when they complete a password reset (the reset link went to the same mailbox), or when staff press Verify Email on the AdminCP user page. A reset on an account that was already confirmed emits nothing here. See Email Verification.
Prop
Type
A listener would send a welcome email or unlock a "verified member" badge.
Emitted after a new password hash is stored - when a member completes a
password reset, or when staff set one from the AdminCP user page. The staff
route answers 403 while password sign-in is turned off, so this never fires
for a password nobody could use. Both paths then sign the account out
everywhere, so user.sessions.revoked follows right after.
Prop
Type
A listener would email the account owner that their password changed, and
use the envelope's actor to say whether they or an administrator did it.
Emitted by revokeSessions once a user's sessions are deleted and their cached
copies dropped. It covers both session kinds - the site session and the AdminCP
session - and fires from every place that signs somebody out remotely: a member
ending one of their own devices, staff ending one device or all of them, and a
password change.
Prop
Type
A listener would write a security audit entry, or warn the member when staff signed them out.
Emitted by SsoConnectionModel.savePreferences after the profile-field sources
and the per-provider sync switches are saved - from Settings → Connected
accounts or from the AdminCP user page. It fires on every successful save,
even when nothing changed.
Prop
Type
A listener would audit-log who changed where an account's profile comes from.
The name and payload live on the VitNodeEvents map so listeners type-check,
but core never emits it: there is no user-deletion flow yet. It is the
agreed-upon name for plugins that implement account deletion themselves, and
core will emit it once deletion lands.
Prop
Type
Emitted after a role is created or edited in the AdminCP, including its
translated names - those live in core_languages_words, so the payload has no
name field to carry.
Prop
Type
A listener would provision plugin-side permission defaults for a new role, or refresh an externally-cached permission matrix when one changes.
Emitted after the delete transaction commits. Its translated names are removed
with it, and its secondary-role assignments and staff-permission entries are
dropped by database cascade. Members point at their primary role through a
NOT NULL restricted foreign key, so anyone in the role is reassigned
before the delete - by the time this fires, no user references it.
Prop
Type
A listener would clean up plugin-owned rows keyed by role id. The role row
is already gone, so key the cleanup off payload.roleId rather than re-reading
core_roles:
export const roleCleanupListener = buildEventListener({
event: 'role.deleted',
name: 'cleanup-role-settings',
handler: async (c, payload) => {
await c
.get('db')
.delete(shop_role_settings)
.where(eq(shop_role_settings.roleId, payload.roleId))
},
})Navigation events
Four names, declared in VitNodeEvents in
packages/vitnode/src/api/models/events.ts. Every one fires after the
header menu or the bottom bar changed in the AdminCP and after the cached menus were expired, so a
listener that re-reads the menu sees the new state.
| Event | Payload | Fires when |
|---|---|---|
navigation.created | { navigationId } | A prebuilt page or a custom link is added to the header menu or the bottom bar |
navigation.updated | { navigationId } | A menu item's title, description, URL, new-tab setting or placement is edited |
navigation.reordered | { items, location } | One menu is saved in a new order or nesting - items is that menu's whole tree |
navigation.deleted | { navigationId } | A menu item is removed, together with its translations |
The item's translated title and description live in core_languages_words, so
the payload carries only the id. On deleted the row is already gone; key any
cleanup off payload.navigationId.
Prop
Type
A listener would purge a CDN's cached HTML, or log who changed the menu and when.
Fires after a drag-and-drop in the AdminCP saved a menu. The payload is that entire menu as the administrator now sees it, one level deep - the header menu or the bottom bar, never both.
Prop
Type
Notification events
Eight names, declared in VitNodeEvents in
packages/vitnode/src/api/models/events.ts. Seven of them fire after an
administrator's change in AdminCP → Notifications
has been saved, and the envelope's actor is that administrator.
notifications.preferences_updated is about one member: it fires when their
own preferences are saved, by them or by staff on their AdminCP user page.
| Event | Payload | Fires when |
|---|---|---|
notifications.type.updated | { typeId } | What a type does by default (list, push, email, member can edit) changes |
notifications.preferences_updated | { userId, typeIds } | One member's preferences are saved - typeIds are the types in the save |
notifications.preferences_reset | { members } | Reset all members to defaults ran - members is how many were reset |
notifications.paused | {} | Notifications were paused from the danger zone |
notifications.resumed | { requeuedEvents } | Notifications were resumed - requeuedEvents waited while paused |
notifications.emails_cancelled | { count } | Queued emails were cancelled - count is how many deliveries were skipped |
notifications.read_all | { items, members } | Everything was marked as read for everyone |
notifications.deleted_all | { items } | Every member's notifications were deleted |
A listener would post an audit entry to your staff channel when someone pauses, deletes or resets everything, or note when staff changed how a single member is notified.
Moderation events
Seven names, declared in VitNodeEvents in
packages/vitnode/src/api/models/events.ts and emitted by core with
pluginId: "@vitnode/core". Each fires after its database transaction
commits. The envelope's actor is the reporter for created and the
moderator for the rest. Adding an internal note emits nothing. See
Moderation.
| Event | Payload | Fires when |
|---|---|---|
moderation.report.created | { reportId, pluginId, targetType, targetId, reason } | A signed-in user files a report |
moderation.report.assigned | { reportId, assigneeId, previousAssigneeId } | A moderator takes, reassigns or unassigns a report. assigneeId may be null |
moderation.report.action | { reportId, action, pluginId, targetType, targetId } | A content action from the ModCP changed the content. action is hide or unhide |
moderation.report.resolved | { reportId, resolution } | A report is closed with an outcome |
moderation.report.reopened | { reportId } | A closed report is reopened |
moderation.report.note_updated | { reportId, noteId } | A moderator edits an internal note |
moderation.report.note_deleted | { reportId, noteId } | A moderator deletes an internal note |
A hide or restore from the ModCP also emits the content type's own
content.{id}.hidden or unhidden event, because it goes through the same
visibility service as the AdminCP.
A listener would notify a staff channel when a report arrives, or keep a per-moderator count of resolved reports.
AI events
Declared in VitNodeEvents in packages/vitnode/src/api/models/events.ts.
The two ai.run.* events are emitted by the shared AI runner (see
AI Actions). They fire after the run was settled,
so its cost and points are already in the history when a listener reads it. A
failing listener never breaks the run - or the ALT write.
| Event | Payload | Fires when |
|---|---|---|
ai.run.completed | { actionKey, actorType, runId, userId } | A managed AI run delivered a valid result |
ai.run.failed | { actionKey, actorType, errorCode, runId, userId } | A run ended without a valid result: provider failure, cancel, bad output |
files.alt.updated | { fileId, languageCodes, origin } | An image's default ALT text was written - by automatic ALT text or by a person |
userId is null for system work such as automatic ALT text. Prompts and
outputs are never part of the payload.
files.alt.updated fires after the core_files_alt rows were written.
languageCodes lists only the languages that actually changed, and origin is
"ai" for the background job or "human" for PUT /admin/files/{id}/alt.
Removing a language's ALT text emits nothing.
A listener would post a notice when an editor's AI suggestion fails, count AI usage per plugin in your own analytics, or purge cached pages that render an image whose ALT text just changed.
Page layout events
One name, declared in VitNodeEvents in
packages/vitnode/src/api/models/events.ts. It fires after the layout row
has been written, and not at all when a save changed nothing.
| Event | Payload | Fires when |
|---|---|---|
core.page-layout.updated | { pageId, changedZones } | Somebody saves widget changes on an editable page |
Emitted by PUT /pages/layout once the one core_page_layouts row for that
page has been written. A page's layout is stored as overrides only, so
changedZones names the zones whose override actually moved - not every zone
the editor happened to send, and not the zones still sitting on the defaults
their defineEditablePage declaration ships.
A save that changes nothing writes nothing and emits nothing, so a listener never fires for a no-op. Saving a zone back to its shipped default removes that override, which counts as a change and is reported here.
Prop
Type
A listener would purge whatever the front end caches for that page. The layout is read by page id, so the id in the payload is the whole cache key:
export const pageLayoutCacheListener = buildEventListener({
event: 'core.page-layout.updated',
name: 'expire-page-cache',
handler: async (c, payload) => {
await c.get('cache')?.delete(`page:${payload.pageId}`)
},
})Content Engine events
Every content type declared with the
Content Engine gets its own event names, built from
its id: content.<id>.<action>. For the example plugin's example.article
that means content.example.article.created, and for the blog's blog.post it
means content.blog.post.created.
Every payload's contentId (and sourceId and duplicatedFromId) has the
content type's id type: a number for the
default serial strategy, a string for uuid and bigint. A bigint id
arrives as its exact decimal string, so never pass it through Number().
Which actions exist depends on what the definition opts into. A content type that declares nothing beyond its fields emits three events; one that declares everything emits nineteen.
| The definition declares | Events it adds |
|---|---|
| nothing extra (always) | created, updated, deleted |
publication | published, unpublished |
editorial | restored |
editorial.scheduling | scheduled, schedule_cancelled |
localization | translation_created, translation_updated, translation_deleted |
localization and publication | translation_published, translation_unpublished |
localization and editorial | translation_restored |
delivery | delivery_slug_changed, delivery_redirect_created |
duplication | duplicated |
visibility | hidden, unhidden |
The gating is in the types, not just at runtime: ContentEventsFor expands to
literal keys only for the features the definition enables. The example plugin's
example.article declares editorial, so
content.example.article.restored type-checks; example.category is one text
field and nothing else, so content.example.category.restored is not a key on
the map and a listener for it does not compile.
Record events
| Event | Payload | Fires when |
|---|---|---|
created | { contentId } | A record is inserted |
updated | { contentId, changedFields } | At least one declared field actually changed |
deleted | { contentId } | A record is deleted |
published | { contentId, publishedAt, scheduledBy?, scheduleId? } | A record becomes publicly visible - interactively or when a schedule fires |
unpublished | { contentId, scheduledBy?, scheduleId? } | A record is withdrawn |
restored | { contentId, changedFields, version, revisionId, restoredFromRevisionId } | A record is rolled back to an earlier revision - emitted instead of updated |
scheduled | { contentId, action, actorUserId, scheduledFor, scheduleId } | A publish or unpublish is booked for later |
schedule_cancelled | { contentId, action, actorUserId, scheduleId } | A pending booking is called off |
duplicated | { actorUserId, contentId, contentTypeId, sourceId } | A record was copied into a new draft, right after the copy's own created |
hidden | { actorUserId, contentId, hiddenAt } | A visible record is taken off the public site without being unpublished |
unhidden | { actorUserId, contentId } | A hidden record is made visible again |
Prop
Type
A listener would mirror the row into a plugin-owned projection, reindex it
for an external search engine, or purge a CDN entry. changedFields is what
lets it skip the work when the field it cares about did not move.
publishedAt is when the record went live for the first time and is never
rewritten by a later unpublish and republish. scheduledBy and scheduleId
are present only when a booking caused the transition; an interactive publish
carries neither.
Prop
Type
A listener would announce the record (a newsletter, a social post, a
realtime toast), add or remove it from an external index, or expire a cache
that only holds public rows. Because the same publish can arrive twice, key
anything that must happen once off scheduleId - see
A scheduled event may arrive twice.
A rollback to an earlier revision's field values. It carries changedFields
exactly like updated, so porting an updated listener is a rename rather
than a rewrite - which is why a restore emits one event and not both.
Prop
Type
A listener would do exactly what its updated listener does. A restore
never moves status, publishedAt or hiddenAt, so nothing watching
visibility has to handle it.
Booking a transition changes no field value, so it consumes no version and
writes no revision - scheduled is a different fact from published. When the
booking fires, the transition emits the ordinary published or unpublished
with scheduleId and scheduledBy set.
Prop
Type
A listener would show an editorial calendar, warn an author that two
bookings collide, or pre-warm a render shortly before scheduledFor.
A duplicate first emits the copy's
ordinary created (with duplicatedFromId) and one translation_created per
copied language, then duplicated. Indexing already ran for created, so
duplicated is a notification only.
Prop
Type
A listener would credit the original author in an audit log, or link the copy to its source in a plugin-owned table.
Hiding takes a record off the public
site without touching status or publishedAt, so a hidden record emits
neither unpublished nor, when unhidden, published. Hiding a record that is
already hidden emits nothing.
Prop
Type
A listener would purge a CDN entry, pull the record from an external search engine, or tell the author that a moderator took their article down. Hiding a draft emits the event too, though nothing public changed.
Translation events
A content type with localization
emits one event per translation mutation, in addition to the record events
above. Every one of them carries locale and languageId, so a listener never
has to go and ask which language moved.
| Event | Payload | Fires when |
|---|---|---|
translation_created | { contentId, locale, languageId, version, revisionId? } | A language's translation row is written |
translation_updated | { ...base, changedFields } | A language's localized fields changed |
translation_deleted | { ...base } | A language's translation is removed |
translation_published | { ...base, publishedAt } | One language becomes publicly visible |
translation_unpublished | { ...base } | One language is withdrawn |
translation_restored | { ...base, changedFields, revisionId, restoredFromRevisionId } | One language is rolled back to a revision |
Prop
Type
A listener would invalidate one locale's cache, reindex one language's search document, or tell a translation vendor that the Polish copy moved.
A shared update and a Polish translation update are different domain facts with
different consequences: one invalidates every language, the other invalidates
one. A listener that had to inspect changedFields to tell them apart would get
it wrong the first time a field was renamed - so changedFields on a
translation event names localized fields only, and never a shared one.
Delivery events
A content type with
delivery emits two more
when its public URL moves. Both arrive alongside the lifecycle event that
moved it - updated, restored, a publication transition or a
translation_* - never instead of one: a field moving and a URL moving are
different facts with different audiences.
| Event | Payload | Fires when |
|---|---|---|
delivery_slug_changed | { contentId, slug, previousSlug, previousPath, canonicalPath, locale } | The canonical public path is different from what it was |
delivery_redirect_created | { contentId, previousSlug, previousPath, canonicalPath, locale } | A path that had genuinely been live now answers as a redirect |
Prop
Type
A listener would purge a CDN entry, write to an edge redirect table, or tell
an external search engine the address changed. Without this event it would have
to inspect changedFields for a slug field whose name it cannot know.
delivery_redirect_created fires only when the old address had genuinely been
publicly addressable - the engine has a retired history row to prove it. An
article whose slug was corrected three times while it was still a draft emits
three delivery_slug_changed and not one redirect; a published article that
moves emits one of each. That is the difference between "a URL now needs a
redirect" and "somebody edited a field".
There is deliberately no sitemap event either: every mutation that changes a
sitemap line already emits published, unpublished, deleted or one of these
two, and a third carrying no new information would be one more thing to keep
consistent for no listener's benefit.
A scheduled event may arrive twice
Announcements for a scheduled transition run in a durable queue task that retries whenever the event, the search write or a cache origin failed - and a retry re-emits an event some listeners already received. Delivery is at-least-once, deliberately: the alternative is a transactional outbox, which the engine does not have.
A listener whose work must happen exactly once keys off scheduleId, which is
stable across every attempt at the same booking:
handler: async (c, payload) => {
if (payload.scheduleId && (await alreadyDone(payload.scheduleId))) return
await sendTheAnnouncement(payload.contentId)
}An interactive publish is emitted once, by the route that performed it, and
carries no scheduleId.
The other way out is an idempotency key on whatever the listener writes. A
notification published with idempotencyKey: `post:${payload.contentId}`
turns a second run into a no-op. See
Idempotency keys.
The envelope's owner is the content type's plugin
pluginId on the envelope answers "whose event is this", not "who was running
at the time". Those come apart the moment something happens on a schedule: core
owns the queue handler, so c.get('plugin') says @vitnode/core, while
content.example.article.published belongs to the example plugin as much as it
ever did.
queue task owner @vitnode/core ← who runs the handler
event envelope @vitnode/example ← who owns the content typeThe engine passes the owner explicitly on every emit, so ownership does not depend on which route module or queue handler invoked it. Your own code can do the same when it emits on someone else's behalf:
await c.get('events').emit('blog.post.created', payload, {
pluginId: '@vitnode/blog',
})Omit the option and nothing changes: the envelope falls back to
c.get('plugin') and then to @vitnode/core.
Failures are reported, not thrown
emit() reports rather than throws. Listeners run after the write it describes
has committed, and a broken listener is not a reason to tell somebody their save
failed - so a failure comes back in the result instead, and is written to
core_logs twice over: once per listener by the transport
(Event listener "plugin:module:listener" for "<event>" failed: ...), and once
as a JSON summary behind the greppable [content-effects] prefix by whichever
effects helper emitted it - the editorial, translation, delivery and scheduled
paths. A plain created/updated/deleted on a content type without
editorial goes straight to the transport, so it produces the first line only.
const result = await c.get('events').emit('blog.post.created', payload)
result.delivered // listeners that ran
result.failures // [{ pluginId, module, listener, error }]Interactive routes ignore that result on purpose, because the mutation succeeded
either way. Background work usually should not: the scheduled-effects task
inspects failures and retries the whole delivery when it is non-empty.
A generated route emits one lifecycle event per successful mutation, after the
database write has returned - plus the delivery pair when the URL moved with
it. A failed validation, a delete blocked by a foreign key, a no-op update, a
no-op publish and a restore that changed nothing all emit nothing. Calling
service.publish() - or any other content service method - directly changes
the database and emits nothing; that code owns its own follow-up. See
Services and API.
Content container events
Four names, declared in VitNodeEvents in
packages/vitnode/src/api/models/events.ts and shared by every
container collection. Each fires after
the write's transaction has committed. A refused write, such as deleting a board
that still has discussions, emits nothing, and so does an update that changes
nothing.
| Event | Payload | Fires when |
|---|---|---|
content.container.created | { definitionId, pluginId, containerId, parentId } | A container is created |
content.container.updated | { definitionId, pluginId, containerId, parentId, changedFields } | Its name, slug or a metadata field actually changed |
content.container.moved | { definitionId, pluginId, containerId, parentId, fromParentId, position } | It moved to another parent, another position, or both |
content.container.deleted | { definitionId, pluginId, containerId, parentId } | An empty container is deleted |
Emitted by createContentContainerService, so the AdminCP routes and your own
code that uses the service emit the same events. Siblings renumbered by a move
or a delete do not get events of their own.
Prop
Type
A listener would rebuild a navigation menu kept outside VitNode, or post to a staff channel when a board is deleted:
export const boardDeletedListener = buildEventListener({
event: 'content.container.deleted',
name: 'announce-deleted-board',
handler: async (c, payload) => {
if (payload.definitionId !== 'example.boards') return
await c.get('log').warn(`Board ${payload.containerId} was deleted`)
},
})Blog events (@vitnode/blog)
The blog runs on the Content Engine, so the events
that describe what actually happened are content.blog.post.* and
content.blog.category.* - they carry changed fields, revision ids,
publication transitions, per-locale translation events and slug history. The
six names below are re-emitted from those by listeners registered on the
plugin's own admin module, so existing consumers keep working. Prefer the
content.* ones for anything new. A duplicated article re-emits
blog.post.created once; duplicated, hidden and unhidden have no blog
adapter.
| Event | Payload | Re-emitted from |
|---|---|---|
blog.post.created | { postId, categoryId } | content.blog.post.created |
blog.post.updated | { postId, categoryId } | content.blog.post.updated |
blog.post.deleted | { postId } | content.blog.post.deleted |
blog.category.created | { categoryId } | content.blog.category.created |
blog.category.updated | { categoryId } | content.blog.category.updated |
blog.category.deleted | { categoryId, postIds } | content.blog.category.deleted |
blog.post.created and blog.post.updated
The adapter reads the article's categories back to fill in categoryId, so a
record deleted in between is simply not announced - and so is one with no
categories yet.
Prop
Type
A listener would push a realtime "new post" notification, ping a webhook (from a queue task) that shares the post to social media, invalidate an external cache, or keep plugin-owned derived data such as related posts in sync.
blog.post.deleted
Prop
Type
No categoryId, unlike created and updated: the row is gone by the time this is
emitted, so there is nothing left to read it from - and inventing one would put
a wrong id into an audit trail. A listener that needs the category should watch
content.blog.post.deleted and keep its own index.
A listener would remove the post from an external index or feed.
blog.category.created and blog.category.updated
Prop
Type
A listener would keep a navigation menu or an externally-cached category tree in sync, or notify an external CMS of a taxonomy change.
blog.category.deleted
Prop
Type
A listener would drop the category from a cached navigation tree. It never has to fan out to the category's posts, because a category with posts cannot be deleted in the first place.
Gotchas
The AdminCP user PATCH emits user.updated whenever it succeeds, even if
every field was written with the value it already had, and the role routes
behave the same way. Only the Content Engine's updated is gated on a real
diff. A listener that does expensive work should compare before acting.
Events fire at runtime for every registered content type, but the names appear
on VitNodeEvents only where the owning plugin declares
ContentEventsFor<typeof myContentType>. The example plugin grafts
example.article and example.category but not its two localized fixtures,
so content.example.localized-article.created really is emitted and still
cannot be given a type-checked listener. If your event name does not
autocomplete, that declaration is what is missing - see
Plugin registration.
A content type with editorial goes through the shared effects helper, which
stamps version and revisionId onto every payload it emits, including
created and deleted. Neither field is on the declared type for those
actions, so treat them as an implementation detail rather than a contract -
the fields you can rely on are the ones in the tables above.
The person who booked a transition is actorUserId on scheduled and
schedule_cancelled, and scheduledBy on the published and unpublished
that the booking eventually fires. Same human, two keys.
Deliberately not emitted yet
High-frequency or consumer-less events are added only when a listener needs
them, so the catalog stays meaningful. There is currently no user.signedIn,
user.passwordResetRequested, or file.uploaded. If you need one, open an
issue or PR - adding an event is a one-line emit plus an entry in the
VitNodeEvents map. (user.deleted is the special case: it is already on the
map, and waits on core growing a user-deletion flow.)
Live editing is quiet on the bus too. Taking or giving back a field lock,
autosaving the shared draft and every keystroke in a co-edited rich text field
happen many times a minute and change nothing anyone outside the form relies
on, so none of them is an event. What does reach the bus is the commit: when
someone presses Save, the record emits its usual updated (and its
translations theirs), exactly as it would without live editing.
Next
Events
How the bus works, what it guarantees, and how to emit and subscribe from a plugin.
Custom Adapter
Replace the in-process transport with a broker so every instance reacts.
Content Engine
Declare a content type and its whole event set is generated for you.
Queue
Durable background work with retries - what a thin listener should hand off to.