Events
Every event a Content Engine content type emits, its payload and the feature that enables it, and how to subscribe with a typed event listener.
Each content type emits events named content.<id>.<action> after a change, such as content.example.article.published. Subscribe to them to send a newsletter, sync an external index or purge a CDN, without touching the code that made the change.
Events come from the generated routes, scheduled publishing and contentEditorialEffects. The plain service(c) emits nothing. A save that changes nothing emits nothing either.
Event reference
Which events exist depends on what the definition enables. example.category declares only fields and emits three; example.article has publication, editorial with scheduling and delivery, and emits ten.
| Action | Payload | Needs |
|---|---|---|
created | { contentId, duplicatedFromId? } | always |
updated | { contentId, changedFields } | always |
deleted | { contentId } | always |
published | { contentId, publishedAt, scheduledBy?, scheduleId? } | publication |
unpublished | { contentId, scheduledBy?, scheduleId? } | publication |
restored | { contentId, changedFields, version, revisionId, restoredFromRevisionId } | editorial |
scheduled | { contentId, action, actorUserId, scheduledFor, scheduleId } | editorial.scheduling |
schedule_cancelled | { contentId, action, actorUserId, scheduleId } | editorial.scheduling |
translation_created | { contentId, locale, languageId, version, revisionId? } | localization |
translation_updated | { contentId, locale, languageId, version, changedFields, revisionId? } | localization |
translation_deleted | { contentId, locale, languageId, version, revisionId? } | localization |
translation_published | { contentId, locale, languageId, version, publishedAt, revisionId? } | localization + publication |
translation_unpublished | { contentId, locale, languageId, version, revisionId? } | localization + publication |
translation_restored | { contentId, locale, languageId, version, changedFields, revisionId, restoredFromRevisionId } | localization + editorial |
delivery_slug_changed | { contentId, slug, previousSlug, previousPath, canonicalPath, locale } | delivery |
delivery_redirect_created | { contentId, previousSlug, previousPath, canonicalPath, locale } | delivery |
duplicated | { actorUserId, contentId, contentTypeId, sourceId } | duplication |
hidden | { actorUserId, contentId, hiddenAt } | visibility |
unhidden | { actorUserId, contentId } | visibility |
A few payload details that change what a listener does:
contentIdhas the type of the content type's id strategy: anumberforserial, astringforuuidandbigint. A bigint id arrives as its exact decimal string.- A duplicate emits the copy's
createdwithduplicatedFromId, onetranslation_createdper copied language, thenduplicated.duplicatedFromIdis absent on an ordinary create. hiddenandunhiddennever replacepublishedorunpublished, because hiding does not change the status.changedFieldslists only the fields whose value actually moved, typed as the content type's own field names. On translation events it names localized fields only.restoredreplacesupdatedfor a rollback, so a listener that cares about field changes should handle both.publishedAtis when the record first went live. It is never rewritten, and it can be missing, so guard forundefined.scheduledByandscheduleIdare set only when a schedule fired the change.actorUserIdonscheduledis the same person asscheduledBylater.- The delivery events arrive alongside
updated,restoredor a publication event, never instead of them.localeisnullfor a shared slug.
Container collections emit their own four events, content.container.created, updated, moved and deleted, listed in Container hierarchy.
A record waiting for approval emits its published or updated only when a moderator approves it, once. The submission itself emits content.approval.submitted, approved and rejected, listed in Built-in events.
The listener also receives an envelope with the actor, the time and pluginId. pluginId is always the plugin that owns the content type, even when a core queue task ran the change. Field-by-field descriptions are in Built-in events.
Subscribe to an event
Register the event types
Add the content type to the global event map once per plugin. The listener's event name and payload are then type-checked, and an event the definition does not enable, such as content.example.category.published, does not compile.
import type { ContentEventsFor } from "@vitnode/core/content";
import type { articleContentType } from "@/content/article";
import type { categoryContentType } from "@/content/category";
declare module "@vitnode/core/api/models/events" {
interface VitNodeEvents
extends
ContentEventsFor<typeof articleContentType>,
ContentEventsFor<typeof categoryContentType> {}
}The example plugin imports this file from config.api.ts with import "@/api/lib/events";, so the declaration travels with the API build.
Write the listener
contentEventName(id, action) builds the name from the definition, so a renamed content type cannot leave a stale string behind:
import { buildEventListener } from "@vitnode/core/api/lib/events";
import { contentEventName } from "@vitnode/core/content";
import { articleContentType } from "@/content/article";
export const announceArticleListener = buildEventListener({
event: contentEventName(articleContentType.id, "published"),
name: "announce-published-article",
description: "Logs every article that goes live",
handler: async (c, payload) => {
await c.get("log").debug(`Article ${payload.contentId} was published`);
},
});Add it to a top-level module
VitNode collects listeners only from the events array of a module passed directly to buildApiPlugin. A listener on a nested module is silently ignored.
export const adminModule = buildModule({
name: "admin",
routes: [],
modules: [
buildContentAdminModule({
contentTypes: [articleContent],
}),
],
events: [announceArticleListener],
});The blog plugin does the same with its blogLegacyEventListeners, which turn content.blog.post.created into the older blog.post.created event.
Check the result
Save the file, publish an article in AdminCP → Example → Articles and look for the debug line in the API terminal. A failing listener does not undo the change. The write has already committed, and the engine logs the failure.
Events from a scheduled publish run in a queue task that retries on failure, so the same published event can arrive twice. Make work that must happen once idempotent, for example keyed on scheduleId. See A scheduled event may arrive twice.