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.
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.
NaNdoes not count as a number. - A payload that does not match makes
send()throw aPayloadValidationError(codePAYLOAD_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: [/* ... */] })import * as v from "valibot"
import { valibotPayload } from "@notifykitjs/core/valibot"
// Pass v.parse so NotifyKit can validate without importing Valibot itself.
const { payload, validate } = valibotPayload(
v.object({ invoiceId: v.pipe(v.string(), v.uuid()), amount: v.number() }),
v.parse,
)import { type } from "arktype"
import { arktypePayload } from "@notifykitjs/core/arktype"
const { payload, validate } = arktypePayload(
type({ invoiceId: "string", amount: "number" }),
)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.
| Where | How values are inserted |
|---|---|
| Inbox title and body | HTML-escaped |
| Inbox actionUrl | Kept only if it is an http(s) URL or a path starting with /; anything else is dropped |
| Email subject | As is |
| Email body | HTML-escaped, unless the channel sets html: false |
| SMS body | As is |
| Webhook url | URL-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:
| Option | What it does |
|---|---|
description | A human-readable label, returned with the notification's metadata for settings and admin UIs |
category | A 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 |
required | Cannot be turned off by preferences. For password resets, sign-in codes, and receipts. A missing address still skips the channel |
defaultChannels | Which channels are on when the user has set nothing, such as { email: false }. May only name channels the notification has |
version | A positive integer stored on every notification record, so old sends can be told apart after the definition changes |
redact | Payload fields replaced with "[REDACTED]" in what hooks receive and in notify.redactPayload(). The stored record keeps the full payload |
validate | A 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 |
fallback | Channels 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:
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
| Change | Effect |
|---|---|
| Edit template text | Safe. Only new sends use it; existing inbox items keep their rendered text |
| Add a channel | Safe, but users who have not opted out start getting it at once. Set defaultChannels to make it opt-in |
| Remove a channel | Safe. Preference rows for it are ignored |
| Add a payload field | Every 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 field | Remove it from the templates in the same change, or startup fails |
| Rename an id | Breaking. Preferences, rate-limit counters, and dedupe keys are stored against the old id |
| Delete a notification | Its 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:
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.