Documentation

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.newsroom.story.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. newsroom.category declares only fields and emits three. newsroom.story has publication, editorial with scheduling and delivery, and emits ten.

ActionPayloadNeeds
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:

  • contentId has the type of the content type's id strategy: a number for serial, a string for uuid and bigint. A bigint id arrives as its exact decimal string.
  • A duplicate emits the copy's created with duplicatedFromId, one translation_created per copied language, then duplicated. duplicatedFromId is absent on an ordinary create.
  • hidden and unhidden never replace published or unpublished, because hiding does not change the status.
  • changedFields lists only the fields whose value actually moved, typed as the content type's own field names. On translation events it names localized fields only.
  • restored replaces updated for a rollback, so a listener that cares about field changes should handle both.
  • publishedAt is when the record first went live. It is never rewritten, and it can be missing, so guard for undefined.
  • scheduledBy and scheduleId are set only when a schedule fired the change. actorUserId on scheduled is the same person as scheduledBy later.
  • The delivery events arrive alongside updated, restored or a publication event, never instead of them. locale is null for 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.newsroom.category.published, does not compile.

plugins/newsroom/src/api/lib/events.ts
import type { ContentEventsFor } from "@vitnode/core/content";

import type { categoryContentType } from "@/content/category";
import type { storyContentType } from "@/content/story";

declare module "@vitnode/core/api/models/events" {
  interface VitNodeEvents
    extends
      ContentEventsFor<typeof storyContentType>,
      ContentEventsFor<typeof categoryContentType> {}
}

The newsroom 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:

plugins/newsroom/src/api/lib/listeners.ts
import { buildEventListener } from "@vitnode/core/api/lib/events";
import { contentEventName } from "@vitnode/core/content";

import { storyContentType } from "@/content/story";

export const announceStoryListener = buildEventListener({
  event: contentEventName(storyContentType.id, "published"),
  name: "announce-published-story",
  description: "Logs every story that goes live",
  handler: async (c, payload) => {
    await c.get("log").debug(`Story ${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.

plugins/newsroom/src/api/modules/admin/admin.module.ts (excerpt)
export const adminModule = buildModule({
  name: "admin",
  routes: [],
  modules: [
    buildContentAdminModule({
      containers: [deskContainers],
      contentTypes: [
        articleContent,
        categoryContent,
        commentContent,
        storyContent,
        explainerContent,
        bulletinContent,
        tagContent,
        eventContent,
        pageContent,
        letterContent,
      ],
    }),
  ],
  events: [announceStoryListener], 
});

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 a story in AdminCP → Newsroom → Stories 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.