Explain & dry run

Before sending, you can ask NotifyKit exactly what would happen. The explain() method (and its dryRun equivalent) returns a full DeliveryExplanation without writing any records or triggering any deliveries.

Pick the right debugging tool

NotifyKit has three debugging surfaces. Each answers a different question — pick based on what you know and what went wrong:

You want to knowUseWhen
What would happen if I sent this now?explain() (this page)Before sending, or reproducing a reported issue without side effects
What did happen to a specific send?timeline()After sending — you have a notification record ID and want the full event log
What happened just now in my code?SendResult fieldsImmediately after send() returns — check skipped, deliveries, deferredChannels
Key distinction: explain() is predictive (what would happen), timeline() is forensic (what did happen), and SendResult is immediate (what just happened). Start with explain() when you can reproduce the scenario. Use timeline() when you have a record ID from a past send that failed.

Using explain()

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

console.log(explanation)

Using dryRun

Alternatively, pass dryRun: true to send():

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

Both are identical. There's also a notify.check() alias that reads better in some contexts.

What you get back

Here's a real explanation object. Read it top to bottom — it tells the full story of what the engine decided:

{
  // Pipeline-level decisions (checked first)
  wouldRateLimit: false,       // under threshold
  wouldDeduplicate: false,     // no matching dedup key
  wouldDigest: false,          // no digest configured
  wouldReplayIdempotent: false,// no matching idempotency key

  // Payload validation
  payloadValidation: { valid: true, errors: [] },

  // Notification metadata
  required: false,             // preferences apply

  // Rate limit state
  rateLimit: { current: 7, max: 30, windowMs: 3600000 },

  // Quiet hours state
  quietHours: { active: false, start: "22:00", end: "08:00", timezone: "America/New_York" },

  // Per-channel resolution
  channels: {
    inbox: {
      outcome: "deliver",      // ✓ will fire
      allowed: true,
      resolvedBy: "app_default",
      trail: [{ layer: "app_default", value: true }],
    },
    email: {
      outcome: "disabled",     // ✗ user opted out
      allowed: false,
      resolvedBy: "user_notification",
      trail: [
        { layer: "app_default", value: true },
        { layer: "notification_default", value: undefined },
        { layer: "tenant_setting", value: undefined },
        { layer: "user_notification", value: false },  // ← blocked here
      ],
    },
  },
}
Read it like a flowchart: pipeline flags first (rate limit? dedup? digest?) → if all pass, check each channel → for blocked channels, the trail array shows exactly which preference layer said no.

DeliveryExplanation shape

The explanation object gives you a complete picture of what the engine decided and why:

FieldWhat it tells you
channelsPer-channel resolution with outcome and preference trail
requiredWhether the notification bypasses preference checks
payloadValidationWhether the payload passes schema validation (with field-level errors)
wouldRateLimitWould exceed the configured rate limit
wouldDigestWould be buffered into a digest window
wouldDeduplicateWould be dropped as a duplicate
wouldReplayIdempotentIdempotency key already exists — would replay
rateLimitCurrent count vs max, and window size
quietHoursWhether quiet hours are active, and when they end

Channel outcomes

Each channel in the explanation has an outcome field. Outcomes fall into three categories — use them to decide your next step:

Will deliver

"deliver" — channel is clear. No action needed.

Permanently blocked

Won't deliver now or on retry. Fix the root cause (preferences, missing address, payload).

Deferred / absorbed

Not lost — will deliver later (quiet hours, digest) or was already handled (dedup, idempotent).

OutcomeCategoryMeaningAction
"deliver"SuccessWould be delivered normallyNone — working as intended
"disabled"BlockedDisabled by user preferencesCheck trail to see which layer blocked it
"unavailable"BlockedRecipient lacks destination (no email/phone)Prompt user to add the missing contact info
"invalid_payload"BlockedPayload validation would failCheck payloadValidation.errors for the specific field
"rate_limited"BlockedWould exceed the configured rate limitExpected — or widen max/windowMs if too tight
"delayed"DeferredWould be deferred by quiet hoursWill deliver when window ends — not a bug
"digested"DeferredWould be buffered into a digest windowWill deliver on next flush — check flushDigests() schedule
"deduplicated"AbsorbedMatched a recent dedup key — skippedExpected if same event fired twice within window
"idempotent"AbsorbedIdempotency key already exists — replayExpected on retries — original result returned
Blocked = fix something. Deferred = wait. Absorbed = safe to ignore. If you see "disabled" or "unavailable", there's a user-facing issue to resolve. If you see "delayed" or "digested", the notification will arrive — just not immediately.

Preference resolution trail

Each channel includes the full preference resolution trail — every layer that was consulted and what value it returned:

// explanation.channels[0].trail:
[
  { layer: "app_default", value: true },
  { layer: "notification_default", value: undefined },
  { layer: "tenant_setting", value: undefined },
  { layer: "user_notification", value: false },  // ← user disabled it
]
// resolvedBy: "user_notification"
// allowed: false

How to read it:

FieldMeaning
trail[].layerWhich resolution layer was checked
trail[].valuetrue = enabled, false = disabled, undefined = no opinion (pass through)
resolvedByThe most specific layer that returned a non-undefined value
allowedFinal answer — will this channel fire?

Preferences explain

For a focused view on just preference resolution (without the full delivery pipeline):

const prefExplanation = await notify.preferences.explain({
  recipientId: user.id,
  notificationId: "comment_mentioned",
})

// Returns PreferenceExplanation with channels, resolution trails,
// required status, classification, and category info.

Troubleshooting with explain

SymptomCheck this fieldCommon cause
User didn't get emailchannels[email].outcome"disabled" (preferences) or "delayed" (quiet hours)
Nothing delivered at allwouldRateLimit / wouldDeduplicateRate limit exceeded or duplicate within dedup window
Send succeeded but no inbox itemchannels[inbox].outcome"disabled" — user opted out of inbox for this notification
Payload validation failedpayloadValidation.errorsMissing required field or wrong type
Email delivered twicewouldReplayIdempotentMissing idempotencyKey on retried sends

Debugging workflow

When a user reports "I didn't get my notification," follow this sequence:

1
Reproduce with explain

Call explain() with the same inputs the original send used. No side effects — safe to run in production.

2
Check top-level flags

Is wouldRateLimit, wouldDeduplicate, or wouldDigest true? If so, the send never reached channels.

3
Inspect the failing channel

Find the channel in explanation.channels. Check outcome — it tells you exactly why: disabled, unavailable, delayed.

4
Read the preference trail

If outcome is "disabled", the trail array shows which layer blocked it and who set it.

scripts/diagnose.ts
const e = await notify.explain({
  recipientId: "user_456",
  notificationId: "comment_mentioned",
  payload: { actorName: "Rey", postTitle: "Q4 Plan", postUrl: "/posts/99" },
})

// Shortcut: find all blocked channels
const blocked = Object.entries(e.channels)
  .filter(([, ch]) => ch.outcome !== "deliver")
  .map(([name, ch]) => `${name}: ${ch.outcome} (by ${ch.resolvedBy ?? "system"})`)

console.log(blocked.length ? blocked : "All channels would deliver")

When to use it

Debugging

"Why didn't this user get an email?" — run explain, check the preference trail, see exactly which layer blocked it.

Admin tooling

Show operators a preview of what would happen before they trigger a broadcast to 10k users.

Testing

Assert expected delivery behavior in your test suite without actually sending emails or writing inbox rows.

Preference UIs

Show users the live effect of toggling a setting — "if you disable this, you'll stop receiving email but still see it in your inbox."

Building a support tool

Non-engineers handle most "I didn't get my notification" tickets. Give them a self-service endpoint that translates explain output into plain language:

app/api/admin/diagnose/route.ts
import { notify } from "@/lib/notifykit"
import { requireAdmin } from "@/lib/auth"

export async function GET(request: Request) {
  await requireAdmin(request)
  const url = new URL(request.url)
  const recipientId = url.searchParams.get("user")!
  const notificationId = url.searchParams.get("notification")!

  const explanation = await notify.explain({
    recipientId,
    notificationId,
    payload: getTestPayload(notificationId), // default test values
  })

  // Translate to support-friendly format
  const diagnosis = {
    wouldDeliver: Object.values(explanation.channels)
      .some(ch => ch.outcome === "deliver"),
    blockers: Object.entries(explanation.channels)
      .filter(([, ch]) => ch.outcome !== "deliver")
      .map(([name, ch]) => ({
        channel: name,
        reason: friendlyReason(ch.outcome, ch.resolvedBy),
      })),
    quietHours: explanation.quietHours?.active
      ? `Active until ${explanation.quietHours.resumesAt}`
      : "Not active",
    rateLimited: explanation.wouldRateLimit
      ? `${explanation.rateLimit.current}/${explanation.rateLimit.max} in window`
      : "Under limit",
  }

  return Response.json(diagnosis)
}

function friendlyReason(outcome: string, layer?: string): string {
  const reasons: Record<string, string> = {
    disabled: `User opted out (set by: ${layer ?? "unknown"})`,
    unavailable: "No email/phone on file for this user",
    delayed: "Held by quiet hours — will deliver when window ends",
    rate_limited: "Too many sends in window — dropped",
    deduplicated: "Already sent recently — skipped as duplicate",
    digested: "Batched into a digest — will arrive with next flush",
  }
  return reasons[outcome] ?? outcome
}
Support questionField to checkPlain-language answer
"Why no email?"blockers[email].reason"User opted out in their preferences" or "No email address on file"
"When will it arrive?"quietHours"Held until 8:00 AM EST"
"Did they get anything?"wouldDeliver"Yes, inbox will deliver" or "No, all channels blocked"
"Why duplicated / missing?"rateLimited"Hit 20/20 limit this hour — subsequent sends dropped"
Protect this endpoint. Explain reveals preference state and rate limit counts for any user. Gate it behind admin auth and log every access. Never expose it to the public API surface.

Testing with explain()

explain() is ideal for test suites — it evaluates the full pipeline without writing records or calling providers. Tests run faster, produce no side effects, and assert on delivery intent rather than delivery outcome.

ApproachWrites records?Calls providers?Best for
send()YesYes (or faked)Integration tests — verify the full delivery cycle end-to-end
explain()NoNoUnit tests — assert on routing decisions without side effects
send({ dryRun: true })NoNoSame as explain() — use whichever reads better in context
tests/notification-routing.test.ts
import { describe, it, expect, beforeAll } from "vitest"
import { createNotifyKit, memoryAdapter, fakeEmailProvider } from "@notifykitjs/core"
import { commentMentioned } from "./notifications"

const notify = createNotifyKit({
  notifications: [commentMentioned] as const,
  database: memoryAdapter(),
  providers: { email: fakeEmailProvider() },
})

describe("notification routing", () => {
  beforeAll(async () => {
    await notify.upsertRecipient({ id: "user_1", email: "a@test.com" })
    await notify.upsertRecipient({ id: "user_2" }) // no email
  })

  it("delivers to inbox and email when recipient has both", async () => {
    const e = await notify.explain({
      recipientId: "user_1",
      notificationId: "comment_mentioned",
      payload: { actorName: "Rey", postUrl: "/p/1" },
    })

    expect(e.channels.inbox.outcome).toBe("deliver")
    expect(e.channels.email.outcome).toBe("deliver")
    expect(e.wouldRateLimit).toBe(false)
    expect(e.wouldDeduplicate).toBe(false)
  })

  it("skips email when recipient has no address", async () => {
    const e = await notify.explain({
      recipientId: "user_2",
      notificationId: "comment_mentioned",
      payload: { actorName: "Rey", postUrl: "/p/1" },
    })

    expect(e.channels.inbox.outcome).toBe("deliver")
    expect(e.channels.email.outcome).toBe("unavailable")
  })

  it("respects preference opt-out", async () => {
    await notify.preferences.update({
      recipientId: "user_1",
      notificationId: "comment_mentioned",
      channels: { email: false },
    })

    const e = await notify.explain({
      recipientId: "user_1",
      notificationId: "comment_mentioned",
      payload: { actorName: "Rey", postUrl: "/p/1" },
    })

    expect(e.channels.email.outcome).toBe("disabled")
    expect(e.channels.email.resolvedBy).toBe("user_notification")
  })

  it("reports rate limit status without consuming budget", async () => {
    // Send 30 times to hit the rate limit
    for (let i = 0; i < 30; i++) {
      await notify.send({
        recipientId: "user_1",
        notificationId: "comment_mentioned",
        payload: { actorName: `User ${i}`, postUrl: "/p/1" },
      })
    }

    // explain() reports the limit WITHOUT counting against it
    const e = await notify.explain({
      recipientId: "user_1",
      notificationId: "comment_mentioned",
      payload: { actorName: "Next", postUrl: "/p/1" },
    })

    expect(e.wouldRateLimit).toBe(true)
    expect(e.rateLimit.current).toBe(30)
    expect(e.rateLimit.max).toBe(30)
  })
})

What to assert on

You want to verifyAssert onExample assertion
Channel routing is correcte.channels[ch].outcomeexpect(e.channels.email.outcome).toBe("deliver")
Preferences are respectede.channels[ch].resolvedByexpect(e.channels.email.resolvedBy).toBe("user_notification")
Quiet hours defer correctlye.quietHours.activeexpect(e.quietHours.active).toBe(true)
Rate limits fire at thresholde.wouldRateLimitexpect(e.wouldRateLimit).toBe(true)
Dedup keys worke.wouldDeduplicateexpect(e.wouldDeduplicate).toBe(true) (after a prior send)
Required bypasses prefse.required + e.channels[ch].outcomeexpect(e.channels.email.outcome).toBe("deliver") even when opted out
Payload validation catches errorse.payloadValidation.errorsexpect(e.payloadValidation.errors).toContain("actorName")
explain() doesn't consume rate limit budget. You can call it 100 times in a test without affecting the counter. This makes it safe to assert on rate limit state from multiple test cases without interference — only actual send() calls increment the counter.

Testing preference resolution layers

The preference trail in explain output lets you verify that your resolution hierarchy works correctly — especially useful when you have tenant defaults, category overrides, and per-notification preferences all interacting:

tests/preference-resolution.test.ts
it("tenant default overrides app default", async () => {
  // Setup: tenant "free_org" has email off by default
  const e = await notify.explain({
    recipientId: "free_user",
    notificationId: "comment_mentioned",
    payload: { actorName: "Rey", postUrl: "/p/1" },
  })

  // Verify the trail shows which layer won
  const emailTrail = e.channels.email.trail
  const tenantLayer = emailTrail.find(t => t.layer === "tenant_setting")

  expect(tenantLayer?.value).toBe(false)
  expect(e.channels.email.resolvedBy).toBe("tenant_setting")
  expect(e.channels.email.outcome).toBe("disabled")
})

it("user preference overrides tenant default", async () => {
  // User explicitly opted back in despite tenant default
  await notify.preferences.update({
    recipientId: "free_user",
    notificationId: "comment_mentioned",
    channels: { email: true },
  })

  const e = await notify.explain({
    recipientId: "free_user",
    notificationId: "comment_mentioned",
    payload: { actorName: "Rey", postUrl: "/p/1" },
  })

  // User preference wins over tenant default
  expect(e.channels.email.resolvedBy).toBe("user_notification")
  expect(e.channels.email.outcome).toBe("deliver")
})
Test your resolution hierarchy early. Preference bugs are invisible until a user reports "I never got that email" — and then you're debugging in production. Write explain-based tests that cover: app default → category override → tenant override → user override → required bypass. Five tests, each one line of setup.