Sending notifications

notify.send() is the one call you'll make from your application code. It's fully typed against the notifications you registered with createNotifyKit().

Under the hood, every send walks through this pipeline:

1
Validate

Payload schema check, idempotency check, dedup check, rate limit check.

2
Resolve

Look up recipient, resolve preferences per channel, check quiet hours.

3
Deliver

Write inbox items, queue email/SMS/webhook deliveries, fire hooks, return result.

Common patterns

Most sends fit one of these four shapes. Copy the one that matches your use case — each handles the safety concern for that context:

Fire-and-forget

User action in a server action — non-blocking, dedup prevents double-sends.

void notify.send({ recipientId: userId, notificationId: "comment_mentioned", payload: { ... }, dedupeKey: `mention:${postId}:${actorId}`, dedupeWindowMs: 5 * 60_000, })

Retry-safe webhook

Incoming webhook that may fire multiple times — idempotency key guarantees at-most-once.

await notify.send({ recipientId: userId, notificationId: "payment_received", payload: { ... }, idempotencyKey: `stripe:${event.id}`, })

Broadcast to a list

Fan-out to a team or followers — parallel with per-recipient idempotency keys.

await Promise.allSettled( userIds.map(id => notify.send({ recipientId: id, notificationId: "project_shipped", payload: { ... }, idempotencyKey: `ship:${projId}:${id}`, })) )

Preview before sending

Dry-run for debugging or admin tooling — zero side effects, shows full pipeline resolution.

const explanation = await notify.explain({ recipientId: userId, notificationId: "comment_mentioned", payload: { ... }, }) // explanation.channels.email.outcome
Not sure which pattern? If the caller can retry → add idempotencyKey. If the user can trigger the same event twice → add dedupeKey. If response time matters → use void (fire-and-forget). Scroll down for the full decision flow.

Pipeline decision map

Each send passes through a sequence of gates. At every gate, the pipeline can exit early with a specific outcome. When debugging "why didn't the user get it?" — find which gate stopped it:

GateCheckIf it failsSendResult signal
1. Payload validationSchema match (types + required fields)Throws — no record created, no deliveryException (not a result field)
2. IdempotencyHas this idempotencyKey been seen?Returns the original result immediatelyidempotent: true
3. DeduplicationHas this dedupeKey been seen within the window?Delivery is skipped; an audit record is still createdskipped[].reason === "duplicate"
4. Rate limitIs the recipient under the threshold for this notification?Send is dropped — permanently gonerateLimited: true
5. Digest bufferIs a digest configured? Buffer the payload.Buffered for later — no immediate deliverydigested: true
6. Recipient lookupDoes the recipient exist in the database?Throws — send cannot proceed without a recipientException (not a result field)
7. Per-channel preferenceHas the user opted out of this channel?Channel skipped (others may still fire)skipped[].reason: "preferences_disabled"
8. Destination checkDoes the recipient have the address (email, phone)?Channel skippedskipped[].reason: "missing_address"
9. Condition functionDoes the channel's condition(payload) return true?Channel skippedskipped[].reason: "condition_false"
10. Quiet hoursIs the recipient in their quiet window? (push channels only)Deferred — scheduled for window enddeferredChannels: ["email", ...]
11. DeliveryProvider call succeeds?Retried with backoff → fallback if exhausteddeliveries[].status: "sent" | "failed"
Read the table top-to-bottom. Gates are checked in this exact order. If gate 4 (rate limit) stops the send, gates 5–11 never run. The SendResult always tells you which gate was the last one reached — use it to pinpoint where the pipeline stopped.
Gates 1–5 are "whole-send" exits. They stop the entire notification. Gates 7–10 are per-channel — one channel can be skipped while another delivers successfully. A send can have skipped.length > 0 AND deliveries.length > 0 at the same time.

Quick troubleshooting

Send not working? Match your symptom to the root cause and fix:

SymptomRoot causeFix
send() throws VALIDATION_ERRORPayload doesn't match the notification's schemaCheck field names and types against your payload definition. Missing fields and wrong types both trigger this.
send() throws RECIPIENT_NOT_FOUNDupsertRecipient() was never called for this userCall upsertRecipient({ id, email }) before the first send(). Common in new-user signup flows.
result.deliveries is empty, no inbox itemAll channels were skipped (preferences, missing address, condition)Check result.skipped — the reason field tells you which gate blocked it. Use notify.explain() for a full trace.
Inbox item created but no email sentUser opted out of email, or recipient has no email fieldCheck result.skipped for preferences_disabled or missing_address. Verify the recipient has an email.
result.rateLimited === trueRecipient exceeded the rate limit for this notificationExpected behavior. If the limit is too tight, increase max or widen windowMs in the notification definition.
result.digested === true, nothing deliveredSend was buffered into a digest window — delivery happens laterNot a bug. Wait for the window to expire, or call flushDigests() to force it. In tests, use windowMs: 0.
Same notification sent twice to the same userNo idempotencyKey or dedupeKey configuredAdd idempotencyKey (for retryable triggers) or dedupeKey (for user-triggered events). See Common patterns above.
Email delivered but arrives in spamSender domain not authenticated (SPF/DKIM/DMARC)Not a NotifyKit issue — configure DNS records for your sending domain in your email provider's dashboard.
Use notify.explain() for any mystery. It dry-runs the full pipeline — same validation, preferences, quiet hours, channel resolution — and tells you what would happen without actually sending. If send() isn't doing what you expect, explain() shows you exactly which gate stopped it. See Explain & dry run.

Basic send

await notify.upsertRecipient({
  id: user.id,
  email: user.email,
  name: user.name,
})

const result = await notify.send({
  recipientId: user.id,
  notificationId: "comment_mentioned",
  payload: {
    actorName: "Rey",
    postTitle: "Launch Plan",
    postUrl: "/posts/42",
  },
})

Send options at a glance

Beyond recipientId, notificationId, and payload, these optional fields control pipeline behavior:

OptionWhat it doesWhen to add it
tenantIdScopes the send to an organizationMulti-tenant apps — ensures tenant-level preferences apply
idempotencyKeyReturns the original result on duplicate callsRetryable triggers (webhooks, queue jobs) — prevents double-sends
dedupeKeySkips if the same key was seen within the windowNoisy events (edits, reactions) — collapses rapid duplicates
dedupeWindowMsDuration the dedup key is rememberedPair with dedupeKey — defaults to 5 minutes
dryRunReturns a DeliveryExplanation without writing anythingDebugging — see what would happen for a given input
Rule of thumb: if the caller can retry, add idempotencyKey. If the user can trigger the same logical event repeatedly, add dedupeKey. They solve different problems — use both when both apply.

SendResult

The result tells you exactly what happened — use it for logging, analytics, or conditional follow-up logic:

FieldTypeMeaning
notificationNotificationRecord | nullThe created record, or null if digested
inboxItemsInboxItem[]Inbox rows written to the database
deliveriesDeliveryRecord[]All delivery attempts including failures
skippedSkippedDelivery[]Channels skipped with reasons (preference opt-out, missing destination)
deferredChannelsChannelType[]Channels held back by quiet hours
digestedbooleanSend was buffered into a digest window
rateLimitedbooleanSend was dropped by a rate limit
idempotentbooleanDuplicate send — idempotency key already seen
Common pattern. Check result.rateLimited or result.digested before showing success toasts — the user may not actually receive anything immediately.

Type safety

These fail at compile time, not at runtime:

// ❌ Unknown notification id
await notify.send({
  recipientId: "u_1",
  notificationId: "wrong_id",  // TS error: not assignable
  payload: {},
})

// ❌ Wrong payload shape
await notify.send({
  recipientId: "u_1",
  notificationId: "comment_mentioned",
  payload: { actorName: 42 },  // TS error: number is not string
})

Inline vs async queue

ModeBehaviorBest for
inlineQueue() (default)send() awaits provider callsSimple apps, scripts, testing
setTimeoutQueue()send() returns immediately, deliveries run asyncPrototypes where losing in-flight work is acceptable
Custom (Queue interface)You control when workers runBullMQ, SQS, Cloudflare Queues

Swap in setTimeoutQueue() to return quickly and run deliveries later in the same process. This is useful for demos, but it is not a durable production queue:

import { setTimeoutQueue } from "@notifykitjs/core"

const notify = createNotifyKit({
  // ...
  queue: setTimeoutQueue(),
  retry: { maxAttempts: 5, delayMs: (n) => 500 * 2 ** (n - 1) },
})

// Wait for in-flight jobs before shutdown:
await notify.drain()

Event hooks

Every interesting moment fires a hook. See Hooks & observability for the full list.

createNotifyKit({
  // ...
  on: {
    "notification.created": ({ notification }) =>
      metrics.inc("notifications.created"),
    "delivery.sent": ({ delivery }) =>
      metrics.inc("delivery.sent", { channel: delivery.channel }),
    "delivery.failed": ({ delivery, error }) =>
      sentry.captureException(error),
  },
})

Quiet hours

Push channels defer, inbox delivers immediately. When a recipient is in their quiet window, email/SMS/webhook are scheduled for the window's end. The inbox item still writes instantly. See Quiet hours for setup and flushing details.

Checking results

After every send, check the result to understand what actually happened:

CheckWhat it meansCommon action
result.digestedBuffered into a digest — no immediate deliveryDon't show a "sent!" toast
result.rateLimitedDropped by rate limit — permanently goneLog for monitoring
result.idempotentDuplicate — original result returnedSafe to ignore
result.skipped.length > 0Some channels were skippedCheck .reason for debugging
result.deferredChannels.length > 0Held by quiet hoursWill deliver when window ends

Where to call send()

send() is server-only — call it anywhere you have access to your NotifyKit instance. Use this decision flow to pick the right pattern for your context:

Can your trigger retry?

Webhooks, queue jobs, and cron tasks can fire multiple times. If yes → add an idempotencyKey.

Does the user's response time depend on delivery?

In server actions and API routes, the user waits. If delivery must outlive the request → write an outbox row or enqueue a durable job.

Can the same logical event fire many times?

Multiple edits to the same comment, repeated likes. If yes → add a dedupeKey scoped to the entity.

ContextExampleAwait?Key to use
Server actionUser posts a comment → notify mentioned usersPersist an outbox row; otherwise await itdedupeKey (same mention in same post)
API route (incoming webhook)Stripe webhook → notify user of paymentYes — confirm delivery for webhook ackidempotencyKey (webhook can retry)
Background jobOrder ships → notify customer with trackingYes — job has no response deadlineidempotencyKey (job can retry)
Cron / scheduledDaily digest → batch notify all usersYes — sequential within the batchdedupeKey with date (prevents double-send)
// Server action — persist intent with the business mutation
"use server"
import { notify } from "@/lib/notifykit"

export async function addComment(postId: string, body: string) {
  return db.transaction(async tx => {
    const comment = await tx.comments.create({ postId, body, authorId: session.userId })

    for (const userId of extractMentions(body)) {
      await tx.notificationOutbox.create({
        notificationId: "comment_mentioned",
        recipientId: userId,
        payload: {
          actorName: session.userName,
          postTitle: comment.postTitle,
          postUrl: `/posts/${postId}`,
        },
        idempotencyKey: `mention:${comment.id}:${userId}`,
      })
    }

    return comment
  })
}

// A durable worker claims the outbox row, then awaits notify.send({
//   ...row,
//   payload: row.payload,
// }) before marking it complete.
Returning early is not the same as durable delivery. If you cannot tolerate loss, persist an outbox row in the same transaction as the business change and let a durable worker process it. Await notify.send() directly when that simpler tradeoff is acceptable.

Sending to multiple recipients

send() targets one recipient at a time — to broadcast, loop over your recipient list. Each send resolves independently (its own preferences, quiet hours, rate limits).

// Notify all team members when a project ships
const members = await db.teamMembers.findMany({ where: { teamId } })

const results = await Promise.allSettled(
  members
    .filter(m => m.userId !== actor.id) // don't notify the person who did it
    .map(m =>
      notify.send({
        recipientId: m.userId,
        notificationId: "project_shipped",
        payload: { projectName, actorName: actor.name },
        idempotencyKey: `shipped:${projectId}:${m.userId}`,
      })
    )
)

const failed = results.filter(r => r.status === "rejected")
if (failed.length) logger.warn(`${failed.length} sends failed`, { projectId })
PatternWhen to useTrade-off
Promise.allSettled()Broadcast to a team or followers (10-100 recipients)One failure doesn't block the rest. Inspect results to log failures.
Sequential for...ofOrdered sends or very large lists (1000+)Slower, but avoids connection pool exhaustion. Add a small delay between batches.
Background job per recipientAsync fan-out from a queue (BullMQ, SQS)Best for scale — each send retries independently. Requires queue infrastructure.
Always use idempotencyKey for broadcasts. If your trigger retries (webhook, queue job), each recipient gets a unique key so duplicates are safely skipped. Format: `event:${eventId}:${recipientId}`.

Error recovery

send() itself only throws for programming errors (bad payload, missing recipient). Provider failures are captured in the result and retried automatically. But bulk operations introduce a third category: partial failures at the orchestration layer.

1
Detect partial failure

After a broadcast, check which sends rejected. Log recipient IDs and the error.

2
Decide: retry or dead-letter

Transient errors (DB timeout, connection reset) → retry. Permanent errors (missing recipient) → dead-letter.

3
Alert if failure rate spikes

A handful of failures is normal. 20%+ failure rate across a broadcast means something systemic.

// Robust broadcast with error classification
async function broadcastWithRecovery(recipientIds: string[], notification: SendInput) {
  const results = await Promise.allSettled(
    recipientIds.map(id =>
      notify.send({ ...notification, recipientId: id, idempotencyKey: `${notification.idempotencyKey}:${id}` })
    )
  )

  const succeeded = results.filter(r => r.status === "fulfilled")
  const failed = results
    .map((r, i) => ({ result: r, recipientId: recipientIds[i] }))
    .filter(({ result }) => result.status === "rejected")

  // Classify failures
  const retryable = failed.filter(({ result }) =>
    result.status === "rejected" && isTransient(result.reason)
  )
  const deadLettered = failed.filter(({ result }) =>
    result.status === "rejected" && !isTransient(result.reason)
  )

  // Log and alert
  if (failed.length > 0) {
    logger.warn("Broadcast partial failure", {
      total: recipientIds.length,
      succeeded: succeeded.length,
      retryable: retryable.length,
      deadLettered: deadLettered.length,
    })
  }

  // Alert on high failure rate
  const failureRate = failed.length / recipientIds.length
  if (failureRate > 0.2) {
    await alertOncall("Broadcast failure rate >20%", { failureRate, notification })
  }

  // Retry transient failures (once, with backoff)
  if (retryable.length > 0) {
    await delay(2000)
    await Promise.allSettled(
      retryable.map(({ recipientId }) =>
        notify.send({ ...notification, recipientId, idempotencyKey: `${notification.idempotencyKey}:${recipientId}` })
      )
    )
  }

  return { succeeded: succeeded.length, failed: failed.length, retried: retryable.length }
}

function isTransient(error: unknown): boolean {
  const message = error instanceof Error ? error.message : ""
  return message.includes("timeout") || message.includes("ECONNRESET") || message.includes("503")
}
Failure typeExamplesStrategy
TransientDB timeout, connection reset, 503 from dependencyRetry once after delay. If still failing, dead-letter and alert.
PermanentRecipient not found, validation error, missing providerDead-letter immediately. These won't succeed on retry.
Systemic>20% failure rate in a single broadcastAlert oncall. Likely a DB outage or misconfiguration, not per-recipient.
Idempotency keys make retries safe. Because every recipient gets a unique key (event:recipient), retrying the entire broadcast is safe — recipients who already succeeded get a no-op replay, and only the failed ones are re-attempted.

Common anti-patterns

These patterns compile fine but cause subtle issues in production. Each one shows the problem and the fix:

Anti-patternWhat goes wrongFix
Provider work in hot pathsResponse latency includes database and provider workPersist an outbox/queue job and process it in a durable worker
Generic dedup keysKey like "comment" silences all comment notifications after the first oneScope to the entity: `comment:${postId}:${actorId}`
Missing idempotency on retryable triggersQueue job retries → user gets the same email 3 timesAlways add idempotencyKey when the caller can retry
Notifying the actor"Rey commented on your post" sent to Rey themselvesFilter recipientId !== actorId before sending
One notification ID for all audiencesUser can't opt out of low-priority "watched post" updates without also losing direct mentionsSeparate IDs per audience: comment_mentioned vs comment_on_watched
// ❌ Untracked fire-and-forget can disappear on crash or deploy
"use server"
export async function addComment(postId: string, body: string) {
  const comment = await db.comments.create({ postId, body })
  void notify.send({ recipientId: mentioned, ... })
  return comment
}

// ✅ Persist notification intent with the business operation
"use server"
export async function addComment(postId: string, body: string) {
  return db.transaction(async tx => {
    const comment = await tx.comments.create({ postId, body })
    await tx.notificationOutbox.create({
      type: "comment_mentioned",
      recipientId: mentioned,
      payload: { postId, commentId: comment.id },
    })
    return comment
  })
}
setTimeoutQueue() is best-effort only. It improves response latency but does not survive process exits or serverless freezes. Use it only when losing an in-flight notification is acceptable.
// ❌ Generic dedup key — silences ALL comment notifications globally
await notify.send({
  recipientId: userId,
  notificationId: "comment_mentioned",
  payload,
  dedupeKey: "comment",  // Only one comment notification per 5 min, ever
})

// ✅ Scoped dedup key — collapses only rapid duplicates for the same context
await notify.send({
  recipientId: userId,
  notificationId: "comment_mentioned",
  payload,
  dedupeKey: `comment:${postId}:${actorId}:${recipientId}`,
})
// ❌ No idempotency on a webhook handler — retries cause duplicates
export async function POST(req: Request) {
  const event = await req.json()
  await notify.send({
    recipientId: event.userId,
    notificationId: "payment_received",
    payload: { amount: event.amount },
  })
  return Response.json({ ok: true })
}

// ✅ Idempotency key derived from the event — retries are safe no-ops
export async function POST(req: Request) {
  const event = await req.json()
  await notify.send({
    recipientId: event.userId,
    notificationId: "payment_received",
    payload: { amount: event.amount },
    idempotencyKey: `stripe:${event.id}`,
  })
  return Response.json({ ok: true })
}
The pipeline won't save you from orchestration bugs. Dedup, idempotency, and preferences work correctly at the send() level — but if your code calls send() with a wrong recipient, a too-broad key, or in the wrong place, the pipeline will faithfully deliver the wrong thing. Test your orchestration logic, not just the pipeline mechanics.

One event, multiple notifications

Real applications rarely send one notification per event. A single domain action — posting a comment, shipping an order, completing a deploy — usually triggers different notifications to different audiences. Here's how to structure that fan-out:

EventAudienceNotificationWhy it's different
Comment posted@mentioned userscomment_mentionedUrgent — they were directly addressed
Post authorcomment_on_your_postImportant but not as targeted
Post watcherscomment_on_watchedInformational — they opted into updates
Deploy completedPR authordeploy_succeededTheir code is live — action item
Team channel (webhook)deploy_webhookSystem-to-system, no inbox needed
Stakeholdersrelease_shippedHigh-level summary, different payload
Separate notification IDs, not just recipients. Different audiences need different urgency levels, channels, digest windows, and preference controls. If you send the same notificationId to everyone, users can't opt out of "comment on watched post" without also losing "mentioned you."

Orchestration pattern

Extract a notification dispatcher for each domain event. It receives the event context and decides who gets what:

// lib/notifications/on-comment-posted.ts
import { notify } from "@/lib/notifykit"

export async function onCommentPosted(comment: {
  id: string
  postId: string
  authorId: string
  body: string
  postAuthorId: string
}) {
  const mentions = extractMentions(comment.body)
  const watchers = await db.watchers.findMany({ where: { postId: comment.postId } })
  const actor = await db.users.findFirst({ where: { id: comment.authorId } })

  // 1. Notify mentioned users (highest priority)
  const mentionSends = mentions
    .filter(userId => userId !== comment.authorId) // don't notify yourself
    .map(userId =>
      notify.send({
        recipientId: userId,
        notificationId: "comment_mentioned",
        payload: { actorName: actor.name, postUrl: `/posts/${comment.postId}` },
        dedupeKey: `mention:${comment.postId}:${comment.authorId}:${userId}`,
        dedupeWindowMs: 5 * 60_000,
      })
    )

  // 2. Notify post author (unless they wrote the comment)
  const authorSend = comment.authorId !== comment.postAuthorId
    ? notify.send({
        recipientId: comment.postAuthorId,
        notificationId: "comment_on_your_post",
        payload: { actorName: actor.name, postUrl: `/posts/${comment.postId}` },
        dedupeKey: `reply:${comment.postId}:${comment.authorId}`,
        dedupeWindowMs: 5 * 60_000,
      })
    : null

  // 3. Notify watchers (lowest priority — often digested)
  const alreadyNotified = new Set([...mentions, comment.postAuthorId, comment.authorId])
  const watcherSends = watchers
    .filter(w => !alreadyNotified.has(w.userId))
    .map(w =>
      notify.send({
        recipientId: w.userId,
        notificationId: "comment_on_watched",
        payload: { actorName: actor.name, postUrl: `/posts/${comment.postId}` },
      })
    )

  await Promise.allSettled([...mentionSends, authorSend, ...watcherSends].filter(Boolean))
}
Design decisionWhy
Deduplicate the audienceA mentioned user who also watches the post should get the mention (higher priority), not both
Skip self-notificationsThe comment author shouldn't get "someone commented on your post" for their own comment
Different dedup keys per typeMention dedup scopes to (post, actor, target). Reply dedup scopes to (post, actor). Different collapse logic.
Promise.allSettledOne failed send (e.g., missing recipient) shouldn't block the others

Where to call the dispatcher

// Server action — fire after the mutation
"use server"
import { onCommentPosted } from "@/lib/notifications/on-comment-posted"

export async function addComment(postId: string, body: string) {
  const comment = await db.comments.create({ ... })

  // Fire-and-forget — don't block the user response
  void onCommentPosted(comment)

  return comment
}

// Or from a background job (webhook, queue worker):
export async function handleCommentWebhook(payload: CommentEvent) {
  await onCommentPosted(payload.comment) // safe to await — no user waiting
}
One file per domain event. Keep orchestrators in lib/notifications/on-*.ts. Each file owns the fan-out logic for one event — who gets notified, with what priority, and which dedup/idempotency keys to use. Your mutation code stays clean (one void onCommentPosted() call) and notification logic is testable in isolation.

Testing sends

Notification orchestration logic — who gets notified, with what keys, from which trigger — is easy to get wrong and hard to debug in production. Test at two levels: unit (the orchestrator in isolation) and integration (the full pipeline with real results).

Test setup

Create a test instance with memoryAdapter() and fakeEmailProvider() — zero external deps, instant delivery:

// test/helpers/notifykit.ts
import { createNotifyKit, memoryAdapter, fakeEmailProvider, channel, notification } from "@notifykitjs/core"

export const commentMentioned = notification({
  id: "comment_mentioned",
  payload: { actorName: "string", postTitle: "string", postUrl: "string" },
  channels: [
    channel.inbox()({ title: "{{actorName}} mentioned you", body: "In {{postTitle}}", actionUrl: "{{postUrl}}" }),
    channel.email()({ subject: "{{actorName}} mentioned you", body: "Open {{postUrl}}" }),
  ],
})

export function createTestNotify() {
  return createNotifyKit({
    notifications: [commentMentioned] as const,
    database: memoryAdapter(),
    providers: { email: fakeEmailProvider() },
  })
}

Integration: assert on SendResult

import { describe, it, expect, beforeEach } from "vitest"
import { createTestNotify } from "./helpers/notifykit"

describe("comment mention sends", () => {
  let notify: ReturnType<typeof createTestNotify>

  beforeEach(async () => {
    notify = createTestNotify()
    await notify.upsertRecipient({ id: "alice", email: "alice@test.com" })
  })

  it("delivers to inbox and email", async () => {
    const result = await notify.send({
      recipientId: "alice",
      notificationId: "comment_mentioned",
      payload: { actorName: "Rey", postTitle: "Launch", postUrl: "/p/1" },
    })

    expect(result.inboxItems).toHaveLength(1)
    expect(result.inboxItems[0].title).toBe("Rey mentioned you")
    expect(result.deliveries).toHaveLength(1)
    expect(result.deliveries[0].channel).toBe("email")
    expect(result.deliveries[0].status).toBe("sent")
    expect(result.skipped).toHaveLength(0)
  })

  it("deduplicates within window", async () => {
    const opts = {
      recipientId: "alice" as const,
      notificationId: "comment_mentioned" as const,
      payload: { actorName: "Rey", postTitle: "Launch", postUrl: "/p/1" },
      dedupeKey: "mention:p1:rey",
      dedupeWindowMs: 60_000,
    }

    const first = await notify.send(opts)
    const second = await notify.send(opts)

    expect(first.inboxItems).toHaveLength(1)
    expect(second.inboxItems).toHaveLength(0) // deduped
  })

  it("idempotency returns original result", async () => {
    const opts = {
      recipientId: "alice" as const,
      notificationId: "comment_mentioned" as const,
      payload: { actorName: "Rey", postTitle: "Launch", postUrl: "/p/1" },
      idempotencyKey: "job:abc",
    }

    const first = await notify.send(opts)
    const replay = await notify.send(opts)

    expect(replay.idempotent).toBe(true)
    expect(replay.notification?.id).toBe(first.notification?.id)
  })
})

Unit: test orchestrators in isolation

Your on-*.ts orchestrators are pure functions of the event. Test the logic (who gets notified, skip-self, dedup keys) without sending real notifications:

// lib/notifications/on-comment-posted.test.ts
import { describe, it, expect, vi } from "vitest"
import { onCommentPosted } from "./on-comment-posted"

// Mock the notify instance
const mockSend = vi.fn().mockResolvedValue({ inboxItems: [], deliveries: [] })
vi.mock("@/lib/notifykit", () => ({
  notify: { send: (...args) => mockSend(...args) },
}))

describe("onCommentPosted", () => {
  it("notifies mentioned users but not the author", async () => {
    await onCommentPosted({
      id: "c1",
      postId: "p1",
      authorId: "rey",
      mentions: ["alice", "bob", "rey"], // rey should be filtered
      postTitle: "Launch Plan",
    })

    expect(mockSend).toHaveBeenCalledTimes(2) // alice + bob, not rey
    expect(mockSend).not.toHaveBeenCalledWith(
      expect.objectContaining({ recipientId: "rey" })
    )
  })

  it("uses per-mention dedup keys", async () => {
    await onCommentPosted({
      id: "c1",
      postId: "p1",
      authorId: "rey",
      mentions: ["alice"],
      postTitle: "Launch",
    })

    expect(mockSend).toHaveBeenCalledWith(
      expect.objectContaining({
        dedupeKey: "mention:p1:rey:alice",
      })
    )
  })
})
Test levelWhat it provesI/O boundaryWhen it catches bugs
Unit (mocked send)Orchestration logic — who, skip-self, key designNo NotifyKit I/OBefore the notification system is even involved
Integration (memory adapter)Full pipeline — preferences, dedup, delivery, result shapeIn-memory database and fake providersPayload mismatches, template bugs, dedup window issues
E2E (real provider, staging)Actual email arrives, webhook hits endpointReal network, DNS, and providerProvider config, DNS, auth token expiry
Test the result, not the internals. Assert on result.inboxItems, result.skipped, and result.deliveries — not on database state or internal method calls. The result is the public contract; internals can change between versions without breaking your tests.