TypeScript types

All types are exported from @notifykitjs/core. This page documents the most commonly referenced ones.

End-to-end inference

Define a payload once — TypeScript narrows send() inputs, hook contexts, and result types automatically.

Single import path

Every type ships from @notifykitjs/core. No hunting across subpackages or peer dependencies.

Utility extractors

InferPayload, InferNotificationId, and InferSendInput derive types from your instance — no manual duplication.

Exhaustive skip reasons

A typed union of every possible skip reason so switch statements catch all cases at compile time.

TypeUsed for
NotificationDefinitionDefining notifications
ChannelConfigInbox, email, SMS, webhook channel templates
RecipientUser profiles with contact info and quiet hours
InboxItemInbox entries with read/archive state
DeliveryRecordDelivery attempts and their outcomes
RecipientPreferencePer-notification channel opt-in/out
EmailProvider / SmsProviderImplementing custom providers
Queue / RetryPolicyCustom queue implementations
SkipReasonUnderstanding why a channel was skipped

Importing types

Everything exports from a single path. Import only what you need:

lib/types.ts
import type {
  SendResult,
  InboxItem,
  DeliveryRecord,
  RecipientPreference,
  EmailProvider,
  SkipReason,
} from "@notifykitjs/core"
BuildingTypes you'll reach for
Notification definitionsNotificationDefinition, ChannelConfig, DigestConfig
Send result handlingSendResult, SkipReason, DeliveryRecord
Custom providerEmailProvider, SmsProvider, WebhookProvider
Custom queueQueue, RetryPolicy, DeliveryJob
UI / client codeInboxItem, RecipientPreference

How types flow through the system

Types aren't isolated — they flow from your definition through the engine and into results. Understanding these connections helps you write typed utilities without casting:

1
NotificationDefinition

You write: id, payload schema, channels. TypeScript infers Id and PayloadSchema as literal types.

2
send() input

Narrowed by notificationId. Pass "comment_mentioned" and TypeScript enforces that notification's payload shape.

3
SendResult

Contains NotificationRecord, InboxItem[], DeliveryRecord[], SkippedDelivery[]. All typed — no unknown fields.

4
Hook context

Hooks receive typed objects: delivery.sent gives you a DeliveryRecord, notification.created gives you a NotificationRecord.

You defineTypeScript infersYou get back
id: "comment_mentioned"Literal type "comment_mentioned"send() only accepts this exact string for notificationId
payload: { actorName: "string" }{ actorName: string }send() requires actorName when this ID is used
channels: [inbox(...), email(...)]Channel config unionresult.deliveries[].channel is "inbox" | "email"
as const on the arrayTuple of all definitionsnotificationId is a union of all registered IDs, not string
The as const assertion is critical. Without it, TypeScript widens "comment_mentioned" to string and you lose all narrowing. Your send()call would accept any string as notificationId and any object as payload — defeating the purpose.

Extracting types from definitions

You don't need to manually duplicate your payload types. Extract them from the definitions themselves:

lib/notification-types.ts
import type { InferPayload, InferNotificationId } from "@notifykitjs/core"
import type { notify } from "@/lib/notifykit"

type NotificationId = InferNotificationId<typeof notify>
// → "comment_mentioned" | "order_shipped" | "team_invite"

type CommentPayload = InferPayload<typeof notify, "comment_mentioned">
// → { actorName: string; postTitle: string; postUrl: string }

function trackNotificationSent(id: NotificationId, payload: Record<string, unknown>) {
  analytics.track("notification_sent", { notificationId: id, ...payload })
}
Utility typeExtractsUse for
InferNotificationId<T>Union of all registered notification IDsTyped switch statements, exhaustiveness checks, analytics events
InferPayload<T, Id>Payload shape for a specific notificationTyping helper functions that construct payloads, test factories
InferSendInput<T>Full send() input unionWrapping send() in a queue job or server action
lib/notification-queue.ts
import type { InferSendInput } from "@notifykitjs/core"
import type { notify } from "@/lib/notifykit"

type SendJob = InferSendInput<typeof notify>

async function enqueueSend(job: SendJob) {
  await queue.add("notification:send", job)
}

async function processSend(job: SendJob) {
  await notify.send(job)
}
Derive, don't duplicate. If you find yourself writing type CommentPayload = { actorName: string; ... } by hand, use InferPayload instead. The definition is the single source of truth — derived types stay in sync automatically when you add or rename fields.

Type map

Every type belongs to one layer. Use this to quickly find what you're looking for:

LayerTypesYou touch these when
DefinitionNotificationDefinition, ChannelConfig, DigestConfig, RateLimitConfigWriting notification definitions in lib/notifications/
InputInferSendInput, InferPayload, InferNotificationIdTyping wrappers around send() — queues, server actions, helpers
OutputSendResult, InboxItem, DeliveryRecord, SkipReasonInspecting what happened after a send — logging, analytics, conditional UI
StateRecipient, RecipientPreference, QuietHours, SecurityScopeManaging user profiles, preferences, and tenant scoping
ExtensionEmailProvider, SmsProvider, WebhookProvider, Queue, RetryPolicy, DatabaseAdapterBuilding custom providers, queues, or database adapters
Most apps only need Output types. If you're using the built-in adapters and providers, you'll mainly import SendResult, InboxItem, and SkipReason for result handling. The Definition layer is handled by the notification() helper, and Extension types are only needed when building custom integrations.

NotificationDefinition

Only 3 fields are required: id, payload, and channels. Everything else is opt-in as your needs grow.
type NotificationDefinition<Id extends string, S extends PayloadSchema> = {
  // Required
  id: Id
  payload: S
  channels: ChannelConfig[]

  // Delivery control
  digest?: DigestConfig<S>
  rateLimit?: RateLimitConfig
  fallback?: InboxChannelConfig | FallbackRule[]
  required?: boolean
  defaultChannels?: ChannelPreferenceMap

  // Metadata
  description?: string
  category?: string
  classification?: "transactional" | "product" | "marketing"
  version?: number

  // Security & validation
  redact?: readonly string[]
  validate?: (payload: unknown) => Record<string, unknown>
}

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 = "inbox" | "email" | "sms" | "webhook"

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" 24h format
  end: string      // "HH:MM" 24h format
  timezone?: string // IANA timezone, defaults to "UTC"
}

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

type DeliveryRecord = {
  id: string
  notificationRecordId: string
  recipientId: string
  tenantId?: string
  workspaceId?: string
  notificationId: string
  channel: "email" | "webhook" | "sms" | "inbox"
  provider: string
  status: "pending" | "sent" | "failed" | "skipped"
  to?: string
  subject?: string
  body?: string
  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
  channels: ChannelPreferenceMap
  updatedAt: Date
}

type ChannelPreferenceMap = Partial<Record<ChannelType, boolean>>

Provider interfaces

InterfaceInputReturn
EmailProviderto, subject, body{ providerMessageId? } — stored on the delivery record for tracking
SmsProviderto, body
WebhookProviderurl, headers, payload
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
  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 }>
}

Queue & retry

type Queue = {
  enqueue(job: DeliveryJob, run: (job: DeliveryJob) => Promise<void>): void | Promise<void>
  drain(): Promise<void>
}

type RetryPolicy = {
  maxAttempts: number
  delayMs(attempt: number): number
}

SecurityScope

type SecurityScope = {
  tenantId?: string
  organizationId?: string  // alias for tenantId
  workspaceId?: string
}

SkipReason

When a channel is skipped, result.skipped[].reason tells you why. Reasons are grouped by the pipeline stage that produced them — earlier stages short-circuit the entire send, later stages affect individual channels.

Early pipeline (stops entire send)

ReasonStageWhat happenedFix
idempotent_replay1. IdempotencyKey already exists — original result returnedExpected on retries. Not a problem.
duplicate2. DedupDedup key matched within the windowExpected. If surprising, check your key design.
invalid_payload3. ValidationPayload failed schema validationFix the payload — check error.fields.
rate_limited4. Rate limitExceeded threshold for this notificationIncrease the limit, or add a digest to batch instead of drop.

Per-channel (skips one channel, others may still deliver)

ReasonStageWhat happenedFix
preferences_disabled5. PreferencesUser opted out of this channelExpected. User chose this — don't override unless required.
unsubscribed5. PreferencesUser clicked unsubscribe link in emailSame as above — respect the opt-out.
condition_false5. PreferencesA conditional channel rule evaluated to falseCheck your channel condition logic.
quiet_hours_deferred6. Quiet hoursDeferred — will deliver when window endsNot truly skipped — check back after quiet hours pass.
missing_address7. DeliveryRecipient has no email/phone for this channelCall upsertRecipient() with the missing field.
missing_provider7. DeliveryNo provider configured for this channel typeAdd a provider in createNotifyKit().

Meta (informational)

ReasonWhat happenedNotes
suppressedAll channels were skipped — notification had no visible effectCheck individual skip reasons to find why each channel failed.
expiredScheduled send expired before delivery window openedUsually means quiet hours ended but the configured TTL passed first.
disabled_in_devChannel disabled by devMode configurationOnly in development — won't appear in production.
required_overridePreferences were bypassed by required: trueInformational — channel did deliver despite user opt-out.

Working with SendResult

SendResult is the most common type you'll interact with. Here are practical patterns for inspecting and acting on it:

lib/send-and-log.ts
import type { SendResult, SkipReason } from "@notifykitjs/core"

async function sendAndLog(result: SendResult) {
  const delivered = result.deliveries.length > 0 || result.inboxItems.length > 0

  if (result.rateLimited) return "dropped_by_rate_limit"
  if (result.digested) return "buffered_in_digest"
  if (result.idempotent) return "duplicate_replay"

  const skippedEmail = result.skipped.find(s => s.channel === "email")
  if (skippedEmail) {
    switch (skippedEmail.reason) {
      case "preferences_disabled":
        break
      case "missing_address":
        await promptUserForEmail(result.notification?.recipientId)
        break
      case "missing_provider":
        logger.warn("No email provider configured")
        break
    }
  }

  const channels = result.deliveries.map(d => d.channel)
  analytics.track("notification_sent", {
    id: result.notification?.notificationId,
    channels,
    skipped: result.skipped.map(s => `${s.channel}:${s.reason}`),
  })

  return delivered ? "delivered" : "suppressed"
}
PatternFields to checkUse case
Did anything deliver?deliveries.length + inboxItems.length > 0Showing "Notification sent" vs "Notification suppressed" toasts
Why was email skipped?skipped.find(s => s.channel === "email")?.reasonDebugging "user didn't get email" support tickets
Is this a retry replay?result.idempotent === trueAvoiding double-counting in analytics
Was it deferred?result.deferredChannels.length > 0Showing "Will deliver at 8am" in the UI
Audit records survive suppression. Rate-limited and deduplicated sends still persist a notification record plus skipped delivery records. result.notification is null only while a digest is buffered for a later flush.

DatabaseAdapter

Implement this interface to connect NotifyKit to any storage backend. The built-in memoryAdapter() and drizzlePostgresAdapter() both satisfy this contract. The outline below is abbreviated; import the exported type for exact inputs and return values:

type DatabaseAdapter = {
  recipients: {
    upsert(input: UpsertRecipientInput): Promise<Recipient>
    findById(id: string): Promise<Recipient | null>
  }
  notifications: {
    create(input: NewNotificationRecord): Promise<NotificationRecord>
    findByIdempotencyKey(key: string): Promise<NotificationRecord | null>
    clearIdempotencyKey(id: string): Promise<void>
  }
  inbox: {
    create(input: NewInboxItem): Promise<InboxItem>
    listByRecipient(recipientId: string, scope?, filter?, limit?): Promise<InboxItem[]>
    markReadForRecipient(id: string, recipientId: string, scope?): Promise<MarkReadResult>
    unreadCount(recipientId: string, scope?): Promise<number>
    markAllRead(recipientId: string, scope?): Promise<number>
    archiveForRecipient(id: string, recipientId: string, scope?): Promise<InboxItemResult>
    unarchiveForRecipient(id: string, recipientId: string, scope?): Promise<InboxItemResult>
    deleteForRecipient(id: string, recipientId: string, scope?): Promise<DeleteResult>
    // plus listByNotificationRecordId(...)
  }
  deliveries: { /* create, findById, list, update, listByNotificationRecordId */ }
  preferences: { /* get, list, upsert */ }
  digests: { /* append, take, restore, list */ }
  rateLimits: { /* reserve, count */ }
  scheduledSends: { /* create, claim, complete, release, listDue, list */ }
  dedupe: { /* check, exists, prune */ }
  timeline?: { /* append, listByNotificationRecordId, listByDeliveryId, prune */ }
}
Method groupCalled byMust be atomic
Notificationssend() pipelineNo — single writes
InboxHandler routes + send()markAllRead should be atomic (updates many rows)
DeliveriesQueue workers + retry logicNo — updates are per-record
Dedup/idempotencysend() early checksYes — dedupe.check() must be an atomic check-and-insert
Scheduled sendsQuiet hours + flushYes — scheduledSends.claim() must prevent double-processing
Start from memoryAdapter() source. It's ~200 lines and implements every method with plain arrays and Maps. Copy it as a starting point for Redis, DynamoDB, or any custom backend — the contract is the same regardless of storage.

Common type errors

TypeScript errors in NotifyKit are usually caused by one of five issues. Match the error message to the fix:

ErrorCauseFix
Argument of type 'string' is not assignable to parameter of type '"comment_mentioned" | "order_shipped"'Variable typed as string instead of the literal unionUse as const on the value, or type the variable as InferNotificationId<typeof notify>
Property 'actorName' is missing in type '{}'Payload doesn't match the schema for this notification IDAdd the missing field. Use InferPayload<typeof notify, "your_id"> to see the expected shape.
Type 'string' is not assignable to type 'never' on notificationIdMissing as const on the notifications array in createNotifyKit()Change notifications: [...] to notifications: [...] as const
Object literal may only specify known properties on payloadPassing extra fields not declared in the notification's payload schemaRemove the extra field, or add it to the notification definition's payload object
Type 'undefined' is not assignable to type 'string' on provider sendCustom provider's send() return doesn't match EmailProviderReturn { providerMessageId?: string } — even {} satisfies the contract
lib/send-notification.ts
// ❌ Error: string ≠ literal union
const id = getNotificationFromConfig()
await notify.send({ recipientId: "u1", notificationId: id, payload: {} })

// ✅ Fix 1: type assertion (when validated externally)
await notify.send({
  recipientId: "u1",
  notificationId: id as InferNotificationId<typeof notify>,
  payload: getPayloadForId(id),
})

// ✅ Fix 2: typed lookup (preferred)
const NOTIFICATION_MAP = {
  comment: "comment_mentioned",
  order: "order_shipped",
} as const

await notify.send({
  recipientId: "u1",
  notificationId: NOTIFICATION_MAP.comment,
  payload: { actorName: "Rey", postUrl: "/p/1" },
})
Never use as any to silence NotifyKit type errors. They exist to catch real bugs — a wrong notification ID means the payload won't match the template, and the notification will render broken or throw at runtime. Fix the type, don't suppress it.