Logo VitNode

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:

plugins/shop/src/api/lib/events.ts
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.

EventPayloadFires 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

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.

EventPayloadFires 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

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.

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

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

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

EventPayloadFires when
core.page-layout.updated{ pageId, changedZones }Somebody saves widget changes on an editable page

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 declaresEvents it adds
nothing extra (always)created, updated, deleted
publicationpublished, unpublished
editorialrestored
editorial.schedulingscheduled, schedule_cancelled
localizationtranslation_created, translation_updated, translation_deleted
localization and publicationtranslation_published, translation_unpublished
localization and editorialtranslation_restored
deliverydelivery_slug_changed, delivery_redirect_created
duplicationduplicated
visibilityhidden, 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

EventPayloadFires 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

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.

EventPayloadFires 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

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.

EventPayloadFires 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

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 type

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

The generated routes emit, the services do not

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.

EventPayloadFires 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

Blog events (@vitnode/blog)

These six names are compatibility adapters

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.

EventPayloadRe-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

Core's user and role events are not deduplicated

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.

A content type is only typed once its plugin grafts it

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.

Editorial payloads carry more than they declare

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.

Names shift between scheduled and published

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