TypeScript types
The exported types: inferred payloads, results, and delivery statuses.
Every type on this page is exported from @notifykitjs/core. Import them with import type; none of them exist at runtime.
Inference
A payload schema is a plain object mapping field names to "string", "number", or "boolean". Three generic types turn your definitions into the types send() checks against:
| Type | Gives you |
|---|---|
InferSchema<S> | The TypeScript shape of a payload schema: { actorName: "string" } becomes { actorName: string } |
NotificationIds<T> | The union of ids in a definitions tuple |
SendInput<T> | The union of valid send() inputs, one member per notification, each with its own payload type |
import type { InferSchema, NotificationIds, SendInput } from "@notifykitjs/core"
import { commentMentioned, orderShipped } from "./notifications"
const all = [commentMentioned, orderShipped] as const
type All = typeof all
type Id = NotificationIds<All> // "comment_mentioned" | "order_shipped"
type Job = SendInput<All> // a queued send, fully typed
type Mention = InferSchema<typeof commentMentioned.payload>
// { actorName: string; postTitle: string; postUrl: string }SendInput is the one to use when you wrap send(), for instance to put sends on your own queue: the notification id and its payload stay tied together. If you use a Zod, Valibot, or ArkType schema, only its top-level string, number, and boolean fields appear in the inferred payload type.
Definitions
type NotificationDefinition<Id extends string, S extends PayloadSchema> = {
id: Id
payload: S
channels: ChannelConfig[]
digest?: DigestConfig
// { max, windowMs, scope?: "recipient" | "global" }
rateLimit?: RateLimitConfig
fallback?: InboxChannelConfig | FallbackRule[]
required?: boolean
defaultChannels?: ChannelPreferenceMap
description?: string
category?: string
classification?: "transactional" | "product" | "marketing"
version?: number
redact?: readonly string[]
validate?: (payload: unknown) => Record<string, unknown>
}You rarely write this type yourself; notification() returns it. Each field is explained on Defining notifications.
Channel configs
type InboxChannelConfig = {
type: "inbox"
title: string
body?: string
actionUrl?: string
}
type EmailChannelConfig = {
type: "email"
subject: string
body: string
html?: boolean
}
type SmsChannelConfig = { type: "sms"; body: string }
type WebhookChannelConfig = {
type: "webhook"
url: string
headers?: Record<string, string>
}
type ChannelConfig =
| InboxChannelConfig
| EmailChannelConfig
| SmsChannelConfig
| WebhookChannelConfig
type ChannelType = ChannelConfig["type"] // "inbox" | "email" | "sms" | "webhook"
type ChannelPreferenceMap = Partial<Record<ChannelType, boolean>>
type FallbackRule = {
if: "channel.failed" | "missing_address" | "skipped" // FallbackTrigger
then: ChannelConfig
from?: ChannelType
}DigestConfig<S> is { windowMs, key?, render }, with key and render typed against the payload. See Digests.
Stored Records
What the database adapter stores, and what the API hands back.
Recipient
type Recipient = {
id: string
tenantId?: string
workspaceId?: string
email?: string
phone?: string
name?: string
quietHours?: QuietHours | null
createdAt: Date
updatedAt: Date
}
type QuietHours = {
start: string // "HH:MM", 24-hour
end: string // may be earlier than start, to cross midnight
timezone?: string // IANA name; "UTC" if omitted
}
type SecurityScope = {
tenantId?: string
organizationId?: string // alias; tenantId wins when both are set
workspaceId?: string
}UpsertRecipientInput is SecurityScope plus id, email, phone, name, and quietHours.
NotificationRecord
One per send() that was not digested, even when every channel was skipped.
type NotificationRecord = {
id: string
recipientId: string
tenantId?: string
workspaceId?: string
notificationId: string
payload: Record<string, unknown>
payloadSchema?: Record<string, string> // the schema at send time
definitionVersion?: number // the definition's version at send time
idempotencyKey?: string
createdAt: Date
}InboxItem
type InboxItem = {
id: string
notificationRecordId: string
recipientId: string
tenantId?: string
workspaceId?: string
notificationId: string
title: string
body?: string
actionUrl?: string
readAt?: Date | null
archivedAt?: Date | null
createdAt: Date
}DeliveryRecord
One per channel per send, for email, SMS, and webhooks, and for inbox when it was skipped.
type DeliveryRecord = {
id: string
notificationRecordId: string
recipientId: string
tenantId?: string
workspaceId?: string
notificationId: string
channel: "email" | "webhook" | "sms" | "inbox" // DeliveryChannel
provider: string
status: "pending" | "sent" | "failed" | "skipped" // DeliveryStatus
to?: string // email address, phone number, or webhook URL
subject?: string
body?: string // for webhooks, the JSON payload
providerMessageId?: string
error?: string
skipReason?: SkipReason
skipDetails?: string
attempts: number
createdAt: Date
updatedAt: Date
sentAt?: Date | null
failedAt?: Date | null
}RecipientPreference
type RecipientPreference = {
recipientId: string
tenantId?: string
workspaceId?: string
notificationId: string // or a synthetic key for global and category rows
channels: ChannelPreferenceMap
updatedAt: Date
}The same shape stores global and category preferences, under GLOBAL_PREFERENCE_KEY and categoryPreferenceKey(category). ScheduledSend, DigestBufferEntry, DedupeRecord, and RateLimitEvent are the remaining stored types, used by adapter authors.
Results
SendResult is described under send(). Each entry in its skipped array is a SkippedDelivery: { channel, reason: SkipReason, details? }. The per-item inbox methods return small unions you can switch on:
type MarkReadForRecipientResult =
| { status: "marked"; item: InboxItem }
| { status: "not_found" }
| { status: "forbidden" }
type InboxItemForRecipientResult =
| { status: "ok"; item: InboxItem }
| { status: "not_found" }
| { status: "forbidden" }
type InboxDeleteForRecipientResult =
| { status: "deleted" } | { status: "not_found" } | { status: "forbidden" }Skip reasons
SkipReason is a union of the strings in SKIP_REASONS. These are the ones the engine produces today:
| Reason | Why the channel did not go out |
|---|---|
preferences_disabled | A preference or default turned it off, at any level |
missing_address | The recipient has no email address or phone number for it |
rate_limited | The notification's rate limit was reached; every channel is skipped |
duplicate | The same dedupeKey was used inside its window |
idempotent_replay | Reported on replayed results for an earlier send with the same idempotency key |
quiet_hours_deferred | Held until quiet hours end. It will still be delivered |
suppressed | A fallback for a channel with no preference resolution; you should not normally see it |
Explanations
What explain() and preferences.explain() return. See Explain and dry run.
type DeliveryExplanation = {
recipientId: string
notificationId: string
scope?: SecurityScope
channels: Array<ChannelResolution & { outcome: ChannelOutcome }>
required: boolean
classification?: NotificationClassification
category?: string
payloadValidation: { valid: boolean; fields: PayloadFieldError[] }
wouldReplayIdempotent: boolean
wouldDeduplicate: boolean
wouldRateLimit: boolean
wouldDigest: boolean
idempotency: { key: string; existingNotificationId: string; ttlMs: number } | null
dedupe: { key: string; windowMs: number } | null
rateLimit: { current: number; max: number; windowMs: number } | null
digest: { windowMs: number } | null
quietHours: { active: boolean; resumesAt: Date | null } | null
}
type ChannelResolution = {
channel: ChannelType
allowed: boolean
resolvedBy: PreferenceResolutionLayer // the layer that decided
trail: Array<{ layer: PreferenceResolutionLayer; value: boolean | undefined }>
reason: string
}PreferenceExplanation is the same without the outcomes and pipeline fields. Each channel gets the first ChannelOutcome that applies, in this order:
| Outcome | Meaning |
|---|---|
unavailable | No address for this channel |
disabled | Turned off by preferences |
invalid_payload | The payload would fail validation |
idempotent | The idempotency key was already used |
deduplicated | The dedupe key was already used inside its window |
rate_limited | The rate limit is reached |
digested | It would join a digest |
delayed | Quiet hours would hold it (email, SMS, and webhooks only) |
deliver | It would go out now |
PreferenceResolutionLayer, lowest priority first: app_default, notification_default, category_default, tenant_setting, user_global, user_category, user_notification, then the overrides required_override and destination_unavailable.
Timeline Events
type TimelineEvent = {
id: string
seq: number // breaks ties between events with the same timestamp
notificationRecordId: string
deliveryId?: string
recipientId: string
tenantId?: string
workspaceId?: string
notificationId: string
channel?: ChannelType
provider?: string
event: TimelineEventType
message: string
metadata?: Record<string, unknown>
timestamp: Date
}TimelineEventType is one of the strings in TIMELINE_EVENT_TYPES. See Delivery timeline for what each one records.
Extension Points
Types you implement to plug in your own provider, queue, or storage.
type EmailProvider = {
id: string
send(input: {
to: string
subject: string
body: string
}): Promise<{ providerMessageId?: string }>
}
type SmsProvider = {
id: string
send(input: {
to: string
body: string
}): Promise<{ providerMessageId?: string }>
}
type WebhookProvider = {
id: string
signed?: boolean // unsigned providers get a startup warning
send(input: {
url: string
headers: Record<string, string>
payload: {
notificationId: string; recipientId: string
tenantId?: string; workspaceId?: string
payload: Record<string, unknown>; sentAt: string
}
}): Promise<{ providerMessageId?: string }>
}
type Queue = {
enqueue(
job: DeliveryJob,
run: (job: DeliveryJob) => Promise<void>,
): void | Promise<void>
drain(): Promise<void>
}
type RetryPolicy = {
maxAttempts: number // including the first
delayMs(attempt: number): number // attempt is 1-based
}DeliveryJob is a serializable union with one member per channel: the ids and scope, the provider, the rendered message (to, subject, and body for email; url and headers for webhooks; to and body for SMS), and the payload. A durable queue stores it and calls notify.processDeliveryJob(job).
DatabaseAdapter has one group of methods per stored type: recipients, notifications, inbox, deliveries, preferences, digests, rateLimits, scheduledSends, dedupe, and an optional timeline. See Database adapters before writing one.
Everything Else
| Area | Types |
|---|---|
| Setup | CreateNotifyKitInput, NotifyKit, DevModeConfig, CapturedSend, NotificationMetadata, Hooks, CategoryDefaults |
| Channel helpers | InboxChannelInput, EmailChannelInput, SmsChannelInput, WebhookChannelInput, and their …ChannelFactory types |
| Inputs | UpsertRecipientInput, GetPreferenceInput, UpdatePreferenceInput, InboxListFilter |
| Handler | CreateHandlerOptions, Handler, Identify, Authorize, HandlerIdentity, HandlerContext, HandlerPermission, WebhookVerifier, WebhookEventHandler, RedactedDeliveryRecord |
| Realtime | RealtimeAdapter, RealtimeEvent, RealtimeListener |
| Providers | WebhookProviderOptions, FakeEmailProvider, FakeSmsProvider, FakeWebhookProvider, their …Options, SentEmail, SentSms, SentWebhook |
| Validation | ValidationIssue, ValidationSeverity, ValidateConfigInput, PayloadValidationResult, PayloadFieldError, NotifyKitErrorContext |
| Other | MemoryAdapter, UnsubscribeTokenClaims, ResolutionContext, SafeWebhookResult, ScheduledSendStatus, PrimitiveSchema, PayloadSchema |