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 know | Use | When |
|---|---|---|
| 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 fields | Immediately after send() returns — check skipped, deliveries, deferredChannels |
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
],
},
},
}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:
| Field | What it tells you |
|---|---|
channels | Per-channel resolution with outcome and preference trail |
required | Whether the notification bypasses preference checks |
payloadValidation | Whether the payload passes schema validation (with field-level errors) |
wouldRateLimit | Would exceed the configured rate limit |
wouldDigest | Would be buffered into a digest window |
wouldDeduplicate | Would be dropped as a duplicate |
wouldReplayIdempotent | Idempotency key already exists — would replay |
rateLimit | Current count vs max, and window size |
quietHours | Whether 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).
| Outcome | Category | Meaning | Action |
|---|---|---|---|
"deliver" | Success | Would be delivered normally | None — working as intended |
"disabled" | Blocked | Disabled by user preferences | Check trail to see which layer blocked it |
"unavailable" | Blocked | Recipient lacks destination (no email/phone) | Prompt user to add the missing contact info |
"invalid_payload" | Blocked | Payload validation would fail | Check payloadValidation.errors for the specific field |
"rate_limited" | Blocked | Would exceed the configured rate limit | Expected — or widen max/windowMs if too tight |
"delayed" | Deferred | Would be deferred by quiet hours | Will deliver when window ends — not a bug |
"digested" | Deferred | Would be buffered into a digest window | Will deliver on next flush — check flushDigests() schedule |
"deduplicated" | Absorbed | Matched a recent dedup key — skipped | Expected if same event fired twice within window |
"idempotent" | Absorbed | Idempotency key already exists — replay | Expected on retries — original result returned |
"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: falseHow to read it:
| Field | Meaning |
|---|---|
trail[].layer | Which resolution layer was checked |
trail[].value | true = enabled, false = disabled, undefined = no opinion (pass through) |
resolvedBy | The most specific layer that returned a non-undefined value |
allowed | Final 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
| Symptom | Check this field | Common cause |
|---|---|---|
| User didn't get email | channels[email].outcome | "disabled" (preferences) or "delayed" (quiet hours) |
| Nothing delivered at all | wouldRateLimit / wouldDeduplicate | Rate limit exceeded or duplicate within dedup window |
| Send succeeded but no inbox item | channels[inbox].outcome | "disabled" — user opted out of inbox for this notification |
| Payload validation failed | payloadValidation.errors | Missing required field or wrong type |
| Email delivered twice | wouldReplayIdempotent | Missing idempotencyKey on retried sends |
Debugging workflow
When a user reports "I didn't get my notification," follow this sequence:
Call explain() with the same inputs the original send used. No side effects — safe to run in production.
Is wouldRateLimit, wouldDeduplicate, or wouldDigest true? If so, the send never reached channels.
Find the channel in explanation.channels. Check outcome — it tells you exactly why: disabled, unavailable, delayed.
If outcome is "disabled", the trail array shows which layer blocked it and who set it.
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:
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 question | Field to check | Plain-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" |
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.
| Approach | Writes records? | Calls providers? | Best for |
|---|---|---|---|
send() | Yes | Yes (or faked) | Integration tests — verify the full delivery cycle end-to-end |
explain() | No | No | Unit tests — assert on routing decisions without side effects |
send({ dryRun: true }) | No | No | Same as explain() — use whichever reads better in context |
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 verify | Assert on | Example assertion |
|---|---|---|
| Channel routing is correct | e.channels[ch].outcome | expect(e.channels.email.outcome).toBe("deliver") |
| Preferences are respected | e.channels[ch].resolvedBy | expect(e.channels.email.resolvedBy).toBe("user_notification") |
| Quiet hours defer correctly | e.quietHours.active | expect(e.quietHours.active).toBe(true) |
| Rate limits fire at threshold | e.wouldRateLimit | expect(e.wouldRateLimit).toBe(true) |
| Dedup keys work | e.wouldDeduplicate | expect(e.wouldDeduplicate).toBe(true) (after a prior send) |
| Required bypasses prefs | e.required + e.channels[ch].outcome | expect(e.channels.email.outcome).toBe("deliver") even when opted out |
| Payload validation catches errors | e.payloadValidation.errors | expect(e.payloadValidation.errors).toContain("actorName") |
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:
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")
})