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.

CategoryMethods
Sendingsend(), explain(), check()
RecipientsupsertRecipient()
Inboxinbox.list(), inbox.markReadForRecipient(), inbox.markAllRead(), inbox.archiveForRecipient(), inbox.deleteForRecipient()
Preferencespreferences.list(), preferences.get(), preferences.update(), preferences.explain()
Deliveriesdeliveries.list()
Debuggingtimeline(), pruneTimeline(), redactPayload()
Lifecycledrain(), flushScheduledSends(), flushDigests()

createNotifyKit(config)

Creates the NotifyKit instance. All options except notifications and database are optional:

OptionRequiredPurpose
notificationsYesArray of notification definitions (use as const for type inference)
databaseYesAdapter — memoryAdapter(), drizzleSqliteAdapter(), or drizzlePostgresAdapter()
providersNo{ email?, sms?, webhook? } — channel providers
queueNoDelivery queue — inlineQueue() (default) or setTimeoutQueue()
retryNo{ maxAttempts, delayMs } — retry policy for failed deliveries
realtimeNoRealtime adapter for SSE push
unsubscribeNo{ secret, baseUrl } — enables HMAC unsubscribe links
defaultsNo{ channels } — app-wide default channel preferences
tenantDefaultsNo(tenantId) => ChannelMap | null — per-tenant overrides
onNoHook handlers — see Hooks
idempotencyKeyTtlMsNoTTL for idempotency keys (default: 24h)
timelineRetentionMsNoAuto-prune window for pruneTimeline()
devModeNotrue to block all real sends in development
lib/notifykit.ts
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 fieldTypeWhat it tells you
channelsRecord<string, ChannelExplanation>Per-channel outcome: would_deliver, skipped (with reason), or deferred
preferencesPreferenceResolutionFull resolution trail — global defaults → tenant → category → notification → recipient override
wouldDeduplicatebooleanWhether the dedup key has been seen within its window
wouldRateLimitbooleanWhether the send would exceed the rate limit threshold
wouldDigestbooleanWhether 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
recipientRecipient | nullThe 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" }
Wire explain() into your admin panel. Support teams can paste a user ID and notification ID to see exactly why a notification would or wouldn't deliver — without triggering any actual send.

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.

FieldRequiredPurpose
idYesYour user ID — used as recipientId in sends
emailNoRequired for email channel delivery
phoneNoRequired for SMS channel delivery
nameNoDisplay name (available in templates via recipient)
quietHoursNoSet to { start, end, timezone } or null to clear
tenantIdNoScopes this recipient to a tenant
workspaceIdNoScopes this recipient to a workspace

notify.preferences

MethodParametersReturns
.list(recipientId, scope?)Recipient ID + optional tenant/workspaceRecipientPreference[]
.get({recipientId, notificationId, ...scope})Specific notification lookupRecipientPreference | null
.update({recipientId, notificationId, channels, ...scope})Channel map like { email: false }RecipientPreference
.explain({recipientId, notificationId, ...scope})Same as getPreferenceExplanation with full resolution trail

notify.inbox

MethodReturnsNotes
.list(recipientId, scope?, filter?, limit?)InboxItem[]Filter: { archived?: boolean }
.markReadForRecipient(itemId, recipientId, scope?)InboxItemSets readAt
.markAllRead(recipientId, scope?)numberCount of items marked
.archiveForRecipient(itemId, recipientId, scope?)InboxItemSets archivedAt
.unarchiveForRecipient(itemId, recipientId, scope?)InboxItemClears archivedAt
.deleteForRecipient(itemId, recipientId, scope?)voidPermanent — cannot undo
.unreadCount(recipientId, scope?)numberCount of items without readAt

notify.deliveries

MethodReturnsNotes
.list(recipientId?, scope?, limit?)DeliveryRecord[]All params optional — omit for global list

notify.timeline

MethodReturnsNotes
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?)numberDeletes old events. Uses timelineRetentionMs if omitted.

Lifecycle methods

MethodWhen to callReturns
notify.drain()Before process shutdown — waits for in-flight queue jobsvoid
notify.flushScheduledSends()On a cron/interval — fires deferred quiet-hours sendsSendResult[]
notify.flushDigests()On a cron/interval — flushes expired digest bucketsSendResult[]
notify.redactPayload(notificationId, payload)When piping payload to external systemsPayload copy with redact fields masked

Which lifecycle methods do I need?

Deploymentdrain()flushScheduledSends()flushDigests()
Long-running server (Node.js, Next.js)Call on SIGTERMNot needed — internal timers handle itNot needed — internal timers handle it
Serverless (Vercel, Lambda)Call before response returnsCall 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 jobsCall from a scheduled workerCall from a scheduled worker
TestsCall in afterAll()Call to assert deferred sends firedCall to assert digest emails rendered
With 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

PropertyTypeUse for
notify.definitionsNotificationDefinition[]Accessing full definitions (channels, payload schemas, config)
notify.notificationMetadataNotificationMeta[]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:

ErrorThrown whenHow to handle
NotifyKitValidationErrorPayload fails schema validationFix the payload — check error.fields for which fields failed
NotifyKitNotFoundErrorRecipient doesn't exist in the databaseCall upsertRecipient() before send()
NotifyKitConfigErrorInvalid config at startup (missing adapter, bad notification def)Fix configuration — this is a fatal startup error
DatabaseErrorUnderlying database operation failed (connection lost, constraint violation)Retry the operation or check database connectivity
lib/send-notification.ts
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.

Provider failures don't throw. If Resend returns a 500, the delivery is retried per your 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

lib/onboard-user.ts
// 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

lib/deactivate-user.ts
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 events
Order matters. Call drain() 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

scripts/diagnose-delivery.ts
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 state

Build a notification preferences UI

app/settings/notifications/page.tsx
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)

app/api/send/route.ts
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)
}
TaskMethods usedOrder matters?
Onboard userupsertRecipientpreferences.updatesendYes — recipient must exist before send
Deactivate userdraininbox.deleteForRecipientpreferences.updatepruneTimelineYes — drain first to prevent race
Debug failed senddeliveries.listtimelineexplainNo — each gives independent context
Preferences UIpreferences.list + notificationMetadataNo — fetch in parallel
Clean shutdownflushScheduledSends + flushDigestsdrainFlush before drain
Use 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...MethodReturns
Send a notificationsend()SendResult
Preview what would happenexplain()DeliveryExplanation
Create/update a userupsertRecipient()Recipient
Read a user's inboxinbox.list()InboxItem[]
Get unread badge countinbox.unreadCount()number
See why a send was skippedtimeline()TimelineEvent[]
Check a user's opt-outspreferences.list()RecipientPreference[]
See which layer blocked a channelpreferences.explain()PreferenceExplanation
Opt a user out of a channelpreferences.update()RecipientPreference
Find failed deliveriesdeliveries.list()DeliveryRecord[]
Clean up old debug datapruneTimeline()number (deleted count)
Shut down cleanlydrain()void
Bookmark this section. The cheat sheet has every method in one block — use it as a quick reminder when you know the method name but need the exact parameter order. For detailed behavior, scroll to the full section above or check the linked docs page.