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.
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",
},
})| Option | Default | What it sets |
|---|---|---|
notifications | Required | Your definitions from notification() |
database | Required | memoryAdapter() or a Drizzle adapter. See Database adapters |
providers | None | { email?, sms?, webhook? }. A channel with no provider is a startup error |
queue | inlineQueue() | Where deliveries run. inlineQueue() delivers before send() returns; setTimeoutQueue() delivers right after |
retry | 3 attempts | { maxAttempts, delayMs(attempt) }. Default waits are 0, 250 ms, then 1 s |
unsubscribe | Off | { secret, baseUrl }. Enables {{_unsubscribeUrl}} in email templates. See Preferences |
defaults | All on | { channels?, categories? }: the lowest-priority preference layer |
tenantDefaults | None | (tenantId) => ChannelPreferenceMap | null, sync or async |
realtime | None | A realtime adapter. See Realtime |
on | None | Lifecycle hooks. See Hooks |
onHookError | Rethrow | Called when a hook throws. Return normally to swallow the error |
onTimelineError | console.error | Called when writing a timeline event fails |
idempotencyKeyTtlMs | 24 hours | How long an idempotency key is remembered |
timelineRetentionMs | 7 days | Timeline events older than this are pruned now and then. 0 turns pruning off |
mode | "production" | "development" sandboxes outbound messages. See Dev mode |
dev | None | { 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:
| Field | Holds |
|---|---|
notification | The stored NotificationRecord. null only when the send went into a digest |
inboxItems | Inbox items written by this send |
deliveries | Every DeliveryRecord, including ones with status skipped |
skipped | { channel, reason, details? } for each channel that did not go out. See skip reasons |
deferredChannels | Channels held until quiet hours end |
digested | true if the send joined a digest instead of going out |
rateLimited | true if a rate limit stopped it |
idempotent | true if this is the stored result of an earlier send with the same idempotency key |
skippedChannels | Deprecated: 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:
| Error | code | Cause |
|---|---|---|
PayloadValidationError | PAYLOAD_VALIDATION_ERROR | The payload does not match the schema. error.fields lists each field |
NotifyKitError | UNKNOWN_RECIPIENT | No recipient with that id. Call upsertRecipient() first |
NotifyKitError | UNKNOWN_NOTIFICATION | No definition with that id |
NotifyKitError | INVALID_INPUT | A bad argument, such as a dedupeKey without dedupeWindowMs |
NotifyKitError | TENANT_MISMATCH, WORKSPACE_MISMATCH | The 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
| Method | Returns | Notes |
|---|---|---|
upsertRecipient(input) | Recipient | Creates or updates. Fields you leave out keep their value; quietHours: null clears quiet hours |
getRecipient(id) | Recipient | null | For 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.
| Method | Returns |
|---|---|
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.
| Method | Returns |
|---|---|
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
| Method | Returns |
|---|---|
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.
| Method | Does |
|---|---|
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
| Property | Holds |
|---|---|
definitions | The definitions you passed in, read-only |
notificationMetadata | A client-safe summary of each: id, channels, payload, and description, category, version, required, defaultChannels, classification when set. No templates |
realtime | The realtime adapter, or undefined |
isDev | true in development mode |
captured | Sandboxed sends in development mode. See Testing |
Other Exports
Everything else @notifykitjs/core exports, by job:
- Defining:
notification(),channel.inbox(),.email(),.sms(),.webhook(). Schema helperszodPayload(),valibotPayload(), andarktypePayload()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 fakesfakeEmailProvider(),fakeSmsProvider(),fakeWebhookProvider(). See Providers. - Unsubscribe:
signUnsubscribeToken(claims, secret)andverifyUnsubscribeToken(token, secret), wheresecretcan be an array to accept old secrets while you rotate. - Validation:
validateConfig(input)returns the startup checks asValidationIssue[]without throwing, andformatValidationIssues(issues)prints them. Also on@notifykitjs/core/validate. - Utilities:
renderTemplate(),extractTemplateVars(),redactPayload(payload, fields),createId(prefix), the error classesNotifyKitErrorandPayloadValidationError, and the listsSKIP_REASONSandTIMELINE_EVENT_TYPES. - Preference keys:
GLOBAL_PREFERENCE_KEY,categoryPreferenceKey(category),isCategoryPreferenceKey(),isSyntheticPreferenceKey(), andparseCategoryFromKey(), for code that reads preference rows directly.