BackReference

API reference

Every method on the object createNotifyKit() returns.

createNotifyKit()

Builds the instance everything else hangs off. Only notifications and database are required. The configuration is validated on the spot: any error throws a NotifyKitError with code INVALID_CONFIG listing every problem at once, and warnings are logged.

lib/notifykit.ts
import { createNotifyKit, setTimeoutQueue } from "@notifykitjs/core"
import { drizzlePostgresAdapter } from "@notifykitjs/drizzle"
import { resendProvider } from "@notifykitjs/resend"

export const notify = createNotifyKit({
	notifications: [commentMentioned, orderShipped] as const,
	database: drizzlePostgresAdapter(db),
	providers: { email: resendProvider({ apiKey, from }) },
	queue: setTimeoutQueue(),
	retry: { maxAttempts: 5 },
	unsubscribe: {
		secret: process.env.NOTIFYKIT_SECRET!,
		baseUrl: "https://app.com/api/notifykit",
	},
})
OptionDefaultWhat it sets
notificationsRequiredYour definitions from notification()
databaseRequiredmemoryAdapter() or a Drizzle adapter. See Database adapters
providersNone{ email?, sms?, webhook? }. A channel with no provider is a startup error
queueinlineQueue()Where deliveries run. inlineQueue() delivers before send() returns; setTimeoutQueue() delivers right after
retry3 attempts{ maxAttempts, delayMs(attempt) }. Default waits are 0, 250 ms, then 1 s
unsubscribeOff{ secret, baseUrl }. Enables {{_unsubscribeUrl}} in email templates. See Preferences
defaultsAll on{ channels?, categories? }: the lowest-priority preference layer
tenantDefaultsNone(tenantId) => ChannelPreferenceMap | null, sync or async
realtimeNoneA realtime adapter. See Realtime
onNoneLifecycle hooks. See Hooks
onHookErrorRethrowCalled when a hook throws. Return normally to swallow the error
onTimelineErrorconsole.errorCalled when writing a timeline event fails
idempotencyKeyTtlMs24 hoursHow long an idempotency key is remembered
timelineRetentionMs7 daysTimeline events older than this are pruned now and then. 0 turns pruning off
mode"production""development" sandboxes outbound messages. See Dev mode
devNone{ allowlist?, subjectPrefix?, logPreviews?, maxCaptured? }, used in development mode

send()

const result = await notify.send({
	recipientId: "user_123",
	notificationId: "comment_mentioned",   // one of your ids
	payload: { actorName: "Rey", postTitle: "Launch Plan", postUrl: "/posts/42" },
	tenantId: "org_1",                     // or organizationId; optional
	workspaceId: "ws_1",                   // optional
	idempotencyKey: "comment_981",         // optional, up to 256 characters
	dedupeKey: "post_42", dedupeWindowMs: 60_000, // optional, together
})

notificationId narrows the type of payload, so a wrong field is a compile error. send() is for trusted server code: it uses recipientId as given, with no check of who is asking. Browser code goes through the handler routes, which get the user from your identify().

It resolves to a SendResult:

FieldHolds
notificationThe stored NotificationRecord. null only when the send went into a digest
inboxItemsInbox items written by this send
deliveriesEvery DeliveryRecord, including ones with status skipped
skipped{ channel, reason, details? } for each channel that did not go out. See skip reasons
deferredChannelsChannels held until quiet hours end
digestedtrue if the send joined a digest instead of going out
rateLimitedtrue if a rate limit stopped it
idempotenttrue if this is the stored result of an earlier send with the same idempotency key
skippedChannelsDeprecated: channel names only. Use skipped

Pass dryRun: true and send() returns a DeliveryExplanation instead, the same as explain().

When send() throws

A provider that fails does not make send() reject. The delivery is retried, then marked failed, the delivery.failed hook fires, and any fallback runs. send() throws for mistakes in the call itself:

ErrorcodeCause
PayloadValidationErrorPAYLOAD_VALIDATION_ERRORThe payload does not match the schema. error.fields lists each field
NotifyKitErrorUNKNOWN_RECIPIENTNo recipient with that id. Call upsertRecipient() first
NotifyKitErrorUNKNOWN_NOTIFICATIONNo definition with that id
NotifyKitErrorINVALID_INPUTA bad argument, such as a dedupeKey without dedupeWindowMs
NotifyKitErrorTENANT_MISMATCH, WORKSPACE_MISMATCHThe scope does not match the recipient's

PayloadValidationError extends NotifyKitError. Every NotifyKitError carries code, and where relevant notificationId, channel, recipientId, field, and a fix suggestion.

explain() and check()

const plan = await notify.explain({ recipientId, notificationId, payload })
plan.channels.map((c) => `${c.channel}: ${c.outcome} (${c.reason})`)
// ["inbox: deliver (...)", "email: disabled (...)"]

Takes the same input as send() and says what would happen, without writing or sending anything. check() is the same method under another name. The result, a DeliveryExplanation, has an outcome per channel plus the idempotency, dedupe, rate-limit, digest, and quiet-hours state. See Explain and dry run and the type.

Recipients

MethodReturnsNotes
upsertRecipient(input)RecipientCreates or updates. Fields you leave out keep their value; quietHours: null clears quiet hours
getRecipient(id)Recipient | nullFor trusted server code
await notify.upsertRecipient({
	id: user.id,
	email: user.email,         // needed for email
	phone: user.phone,         // needed for SMS
	name: user.name,
	tenantId: user.orgId,      // optional scope
	quietHours: { start: "22:00", end: "08:00", timezone: "America/Toronto" },
})

A recipient’s tenant and workspace are fixed once set: upserting the same id with a different one throws TENANT_REASSIGNMENT or WORKSPACE_REASSIGNMENT. Calling it on every sign-in is safe.

notify.inbox

Server-side access to inbox items. Every method takes the recipient explicitly, plus an optional scope ({ tenantId?, organizationId?, workspaceId? }). Methods that act on one item check it belongs to that recipient and scope.

MethodReturns
list(recipientId, scope?, filter?, limit?)InboxItem[], newest first. Archived items are left out unless filter is { archived: true }, which returns only archived ones. No limit by default
unreadCount(recipientId, scope?)number of unread, unarchived items
markReadForRecipient(id, recipientId, scope?){ status: "marked", item }, or not_found / forbidden
markAllRead(recipientId, scope?)The number of items marked
archiveForRecipient(id, recipientId, scope?){ status: "ok", item }, or not_found / forbidden
unarchiveForRecipient(id, recipientId, scope?)Same as archive
deleteForRecipient(id, recipientId, scope?){ status: "deleted" }, or not_found / forbidden. Permanent

Each change publishes a realtime event if a realtime adapter is set, and fires the matching inbox.* hook.

notify.preferences

Preferences are channel maps such as { email: false }, stored at three levels: per notification, per category, and global. Every input also accepts tenantId, organizationId, and workspaceId. See Preferences for how the levels combine.

MethodReturns
get({ recipientId, notificationId })RecipientPreference | null
list(recipientId, scope?)Per-notification preferences. Category and global rows are left out
update({ recipientId, notificationId, channels })The saved RecipientPreference. Channels you omit keep their value
getGlobal({ recipientId })RecipientPreference | null
updateGlobal({ recipientId, channels })Applies to every notification
getCategory({ recipientId, category })RecipientPreference | null
listCategories(recipientId, scope?)Every category preference for the recipient
updateCategory({ recipientId, category, channels })Throws UNKNOWN_CATEGORY if no definition uses the category
explain({ recipientId, notificationId })A PreferenceExplanation: each channel, whether it is allowed, and every layer that was consulted

notify.deliveries

deliveries.list(recipientId?, scope?, limit?) returns DeliveryRecord[], including the rendered subject and body. Leave out recipientId to list everyone’s. It is for trusted code only; the handler’s GET /deliveries route checks permissions and removes the message content.

Timeline and Redaction

MethodReturns
timeline(notificationRecordId, options?)TimelineEvent[] in order. options: { deliveryId?, limit? }, limit 1000 by default. See Timeline
pruneTimeline(olderThan?)How many events were deleted. Without a date it uses timelineRetentionMs, and does nothing if that is 0
redactPayload(notificationId, payload)A copy with the definition’s redact fields replaced by "[REDACTED]"

Lifecycle

Digest windows and quiet-hours deferrals run on timers inside the process. These methods let you wait for, force, or recover that work. All resolve to nothing.

MethodDoes
drain()Waits for queued deliveries and their retries, and for digest windows and scheduled sends to fire on their own time
close()Cancels pending digest and quiet-hours timers, discarding that work, then drains the queue. No timers are left afterwards
flushDigests()Sends every open digest now
flushScheduledSends(options?)Delivers held quiet-hours sends. By default all of them (force: true); with { force: false } only those already due
recoverScheduledSends()Same as flushScheduledSends({ force: false }). Call it on boot to pick up sends a crash left behind
processDeliveryJob(job)Runs one serialized DeliveryJob. For a worker fed by your own durable queue

Properties

PropertyHolds
definitionsThe definitions you passed in, read-only
notificationMetadataA client-safe summary of each: id, channels, payload, and description, category, version, required, defaultChannels, classification when set. No templates
realtimeThe realtime adapter, or undefined
isDevtrue in development mode
capturedSandboxed sends in development mode. See Testing

Other Exports

Everything else @notifykitjs/core exports, by job:

  • Defining: notification(), channel.inbox(), .email(), .sms(), .webhook(). Schema helpers zodPayload(), valibotPayload(), and arktypePayload() come from @notifykitjs/core/zod, /valibot, and /arktype. See Defining.
  • HTTP: createHandler(notify, options). See Handler routes.
  • Storage and transport: memoryAdapter(), memoryRealtimeAdapter(), inlineQueue(), setTimeoutQueue({ onError? }), defaultRetryPolicy.
  • Providers: webhookProvider({ secret?, timeoutMs?, fetch? }), verifyWebhookSignature(body, signatureHeader, secret), and the fakes fakeEmailProvider(), fakeSmsProvider(), fakeWebhookProvider(). See Providers.
  • Unsubscribe: signUnsubscribeToken(claims, secret) and verifyUnsubscribeToken(token, secret), where secret can be an array to accept old secrets while you rotate.
  • Validation: validateConfig(input) returns the startup checks as ValidationIssue[] without throwing, and formatValidationIssues(issues) prints them. Also on @notifykitjs/core/validate.
  • Utilities: renderTemplate(), extractTemplateVars(), redactPayload(payload, fields), createId(prefix), the error classes NotifyKitError and PayloadValidationError, and the lists SKIP_REASONS and TIMELINE_EVENT_TYPES.
  • Preference keys: GLOBAL_PREFERENCE_KEY, categoryPreferenceKey(category), isCategoryPreferenceKey(), isSyntheticPreferenceKey(), and parseCategoryFromKey(), for code that reads preference rows directly.