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