BackCore concepts

Defining notifications

Notification ids, payload schemas, channel templates, and categories, all checked by TypeScript.

The Basics

A notification has three required parts: an id, a payload schema describing the data each send carries, and the channels it can go out on, each with its own templates.

lib/notifications.ts
import { channel, notification } from "@notifykitjs/core"

const inbox = channel.inbox()
const email = channel.email()

export const commentMentioned = notification({
	id: "comment_mentioned",
	payload: { actorName: "string", postTitle: "string", postUrl: "string" },
	channels: [
		inbox({
			title: "{{actorName}} mentioned you",
			body: "In {{postTitle}}",
			actionUrl: "{{postUrl}}",
		}),
		email({
			subject: "{{actorName}} mentioned you in {{postTitle}}",
			body: "Open {{postUrl}} to reply.",
		}),
	],
})

channel.inbox() and the others return a factory; call it once per template. The inbox, email, SMS, and webhook channels and what each needs are on Channels. A notification can have at most one channel of each type.

Payload Schemas

The simplest schema maps each field to "string", "number", or "boolean". It has no dependencies and gives you both the TypeScript type for send() and a runtime check:

notification({
	id: "order_shipped",
	payload: { orderNumber: "string", total: "number", expedited: "boolean" },
	channels: [/* ... */],
})
  • Every field is required. There are no optional fields in this form.
  • Fields not in the schema are dropped before anything is stored. NaN does not count as a number.
  • A payload that does not match makes send() throw a PayloadValidationError (code PAYLOAD_VALIDATION_ERROR) listing every bad field. Nothing is stored or sent.
  • Field names can only contain letters, digits, _, and $, the characters a template placeholder accepts.

Zod, Valibot, and ArkType

For refinements such as UUIDs, ranges, or nested objects, derive the schema from a validation library. Each helper returns the payload schema and a validate function that replaces the built-in check:

import { z } from "zod"
import { zodPayload } from "@notifykitjs/core/zod"

const { payload, validate } = zodPayload(
	z.object({ invoiceId: z.string().uuid(), amount: z.number().positive() }),
)

notification({ id: "invoice_created", payload, validate, channels: [/* ... */] })

Only top-level plain string, number, and boolean fields become part of the payload schema, so only those can appear in templates. Arrays, objects, enums, and constrained ArkType fields such as "string.email" are still validated, but cannot be used in a {{placeholder}}. Each such field is logged as a warning when the schema is built. Zod 3 and 4, Valibot 1, and ArkType 2 are supported.

You can also pass your own validate(payload). It must throw on bad input and return the validated payload as a plain object.

Templates

Every text field in a channel can use {{field}} placeholders, filled in from the payload at send time. A value that is missing or null renders as an empty string. A placeholder that names a field the schema does not declare is caught at startup, not at send time.

WhereHow values are inserted
Inbox title and bodyHTML-escaped
Inbox actionUrlKept only if it is an http(s) URL or a path starting with /; anything else is dropped
Email subjectAs is
Email bodyHTML-escaped, unless the channel sets html: false
SMS bodyAs is
Webhook urlURL-encoded

Rendered text is stored with the inbox item, so changing a template only affects later sends. Email templates can also use {{_unsubscribeUrl}}, a signed one-click unsubscribe link that is filled in when unsubscribe is configured. See Preferences and unsubscribe.

Options

Beyond the three required parts, a definition accepts:

OptionWhat it does
descriptionA human-readable label, returned with the notification's metadata for settings and admin UIs
categoryA free-form group such as "billing". Users and your app defaults can turn a whole category on or off
classification"transactional", "product", or "marketing". Metadata for grouping in preference UIs
requiredCannot be turned off by preferences. For password resets, sign-in codes, and receipts. A missing address still skips the channel
defaultChannelsWhich channels are on when the user has set nothing, such as { email: false }. May only name channels the notification has
versionA positive integer stored on every notification record, so old sends can be told apart after the definition changes
redactPayload fields replaced with "[REDACTED]" in what hooks receive and in notify.redactPayload(). The stored record keeps the full payload
validateA custom validator that replaces the built-in payload check
rateLimit{ max, windowMs, scope? }: cap sends per recipient (or across everyone). See Digests and rate limits
digest{ windowMs, render, key? }: batch sends into one. See Digests and rate limits
fallbackChannels to try when one fails, is skipped, or has no address. See Fallback channels
notification({
	id: "password_reset",
	description: "Sent when someone asks to reset their password",
	category: "security",
	classification: "transactional",
	required: true,
	payload: { resetUrl: "string", ipAddress: "string" },
	redact: ["resetUrl", "ipAddress"],
	rateLimit: { max: 5, windowMs: 60 * 60_000 },
	channels: [
		email({ subject: "Reset your password", body: "Reset it here: {{resetUrl}}" }),
	],
})

Start with just the id, payload, and channels. Add a rate limit when something can be triggered in a loop, a digest when people complain about noise, and a fallback where a lost message would cause a support ticket.

Registering

Pass every definition to createNotifyKit(), with as const on the array:

lib/notifykit.ts
import { commentMentioned, orderShipped, passwordReset } from "./notifications"

export const notify = createNotifyKit({
	notifications: [commentMentioned, orderShipped, passwordReset] as const,
	database,
	providers,
})

as const keeps the ids as literal types, which is what lets send() reject an unknown id and check each payload. Ids must be unique. Lowercase with ., -, or _ separators is recommended (anything else is a warning), and ids starting with __ are reserved.

Startup Checks

createNotifyKit() checks the whole configuration before it returns and throws one error listing every problem, each with a fix. Among the things it catches:

  • A template placeholder for a field the payload does not declare
  • A channel without its required text, such as an inbox with no title or an email with no body
  • An email, SMS, or webhook channel (or fallback) with no matching provider configured
  • Duplicate ids, duplicate channel types, and fallbacks that point back at the same channel
  • A digest without render, or a rate limit or window that is not a positive number
  • An unsubscribe secret shorter than 32 characters

Softer problems, such as an SMS notification with no rate limit or {{_unsubscribeUrl}} without unsubscribe configured, are printed as warnings instead. The same checks are available to CI through the CLI; see Testing and dev mode.

Changing Definitions

ChangeEffect
Edit template textSafe. Only new sends use it; existing inbox items keep their rendered text
Add a channelSafe, but users who have not opted out start getting it at once. Set defaultChannels to make it opt-in
Remove a channelSafe. Preference rows for it are ignored
Add a payload fieldEvery send() must now pass it: a type error at each call site. A digest’s render() output is validated too, so payloads buffered before the change must still produce the new field
Remove a payload fieldRemove it from the templates in the same change, or startup fails
Rename an idBreaking. Preferences, rate-limit counters, and dedupe keys are stored against the old id
Delete a notificationIts inbox items stay. Its preference rows are ignored, but reusing the id later brings them back into effect

To rename, add the new definition next to the old one, copy each user’s preferences across with notify.preferences.list() and notify.preferences.update(), move the send() calls, then remove the old definition. Before removing any notification that uses a digest or can be held by quiet hours, let pending sends go out with notify.flushDigests() and notify.flushScheduledSends().

Organizing Files

A handful of notifications fit in lib/notifykit.ts. Past that, one file per notification, grouped by area, keeps each definition easy to find and delete with the feature that sends it. Shared channel templates can be plain functions:

lib/notifications/shared.ts
import { channel } from "@notifykitjs/core"

const inbox = channel.inbox()

export function activityInbox(action: string) {
	return inbox({ title: `{{actorName}} ${action}`, actionUrl: "{{targetUrl}}" })
}

List the definitions explicitly in the notifications array. as const only keeps the ids precise on an array literal, so building the array with Object.values() loses the type checking on send().

Split two notifications when users should be able to turn them off separately, or when they need different channels, digests, or rate limits. Merging two later is easier than splitting one, which means migrating preferences.