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)

Six 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 (profile fields and/or role assignments)
user.deleted{ userId, email }Never - the name is declared for plugins, core has no deletion flow
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

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.

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

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

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

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

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.

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

Next