Documentation

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.

This page lists every event VitNode core, the Content Engine and the first-party plugins emit, with its name, its payload and exactly when it fires. Use it to find the event to listen to. To write your first listener, or to emit events of your own, start with Events; every listener below is registered the same way, with no import from the emitting plugin, because the event map is global.

DomainNames
Coreuser.*, file.editor.uploaded, role.*
Notificationsnotifications.*
Moderationmoderation.*
AIai.run.*, files.alt.updated
Paymentspayment.*
Page layoutscore.page-layout.updated
Content Enginecontent.<content type id>.<action>
Content containerscontent.container.*
Content accesscontent.access.updated
Content approvalscontent.approval.*
Blogblog.post.*, blog.category.*

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. Slow work, such as sending an email, goes to a queue task your plugin registers:

plugins/site-notes/src/api/tasks/welcome-member.task.ts
import { buildQueueTask } from '@vitnode/core/api/lib/queue'
import { z } from 'zod'

const zodPayload = z.object({ userId: z.number().int().positive() })

export const welcomeMemberTask = buildQueueTask({
  name: 'welcome-member',
  handler: async (c, payload) => {
    const { userId } = zodPayload.parse(payload)

    await c.get('log').debug(`Welcoming member #${userId}`)
  },
})
plugins/site-notes/src/api/lib/welcome.listener.ts
import { buildEventListener } from '@vitnode/core/api/lib/events'

import { manifest } from '@/manifest.gen'

export const welcomeListener = buildEventListener({
  event: 'user.created', 
  name: 'queue-welcome-member',
  handler: async (c, payload) => {
    await c.get('queue').dispatch({
      name: 'welcome-member',
      payload: { userId: payload.userId },
      pluginId: manifest.id, 
    })
  },
})
plugins/site-notes/src/api/modules/notes/notes.module.ts (excerpt)
export const notesModule = buildModule({
  name: 'notes',
  routes: [listNotesRoute, showNoteRoute],
  queueTasks: [welcomeMemberTask], 
  events: [welcomeListener], 
})

The payload type comes from the event name, and the delivery rules are the same for a core event as for one of your own.

Listener context

A listener runs inside the request that emitted the event and receives that request's c. For user.created, that is core's sign-up request, so:

On cBelongs to
c.get('plugin')The emitting plugin (here @vitnode/core), not yours.
c.get('queue')dispatch() defaults to the emitter's plugin. Pass pluginId: manifest.id to queue your task, or it fails with "No handler registered".
c.get('cache')Keys are prefixed with the emitter's plugin id, so they never match keys your own routes wrote.
c.get('user')Whoever triggered the event, if anyone; the envelope's actor says the same.

Content Engine events emitted by core's scheduled-publishing task show the split most clearly. c.get('plugin') is @vitnode/core there too, while the envelope's pluginId names the content type's owner (see The envelope's owner).

Core events (@vitnode/core)

Nineteen names, all declared in VitNodeEvents in packages/vitnode/src/api/models/events.ts. Every one except user.deleted, which core never emits, 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, time zone or Browse anonymously 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
file.editor.uploaded{ fileId, userId, kind }An image or attachment is dropped, pasted or inserted into an editor that allows uploads
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

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

Eleven 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 write commits. The envelope's actor is the reporter for created, the administrator for report_reason.* 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. reason is the reason's code
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
moderation.report_reason.created{ reasonId, code }An admin adds a report reason
moderation.report_reason.updated{ reasonId, code }An admin edits a report reason
moderation.report_reason.deleted{ reasonId, code }An admin deletes a report reason. Its reports stay
moderation.report_reason.reordered{ reasonIds }An admin saves a new order. reasonIds lists every reason in that order

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 errors and limits). 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.

Payment events

Declared in VitNodeEvents in packages/vitnode/src/api/models/events.ts and emitted by payments after the new status is committed, once per status change, whichever path found it: the webhook route, a customer's status refresh or the reconcile-payments cron job. Every event has the same payload.

EventPayloadFires when
payment.processing{ amount, currency, paymentId, pluginId, providerId, reference, userId }The customer finished the checkout, but a delayed payment method has not settled
payment.succeededsameThe provider confirmed the payment
payment.failedsameThe provider reported a final failure, or refused to create the checkout
payment.expiredsameThe checkout closed unpaid, or an attempt whose checkout was never created expired

amount is in minor units, pluginId is the plugin that created the payment and reference is that plugin's own id, so filter on pluginId first. A checkout the provider refused to create also emits payment.failed; the stored payment then has an errorCode such as amount_too_small.

With the default local adapter a listener runs inside the request that changed the status, and a failing listener is logged, not retried. Derive what a member owns from the stored payment (c.get("payments").listByReference()), and use these events for side effects such as a thank-you notification.

A listener would send a receipt notification, post to a sales channel, or clear a cached page that shows the buyer's purchases.

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>. This section is the field-by-field reference; Content Engine events has the short action table and a subscribe walkthrough. For the newsroom plugin's newsroom.story that means content.newsroom.story.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 newsroom plugin's newsroom.story declares editorial, so content.newsroom.story.restored type-checks; newsroom.category is a name and a slug and nothing else, so content.newsroom.category.restored is not a key on the map and a listener for it does not compile.

Record events

EventPayloadFires when
created{ contentId, duplicatedFromId? }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.newsroom.story.published belongs to the newsroom plugin as much as it ever did.

queue task owner    @vitnode/core        ← who runs the handler
event envelope      newsroom             ← 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 desk that still has letters, 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, a metadata field or its publishing policies 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

Content access events

EventPayloadEmitted when
content.access.updated{ family, scopeId, pluginId, changedActions, userId }An administrator saved access rules that changed at least one action of a container, a blog category or another scope

Content approval events

Three names, declared in VitNodeEvents in packages/vitnode/src/api/models/events.ts and shared by every content type that waits for approval under a container's publishing policy. Each fires after its transaction commits. A refused decision, such as a 409 stale, emits nothing.

EventPayloadFires when
content.approval.submitted{ submissionId, replacedSubmissionId, pluginId, contentTypeId, contentId, containerId, kind, authorId }A member's create or edit starts waiting, including a resubmission or a newer proposal
content.approval.approved{ submissionId, pluginId, contentTypeId, contentId, containerId, kind, authorId, reviewerId }A moderator approves a submission
content.approval.rejected{ submissionId, pluginId, contentTypeId, contentId, containerId, kind, authorId, reviewerId, reason }A moderator rejects a submission

The record's own events are never duplicated. A pending create emits the content type's created when it is stored, and its published only when it is approved. Approving an edit emits one updated, with the changed fields. Rejecting emits nothing from the content type, because nothing on the record changed.

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 newsroom plugin grafts newsroom.story and newsroom.category but not, say, newsroom.bulletin, so content.newsroom.bulletin.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.