Testing and dev mode
Block real sends in development, assert on deliveries in tests, and validate definitions from the CLI.
Dev Mode
Pass mode: "development" to createNotifyKit() and every email, SMS, and webhook goes through a sandbox instead of your real provider. The pipeline runs exactly as it would in production: inbox items are written, preferences apply, deliveries are recorded. Only the outbound message is held back, unless its address is on the allowlist.
export const notify = createNotifyKit({
notifications: [commentMentioned] as const,
database: memoryAdapter(),
providers: { email: resendProvider({ apiKey, from }) },
mode: process.env.NODE_ENV === "production" ? "production" : "development",
dev: {
allowlist: ["dev@yourteam.com"],
subjectPrefix: "[DEV] ",
},
})- A send to an address on the allowlist goes to the real provider, with the subject prefix added. Everything else is blocked and gets a
dev-blocked-…message id, so the delivery still reads as sent. - Allowlist entries are compared with the whole address, ignoring case. For webhooks, the entry is the full URL.
- Email is always sandboxed, so dev mode works before you have an email provider at all: allowed emails with no real provider are captured but not sent. SMS and webhooks are only wrapped when you configured a provider for them.
- On startup NotifyKit logs
[notifykit] Development mode active.and the allowlist, and warns ifNODE_ENVisproductionwhile dev mode is on.
Options
| dev option | Default | Effect |
|---|---|---|
allowlist | [] | Addresses (or webhook URLs) that still reach the real provider. Empty blocks everything |
subjectPrefix | "[DEV] " | Added to the subject of every email, sent or blocked |
logPreviews | false | Log each send to the console with its first 200 characters |
maxCaptured | 1000 | How many sends notify.captured keeps; the oldest are dropped first |
Captured sends
notify.captured is an array of every outbound message, blocked or not, and notify.isDev is true. In production mode captured stays empty.
type CapturedSend = {
channel: "email" | "webhook" | "sms"
to: string // email address, phone number, or webhook URL
subject?: string // email only, with the prefix applied
body: string // webhook: the JSON payload
blocked: boolean
timestamp: Date
}Test Harness
@notifykitjs/testing builds a NotifyKit instance for tests: the in-memory database, fake email, SMS, and webhook providers that record what they are sent, and a single delivery attempt with no retry delay. It works with any test runner.
npm install -D @notifykitjs/testingimport { expect, test } from "vitest"
import {
assertInboxItem,
assertSentEmail,
createTestNotifyKit,
} from "@notifykitjs/testing"
import { commentMentioned } from "./lib/notifications"
test("a mention reaches the inbox and email", async () => {
const notify = createTestNotifyKit([commentMentioned] as const)
await notify.upsertRecipient({ id: "user_1", email: "jane@example.com" })
await notify.send({
recipientId: "user_1",
notificationId: "comment_mentioned",
payload: {
actorName: "Rey",
postTitle: "Launch Plan",
postUrl: "/posts/1",
},
})
assertSentEmail(notify, { to: "jane@example.com", subject: /mentioned you/ })
assertInboxItem(notify, { recipientId: "user_1", title: "Rey mentioned you" })
expect(notify.testing.lastResult?.skipped).toEqual([])
})createTestNotifyKit(notifications, options?) accepts every createNotifyKit() option except notifications, database, and providers. You can pass your own fake providers under providers, and override retry to test backoff. The result is a normal NotifyKit instance with one extra property, testing.
notify.testing
| Member | What it gives you |
|---|---|
sentEmails() | Every email the fake provider received: to, subject, body, providerMessageId, sentAt |
sentSms() | Every SMS the fake provider received |
sentWebhooks() | Every webhook: url, headers, payload |
inboxFor(recipientId) | The recipient’s inbox, same as notify.inbox.list() |
deliveriesFor(recipientId) | The recipient's delivery records |
lastResult | The SendResult of the most recent send(), or null |
results | Every SendResult so far. Dry runs are not recorded |
database | The memory adapter; its raw arrays are on database._state |
providers | The fake email, sms, and webhook providers |
reset() | Clears sent messages, results, notifications, inbox items, deliveries, digests, rate limits, scheduled sends, dedupe records, and timeline events |
Assertions
Each helper throws with a list of what was actually there, so a failure message tells you what went wrong. String matchers match a substring; pass a regular expression for anything else.
| Helper | Passes when |
|---|---|
assertSentEmail(notify, match?) | At least one email was sent, and one matches { to?, subject?, body? }. to is compared exactly |
assertNoEmailSent(notify) | No email was sent |
assertInboxItem(notify, match?) | An inbox item exists matching { recipientId?, notificationId?, title?, body? } |
assertNoInboxItems(notify) | No inbox items exist |
assertDelivery(notify, match?) | A delivery record matches { channel?, status?, recipientId?, notificationId? } |
assertNotificationSent(notify, id) | A notification record exists for that notification id |
assertNotificationNotSent(notify, id) | No notification record exists for it |
A rate-limited or fully opted-out send still stores a notification record, so assertNotificationSent passes for it. To check that nothing went out, assert on the channel instead, with assertNoEmailSent or assertDelivery(notify, { status: "skipped" }).
Simulating failures
Every fake provider has setFailOnNext(true), which makes its next send throw once. With the harness’s single attempt, that delivery is marked failed and any fallback runs:
notify.testing.providers.email.setFailOnNext(true)
await notify.send({
recipientId: "user_1",
notificationId: "comment_mentioned",
payload,
})
assertDelivery(notify, { channel: "email", status: "failed" })The same fakes are exported from @notifykitjs/core as fakeEmailProvider(), fakeSmsProvider(), and fakeWebhookProvider(), each taking { failOnNext?: boolean }, with a sent array and clear().
Digests and quiet hours
Digest windows and quiet-hours deferrals run on real timers. The harness has no fake clock, so rather than waiting, force them:
await notify.flushDigests()sends every open digest now.await notify.flushScheduledSends()delivers everything held for quiet hours, whatever its scheduled time.await notify.close()inafterEachcancels leftover timers so the test runner can exit.
CLI
@notifykitjs/cli installs a notifykit command that loads your definitions from a config file and checks them, sends one through a throwaway instance, or serves the HTTP routes locally.
npm install -D @notifykitjs/cliConfig file
The CLI looks for notifykit.config.ts, .js, or .mjs in the current folder, or the path you pass with --config. It must default-export defineConfig(). TypeScript configs load through Bun when you run under Bun, and through tsx (bundled with the CLI) under Node.
import { defineConfig } from "@notifykitjs/cli"
import { commentMentioned, weeklyDigest } from "./lib/notifications"
export default defineConfig({
notifications: [commentMentioned, weeklyDigest],
unsubscribe: {
secret: process.env.NOTIFYKIT_SECRET!,
baseUrl: "https://app.com/api/notifykit",
},
})Besides notifications, the config takes providers, unsubscribe, defaults, retry, idempotencyKeyTtlMs, and timelineRetentionMs, with the same meaning as in createNotifyKit().
notifykit check
notifykit check [--config <path>] [--strict]Lists every notification with its channels and payload, then runs the same validation createNotifyKit() runs at startup: template variables that are not in the payload, duplicate or reserved ids, fallbacks that loop, digest and rate-limit settings, missing providers, weak unsubscribe secrets, and more. Errors fail the command. Warnings, such as an unsigned webhook provider or an id that is not snake_case, only fail it with --strict. Put it in CI to catch a broken template before it ships.
notifykit send
notifykit send --to <recipientId> --id <notificationId> [--payload <json>]
[--email <address>] [--phone <number>] [--config <path>]Sends one notification through a fresh in-memory instance and prints the inbox items and each delivery’s status. The recipient is created on the spot with the address you give. Email and webhooks use the providers from your config, or fakes if there are none, so without a provider nothing really goes out. It exits with 1 if any delivery failed.
notifykit send --to user_1 --id comment_mentioned --email me@example.com \
--payload '{"actorName":"Rey","postTitle":"Plan","postUrl":"/p/1"}'notifykit serve
notifykit serve [--port <n>] [--dev-user <id>] [--base-path <path>] [--config <path>]Serves the handler routes on 127.0.0.1, port 4000 by default, under /api/notifykit unless you set --base-path. State is in memory, CORS is open, and lifecycle events are logged as they happen. Point a frontend at it to build an inbox before your backend exists.
Exit codes
| Code | Meaning |
|---|---|
0 | Success, or --help |
1 | Validation errors, a failed delivery, an unknown command, or any other error |
2 | The config file is missing or malformed |