API reference
Complete reference for the createNotifyKit() instance. All methods below are available on the object returned by createNotifyKit().
Sending
send(), explain(), and check() — deliver notifications or dry-run the pipeline without side effects.
State management
Inbox, preferences, and recipient APIs — read and write user-facing notification state.
Debugging
timeline() and redactPayload() — forensic event logs and PII-safe payload inspection.
Lifecycle
drain(), flushScheduledSends(), and flushDigests() — graceful shutdown and scheduled delivery.
| Category | Methods |
|---|---|
| Sending | send(), explain(), check() |
| Recipients | upsertRecipient() |
| Inbox | inbox.list(), inbox.markReadForRecipient(), inbox.markAllRead(), inbox.archiveForRecipient(), inbox.deleteForRecipient() |
| Preferences | preferences.list(), preferences.get(), preferences.update(), preferences.explain() |
| Deliveries | deliveries.list() |
| Debugging | timeline(), pruneTimeline(), redactPayload() |
| Lifecycle | drain(), flushScheduledSends(), flushDigests() |
createNotifyKit(config)
Creates the NotifyKit instance. All options except notifications and database are optional:
| Option | Required | Purpose |
|---|---|---|
notifications | Yes | Array of notification definitions (use as const for type inference) |
database | Yes | Adapter — memoryAdapter(), drizzleSqliteAdapter(), or drizzlePostgresAdapter() |
providers | No | { email?, sms?, webhook? } — channel providers |
queue | No | Delivery queue — inlineQueue() (default) or setTimeoutQueue() |
retry | No | { maxAttempts, delayMs } — retry policy for failed deliveries |
realtime | No | Realtime adapter for SSE push |
unsubscribe | No | { secret, baseUrl } — enables HMAC unsubscribe links |
defaults | No | { channels } — app-wide default channel preferences |
tenantDefaults | No | (tenantId) => ChannelMap | null — per-tenant overrides |
on | No | Hook handlers — see Hooks |
idempotencyKeyTtlMs | No | TTL for idempotency keys (default: 24h) |
timelineRetentionMs | No | Auto-prune window for pruneTimeline() |
devMode | No | true to block all real sends in development |
import { createNotifyKit, memoryAdapter, setTimeoutQueue } from "@notifykitjs/core"
export const notify = createNotifyKit({
notifications: [commentMentioned, orderShipped] as const,
database: memoryAdapter(),
providers: { email: resendProvider({ apiKey, from }) },
queue: setTimeoutQueue(),
retry: { maxAttempts: 5, delayMs: (n) => 1000 * 2 ** (n - 1) },
unsubscribe: { secret: process.env.NOTIFYKIT_SECRET!, baseUrl },
devMode: process.env.NODE_ENV !== "production",
})notify.send(input)
Send a notification. Returns a SendResult with all outcomes. Pass dryRun: true to get a DeliveryExplanation instead.
const result = await notify.send({
recipientId: string,
notificationId: string, // must match a registered definition
payload: { ... }, // typed per notification
tenantId?: string,
organizationId?: string, // alias for tenantId
workspaceId?: string,
idempotencyKey?: string,
dedupeKey?: string,
dedupeWindowMs?: number,
dryRun?: boolean,
})SendResult
type SendResult = {
notification: NotificationRecord | null
inboxItems: InboxItem[]
deliveries: DeliveryRecord[]
skippedChannels: ChannelType[] // deprecated; use skipped[]
skipped: SkippedDelivery[]
deferredChannels: ChannelType[]
digested: boolean
rateLimited: boolean
idempotent: boolean
}notify.explain(input)
Dry-run a send with zero side effects — no records written, no emails sent. Returns a DeliveryExplanation showing exactly whatwould happen if this were a real send(). Same input shape as send().
| Return field | Type | What it tells you |
|---|---|---|
channels | Record<string, ChannelExplanation> | Per-channel outcome: would_deliver, skipped (with reason), or deferred |
preferences | PreferenceResolution | Full resolution trail — global defaults → tenant → category → notification → recipient override |
wouldDeduplicate | boolean | Whether the dedup key has been seen within its window |
wouldRateLimit | boolean | Whether the send would exceed the rate limit threshold |
wouldDigest | boolean | Whether the send would enter a digest buffer instead of delivering immediately |
quietHours | { active, resumesAt? } | Whether the recipient is in quiet hours and when push channels would fire |
recipient | Recipient | null | The resolved recipient record (or null if not found) |
const explanation = await notify.explain({
recipientId: "user_123",
notificationId: "comment_mentioned",
payload: { actorName: "Rey", postUrl: "/posts/42" },
})
// Check why email didn't fire:
console.log(explanation.channels.email)
// → { outcome: "skipped", reason: "preferences_disabled" }
// Check the full preference trail:
console.log(explanation.preferences)
// → { global: { email: true }, recipient: { email: false }, resolved: { email: false } }
// Check pipeline stages:
console.log(explanation.wouldRateLimit) // false
console.log(explanation.quietHours) // { active: true, resumesAt: "2026-06-28T08:00:00Z" }notify.check(input)
Alias for explain(). Identical behavior and return type.
notify.upsertRecipient(input)
Create or update a recipient. Fields you omit are left unchanged on update. Always call before the first send() to a new user.
| Field | Required | Purpose |
|---|---|---|
id | Yes | Your user ID — used as recipientId in sends |
email | No | Required for email channel delivery |
phone | No | Required for SMS channel delivery |
name | No | Display name (available in templates via recipient) |
quietHours | No | Set to { start, end, timezone } or null to clear |
tenantId | No | Scopes this recipient to a tenant |
workspaceId | No | Scopes this recipient to a workspace |
notify.preferences
| Method | Parameters | Returns |
|---|---|---|
.list(recipientId, scope?) | Recipient ID + optional tenant/workspace | RecipientPreference[] |
.get({recipientId, notificationId, ...scope}) | Specific notification lookup | RecipientPreference | null |
.update({recipientId, notificationId, channels, ...scope}) | Channel map like { email: false } | RecipientPreference |
.explain({recipientId, notificationId, ...scope}) | Same as get | PreferenceExplanation with full resolution trail |
notify.inbox
| Method | Returns | Notes |
|---|---|---|
.list(recipientId, scope?, filter?, limit?) | InboxItem[] | Filter: { archived?: boolean } |
.markReadForRecipient(itemId, recipientId, scope?) | InboxItem | Sets readAt |
.markAllRead(recipientId, scope?) | number | Count of items marked |
.archiveForRecipient(itemId, recipientId, scope?) | InboxItem | Sets archivedAt |
.unarchiveForRecipient(itemId, recipientId, scope?) | InboxItem | Clears archivedAt |
.deleteForRecipient(itemId, recipientId, scope?) | void | Permanent — cannot undo |
.unreadCount(recipientId, scope?) | number | Count of items without readAt |
notify.deliveries
| Method | Returns | Notes |
|---|---|---|
.list(recipientId?, scope?, limit?) | DeliveryRecord[] | All params optional — omit for global list |
notify.timeline
| Method | Returns | Notes |
|---|---|---|
notify.timeline(recordId) | TimelineEvent[] | All events for a notification send |
notify.timeline(recordId, { deliveryId }) | TimelineEvent[] | Events for a specific delivery only |
notify.timeline(recordId, { limit }) | TimelineEvent[] | Cap returned events |
notify.pruneTimeline(olderThan?) | number | Deletes old events. Uses timelineRetentionMs if omitted. |
Lifecycle methods
| Method | When to call | Returns |
|---|---|---|
notify.drain() | Before process shutdown — waits for in-flight queue jobs | void |
notify.flushScheduledSends() | On a cron/interval — fires deferred quiet-hours sends | SendResult[] |
notify.flushDigests() | On a cron/interval — flushes expired digest buckets | SendResult[] |
notify.redactPayload(notificationId, payload) | When piping payload to external systems | Payload copy with redact fields masked |
Which lifecycle methods do I need?
| Deployment | drain() | flushScheduledSends() | flushDigests() |
|---|---|---|---|
| Long-running server (Node.js, Next.js) | Call on SIGTERM | Not needed — internal timers handle it | Not needed — internal timers handle it |
| Serverless (Vercel, Lambda) | Call before response returns | Call on a cron route (e.g. every 1 min) | Call on a cron route (e.g. every 1 min) |
| External queue (BullMQ, SQS) | Not needed — queue manages jobs | Call from a scheduled worker | Call from a scheduled worker |
| Tests | Call in afterAll() | Call to assert deferred sends fired | Call to assert digest emails rendered |
setTimeoutQueue(), flushes happen automatically via internal timers. You only need to call these manually with external queues (BullMQ, SQS) or in serverless environments.Definition metadata
| Property | Type | Use for |
|---|---|---|
notify.definitions | NotificationDefinition[] | Accessing full definitions (channels, payload schemas, config) |
notify.notificationMetadata | NotificationMeta[] | Driving admin UIs — safe subset with id, channels, category, description |
Error handling
NotifyKit throws specific error classes you can catch and handle. Provider failures during delivery are not thrown — they're captured in the result and retried. Only programming errors and infrastructure failures throw:
| Error | Thrown when | How to handle |
|---|---|---|
NotifyKitValidationError | Payload fails schema validation | Fix the payload — check error.fields for which fields failed |
NotifyKitNotFoundError | Recipient doesn't exist in the database | Call upsertRecipient() before send() |
NotifyKitConfigError | Invalid config at startup (missing adapter, bad notification def) | Fix configuration — this is a fatal startup error |
DatabaseError | Underlying database operation failed (connection lost, constraint violation) | Retry the operation or check database connectivity |
import { NotifyKitValidationError, NotifyKitNotFoundError } from "@notifykitjs/core"
try {
const result = await notify.send({
recipientId: userId,
notificationId: "comment_mentioned",
payload: data,
})
// result.deliveries may contain failed deliveries — those are NOT errors
// They were attempted, retried, and captured in the result
} catch (err) {
if (err instanceof NotifyKitValidationError) {
// Payload was wrong — log which fields and fix upstream
console.error("Bad payload:", err.fields)
} else if (err instanceof NotifyKitNotFoundError) {
// Recipient doesn't exist yet — create them and retry
await notify.upsertRecipient({ id: userId, email: userEmail })
await notify.send({ ... })
} else {
// Infrastructure error (DB down, connection refused)
throw err
}
}Throws (programming/infra error)
Bad payload, missing recipient, DB failure. You must fix the root cause.
Returns in result (delivery failure)
Provider timeout, 500 from Resend, network blip. Retried automatically. Check result.deliveries.
retry config. After all attempts fail, it shows up in result.deliveries with status: "failed" and fires the delivery.failed hook. Your send() call itself never rejects because of a provider error.Common workflows
Individual methods are documented above. Here's how they compose for the tasks you'll actually build:
Onboard a new user
// Create the recipient (idempotent — safe to call on every login)
await notify.upsertRecipient({
id: user.id,
email: user.email,
name: user.name,
tenantId: user.orgId,
})
// 2. Set their default preferences (optional — skip if you want all-on)
await notify.preferences.update({
recipientId: user.id,
notificationId: "marketing_digest",
channels: { email: false },
tenantId: user.orgId,
})
// 3. Send the welcome notification
const result = await notify.send({
recipientId: user.id,
notificationId: "welcome",
payload: { name: user.name },
tenantId: user.orgId,
})Deactivate a user
const recipientId = user.id
const tenantId = user.orgId
// 1. Drain any in-flight sends so nothing new arrives mid-cleanup
await notify.drain()
// 2. Clear their inbox (iterate and delete)
const items = await notify.inbox.list(recipientId, { tenantId })
await Promise.all(
items.map(item => notify.inbox.deleteForRecipient(item.id, recipientId, { tenantId }))
)
// 3. Opt them out of all channels to prevent future sends
// (belt-and-suspenders — even if your app stops calling send() for this user,
// scheduled digests or queued sends might still fire)
const metadata = notify.notificationMetadata
await Promise.all(
metadata.map(n =>
notify.preferences.update({
recipientId,
notificationId: n.id,
channels: { email: false, sms: false, inbox: false, webhook: false },
tenantId,
})
)
)
// 4. Prune their timeline data (GDPR / data minimization)
await notify.pruneTimeline(new Date()) // prune all existing timeline eventsdrain() first — otherwise a queued send could deliver between steps 2 and 3, creating a new inbox item for a "deleted" user. If you only need soft-delete (user might return), skip steps 2 and 4 and just opt them out.Diagnose a failed delivery
const recipientId = "user_abc"
// 1. Find recent deliveries for this user
const deliveries = await notify.deliveries.list(recipientId)
const failed = deliveries.filter(d => d.status === "failed" || d.status === "skipped")
// 2. Check the timeline for the specific send
if (failed.length > 0) {
const events = await notify.timeline(failed[0].notificationRecordId)
// events show: created → channel_evaluated → delivery_attempted → failed (with error)
}
// 3. Dry-run the same send to see what would happen now
const explanation = await notify.explain({
recipientId,
notificationId: "comment_mentioned",
payload: { actorName: "Test", postTitle: "Test", postUrl: "#" },
})
// explanation shows current preferences, quiet hours, provider stateBuild a notification preferences UI
async function getPreferencesPageData(recipientId: string, tenantId: string) {
const [prefs, metadata] = await Promise.all([
notify.preferences.list(recipientId, { tenantId }),
notify.notificationMetadata, // safe subset: id, description, category, channels
])
// Group by category for the UI
const grouped = Object.groupBy(metadata, m => m.category ?? "general")
// Merge saved preferences with available notifications
return Object.entries(grouped).map(([category, notifications]) => ({
category,
notifications: notifications!.map(n => ({
id: n.id,
description: n.description,
channels: n.channels,
preferences: prefs.find(p => p.notificationId === n.id)?.channels ?? {},
})),
}))
}Graceful shutdown (serverless / edge)
export async function handler(req: Request) {
const result = await notify.send({ ... })
// Flush deferred sends (quiet hours) and digests before shutdown
await Promise.all([
notify.flushScheduledSends(),
notify.flushDigests(),
])
// Wait for any in-flight queue jobs to complete
await notify.drain()
return Response.json(result)
}| Task | Methods used | Order matters? |
|---|---|---|
| Onboard user | upsertRecipient → preferences.update → send | Yes — recipient must exist before send |
| Deactivate user | drain → inbox.deleteForRecipient → preferences.update → pruneTimeline | Yes — drain first to prevent race |
| Debug failed send | deliveries.list → timeline → explain | No — each gives independent context |
| Preferences UI | preferences.list + notificationMetadata | No — fetch in parallel |
| Clean shutdown | flushScheduledSends + flushDigests → drain | Flush before drain |
explain() liberally in development. It's a zero-side-effect dry run that shows exactly what would happen — which channels fire, which get skipped and why, and how preferences resolve. Wire it into your admin panel or support tooling.Cheat sheet
Every method on a single screen. Copy the signature, fill in your values. Grouped by what you're trying to do:
// ─── Send ────────────────────────────────────────────────────────────
await notify.send({ recipientId, notificationId, payload })
await notify.send({ recipientId, notificationId, payload, tenantId, idempotencyKey, dedupeKey, dedupeWindowMs })
await notify.send({ recipientId, notificationId, payload, dryRun: true }) // → DeliveryExplanation
await notify.explain({ recipientId, notificationId, payload }) // same as dryRun: true
// ─── Recipients ──────────────────────────────────────────────────────
await notify.upsertRecipient({ id, email?, phone?, name?, tenantId?, quietHours? })
// ─── Inbox (server-side) ─────────────────────────────────────────────
await notify.inbox.list(recipientId, { tenantId }?, { archived }?, limit?)
await notify.inbox.unreadCount(recipientId, { tenantId }?)
await notify.inbox.markReadForRecipient(itemId, recipientId, { tenantId }?)
await notify.inbox.markAllRead(recipientId, { tenantId }?)
await notify.inbox.archiveForRecipient(itemId, recipientId, { tenantId }?)
await notify.inbox.unarchiveForRecipient(itemId, recipientId, { tenantId }?)
await notify.inbox.deleteForRecipient(itemId, recipientId, { tenantId }?)
// ─── Preferences ─────────────────────────────────────────────────────
await notify.preferences.list(recipientId, { tenantId }?)
await notify.preferences.get({ recipientId, notificationId, tenantId? })
await notify.preferences.update({ recipientId, notificationId, channels: { email: false }, tenantId? })
await notify.preferences.explain({ recipientId, notificationId, tenantId? })
// ─── Debugging ───────────────────────────────────────────────────────
await notify.timeline(notificationRecordId)
await notify.timeline(notificationRecordId, { deliveryId })
await notify.deliveries.list(recipientId?, { tenantId }?, limit?)
await notify.pruneTimeline(olderThan?)
notify.redactPayload(notificationId, payload)
// ─── Lifecycle ───────────────────────────────────────────────────────
await notify.drain() // wait for in-flight jobs
await notify.flushScheduledSends() // fire deferred quiet-hours sends
await notify.flushDigests() // flush expired digest buckets
// ─── Instance properties ─────────────────────────────────────────────
notify.definitions // NotificationDefinition[]
notify.notificationMetadata // NotificationMeta[] (safe for client)| I want to... | Method | Returns |
|---|---|---|
| Send a notification | send() | SendResult |
| Preview what would happen | explain() | DeliveryExplanation |
| Create/update a user | upsertRecipient() | Recipient |
| Read a user's inbox | inbox.list() | InboxItem[] |
| Get unread badge count | inbox.unreadCount() | number |
| See why a send was skipped | timeline() | TimelineEvent[] |
| Check a user's opt-outs | preferences.list() | RecipientPreference[] |
| See which layer blocked a channel | preferences.explain() | PreferenceExplanation |
| Opt a user out of a channel | preferences.update() | RecipientPreference |
| Find failed deliveries | deliveries.list() | DeliveryRecord[] |
| Clean up old debug data | pruneTimeline() | number (deleted count) |
| Shut down cleanly | drain() | void |