Documentation

Notifications

Publish notifications

Send a notification from a VitNode plugin. Declare a type with buildNotificationType, register it, and call c.get("notifications").publish() inside your transaction.

This guide notifies a topic's author when someone replies to their topic. You declare a notification type once, register it on your API plugin, and publish it from the route that saves the reply. The examples use a forum plugin with the id @acme/forum.

Before you begin

You need a plugin with an API part (src/config.api.ts) and a route that writes to the database. See Create a plugin if you do not have one yet.

Declare the notification type

Create a file for your plugin's notification types:

plugins/forum/src/api/lib/notifications.ts
import { buildNotificationType } from '@vitnode/core/api/lib/notifications/registry'
import { z } from 'zod'

export const topicReplyNotification = buildNotificationType({
  id: 'forum.topic_reply',
  version: 1,
  schema: z.object({ topicId: z.number(), topicTitle: z.string() }),
  category: 'social',
  label: '@acme/forum.notifications.topic_reply.label',
  defaults: { inApp: true, email: 'none' },
  present: ({ data, t }) => ({
    title: t('@acme/forum.notifications.topic_reply.title', {
      title: data.topicTitle,
    }),
    target: `/forum/topics/${data.topicId}`,
  }),
})

id is permanent, because stored notifications refer to it. schema checks the data you publish. present turns that data into the inbox text, in the recipient's language, every time the inbox loads. Every option is listed in Notification types.

Add the messages

The API renders notification text, so the strings belong in your plugin's API messages:

plugins/forum/src/locales/api/en.json
{
  "@acme/forum": {
    "notifications": {
      "topic_reply": {
        "label": "Replies to your topics",
        "title": "New reply in {title}"
      }
    }
  }
}

label names the type on the member's preferences page. If your plugin has no src/locales/api/ folder yet, create it as shown in Shipping translations with a plugin.

Register the type

Pass the type and the API messages to buildApiPlugin():

plugins/forum/src/config.api.ts
import { buildApiPlugin } from '@vitnode/core/api/lib/plugin'

import { topicReplyNotification } from '@/api/lib/notifications'
import { topicsModule } from '@/api/modules/topics/topics.module'
import messages from '@/locales/api'
import { manifest } from '@/manifest.gen'

export const forumApiPlugin = () =>
  buildApiPlugin({
    manifest,
    messages,
    notificationTypes: [topicReplyNotification], 
    modules: [topicsModule],
  })

Core collects the types of every installed plugin when the API starts, so restart the API after adding one. Two types with the same id stop the API from starting, with an error that names both plugins.

Publish in the reply route

In the handler that saves the reply, publish inside the same transaction:

plugins/forum/src/api/modules/topics/routes/reply.route.ts
await c.get('db').transaction(async (tx) => {
  const [reply] = await tx
    .insert(forum_replies)
    .values({ topicId: topic.id, authorId: user.id, content })
    .returning()

  await c.get('notifications').publish({
    type: topicReplyNotification,
    tx,
    recipients: [topic.authorId],
    subject: { type: 'forum.topic', id: topic.id },
    data: { topicId: topic.id, topicTitle: topic.title },
    idempotencyKey: `reply:${reply.id}`,
  })
})

topic, user and content come from your existing route. Because of tx, the notification is saved together with the reply: a rolled-back reply notifies nobody, and a saved reply always notifies, even if the server stops right after the commit. subject names what the notification is about. Core uses it to group notifications and to remove them later.

publish() returns { eventId, duplicate } and does not wait for delivery. Delivery starts after your route sends its response, so a large audience never slows the request down.

A rejected publish rolls back your write

publish() throws a NotificationPublishError when data does not match the schema or the type is not registered. Inside a transaction, that error rolls back the reply too. Catch it if the notification is optional for the write. See Errors.

Choose an idempotency key

The idempotencyKey names the real-world event, not the attempt to publish it. Publishing the same type with the same key again does nothing and returns the first event with duplicate: true. Retries, double clicks and replayed event listeners never notify twice.

EventGood keyBad key
A reply was postedreply:${reply.id}reply:${Date.now()}
An article was first publishedpost-published:${post.id}post-${post.updatedAt}
An export finishedexport:${job.id}crypto.randomUUID()

Use a random key only when every call really is a new message, such as an administrator sending the same announcement twice on purpose.

Choose the recipients

recipients holds the user ids your plugin picked, up to 100,000 per publish. Core removes duplicates and the actor, which is the signed-in admin or user of the request. When the notification is delivered, core also skips deleted users, members who turned the type off and anyone your type's access check rejects.

To notify the actor about their own work, set allowSelf: true:

plugins/forum/src/api/modules/exports/routes/finish.route.ts
await c.get('notifications').publish({
  type: exportReadyNotification,
  recipients: [user.id],
  allowSelf: true,
  data: { exportId: job.id },
  idempotencyKey: `export:${job.id}`,
})

A queue task or cron job has no signed-in user. Pass the real actor as actorId, or actorId: null for a system notification.

Check the result

  1. Sign in as a second user and reply to a topic started by someone else. Replying to your own topic notifies nobody, because the actor is skipped.
  2. Sign in as the topic author. Within a few seconds the bell shows an unread count, and the new item links to /forum/topics/{id}.
  3. Open /settings/notifications. Replies to your topics is listed under What you get notified about, in the Social group once more than one category exists.

If nothing arrives, see Troubleshooting.