BackReference

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:

TypeGives 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
lib/notification-types.ts
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:

ReasonWhy the channel did not go out
preferences_disabledA preference or default turned it off, at any level
missing_addressThe recipient has no email address or phone number for it
rate_limitedThe notification's rate limit was reached; every channel is skipped
duplicateThe same dedupeKey was used inside its window
idempotent_replayReported on replayed results for an earlier send with the same idempotency key
quiet_hours_deferredHeld until quiet hours end. It will still be delivered
suppressedA 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:

OutcomeMeaning
unavailableNo address for this channel
disabledTurned off by preferences
invalid_payloadThe payload would fail validation
idempotentThe idempotency key was already used
deduplicatedThe dedupe key was already used inside its window
rate_limitedThe rate limit is reached
digestedIt would join a digest
delayedQuiet hours would hold it (email, SMS, and webhooks only)
deliverIt 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

AreaTypes
SetupCreateNotifyKitInput, NotifyKit, DevModeConfig, CapturedSend, NotificationMetadata, Hooks, CategoryDefaults
Channel helpersInboxChannelInput, EmailChannelInput, SmsChannelInput, WebhookChannelInput, and their …ChannelFactory types
InputsUpsertRecipientInput, GetPreferenceInput, UpdatePreferenceInput, InboxListFilter
HandlerCreateHandlerOptions, Handler, Identify, Authorize, HandlerIdentity, HandlerContext, HandlerPermission, WebhookVerifier, WebhookEventHandler, RedactedDeliveryRecord
RealtimeRealtimeAdapter, RealtimeEvent, RealtimeListener
ProvidersWebhookProviderOptions, FakeEmailProvider, FakeSmsProvider, FakeWebhookProvider, their …Options, SentEmail, SentSms, SentWebhook
ValidationValidationIssue, ValidationSeverity, ValidateConfigInput, PayloadValidationResult, PayloadFieldError, NotifyKitErrorContext
OtherMemoryAdapter, UnsubscribeTokenClaims, ResolutionContext, SafeWebhookResult, ScheduledSendStatus, PrimitiveSchema, PayloadSchema