Explain and dry run
See exactly what a send would do, channel by channel, without storing or delivering anything.
Which Tool
| You want to know | Use |
|---|---|
| What would happen if I sent this now? | explain(), on this page |
| What did happen to a send? | timeline(), with the notification record id |
| What happened in the call I just made? | The SendResult: skipped, deferredChannels, rateLimited, digested |
Using It
explain() takes exactly what send() takes and runs the same checks, but writes nothing and calls no provider. It is safe to run in production.
const explanation = await notify.explain({
recipientId: "user_123",
notificationId: "comment_mentioned",
payload: { actorName: "Rey", postTitle: "Launch Plan", postUrl: "/posts/42" },
})notify.send({ ...input, dryRun: true }) and notify.check() do the same thing. Like send(), it throws for an unknown notification id or a recipient you have not created with upsertRecipient(). Idempotency and deduplication are only checked if you pass the same idempotencyKey or dedupeKey the real send would use.
What Comes Back
{
recipientId: "user_123",
notificationId: "comment_mentioned",
required: false,
payloadValidation: { valid: true, fields: [] },
wouldReplayIdempotent: false,
wouldDeduplicate: false,
wouldRateLimit: false,
wouldDigest: false,
rateLimit: { current: 7, max: 30, windowMs: 3_600_000 },
quietHours: { active: false, resumesAt: null },
channels: [
{
channel: "inbox",
outcome: "deliver",
allowed: true,
resolvedBy: "app_default",
...
},
{
channel: "email",
outcome: "disabled",
allowed: false,
resolvedBy: "user_notification",
reason: "...",
trail: [
{ layer: "app_default", value: true },
{ layer: "notification_default", value: undefined },
{ layer: "category_default", value: undefined },
{ layer: "tenant_setting", value: undefined },
{ layer: "user_global", value: undefined },
{ layer: "user_category", value: undefined },
{ layer: "user_notification", value: false }, // turned off here
],
},
],
}| Field | Tells you |
|---|---|
channels | One entry per channel: its outcome, whether preferences allow it, and the trail that decided it |
payloadValidation | Whether the payload passes, with a fields list of what is wrong |
wouldReplayIdempotent | The idempotency key was used before; idempotency has the earlier record id |
wouldDeduplicate | The dedupe key is still inside its window; dedupe has the details |
wouldRateLimit | The limit is reached; rateLimit has the current count |
wouldDigest | The notification has a digest, so this send would be batched; digest has the window |
quietHours | active and resumesAt, or null if the recipient has none |
required | The notification ignores preferences |
Channel Outcomes
Each channel gets the first outcome that applies, in this order. Preferences come first, so a channel the user turned off shows disabled even when the whole send would also be rate-limited.
| Outcome | Meaning | What to do |
|---|---|---|
unavailable | No address: the recipient has no email or phone | Ask the user for one |
disabled | Preferences turn this channel off | Read the trail |
invalid_payload | The payload fails validation | See payloadValidation.fields |
idempotent | This exact send already happened | Nothing; the earlier result would be returned |
deduplicated | The same event was sent inside the window | Nothing, unless the key is too broad |
rate_limited | Over the limit for the window | Wait, or raise the limit |
digested | Would join a batch | It arrives when the digest flushes |
delayed | Email, SMS, or webhook held by quiet hours | It arrives at quietHours.resumesAt |
deliver | Would be delivered now | Nothing |
The Preference Trail
trail lists every preference layer in order, from the app default to the user’s setting for this notification. undefined means the layer has no opinion. The last layer with a value wins and is named in resolvedBy, unless one of two layers is added at the end:
required_override: the notification isrequired, so the channel is allowed whatever the user chose.destination_unavailable: there is nowhere to send it, so it is off even when required.
For the trail without the rest of the pipeline, use notify.preferences.explain(), which takes a recipient, a notification id, and an optional scope. See Preferences.
Debugging a Missing Message
- Run
explain()with the inputs the original send used. - If
wouldRateLimit,wouldDeduplicate, orwouldReplayIdempotentis true, the send stopped before any channel. - Otherwise find the channel in
channelsand read itsoutcome. - For
disabled, thetrailshows which layer turned it off.
const e = await notify.explain(input)
const blocked = e.channels
.filter((ch) => ch.outcome !== "deliver")
.map((ch) => `${ch.channel}: ${ch.outcome} (${ch.resolvedBy})`)
console.log(blocked.length ? blocked : "Every channel would deliver")If explain() says deliver but the message never arrived, the problem came later, at the provider. Pull the timeline for that send.
Support Tools
An explanation is a good basis for an internal “why didn’t this user get it?” page. Put it behind admin auth: it reveals a user’s preferences and rate-limit counts.
export async function GET(request: Request) {
await requireAdmin(request)
const url = new URL(request.url)
const explanation = await notify.explain({
recipientId: url.searchParams.get("user")!,
notificationId: url.searchParams.get("notification")! as "comment_mentioned",
payload: samplePayload,
})
return Response.json({
wouldDeliver: explanation.channels.some((ch) => ch.outcome === "deliver"),
channels: explanation.channels.map(({ channel, outcome, resolvedBy }) => ({
channel, outcome, resolvedBy,
})),
quietHoursUntil: explanation.quietHours?.resumesAt ?? null,
})
}The HTTP handler also serves GET /explain and GET /preferences/explain, limited to the signed-in user, for a preferences page that shows what a toggle would change.
In Tests
Because it has no side effects, explain() is the quickest way to assert on routing: which channels a send would use and why, without faking providers or cleaning up records.
it("respects an email opt-out", async () => {
await notify.upsertRecipient({ id: "u1", email: "a@example.com" })
await notify.preferences.update({
recipientId: "u1",
notificationId: "comment_mentioned",
channels: { email: false },
})
const e = await notify.explain({
recipientId: "u1",
notificationId: "comment_mentioned",
payload,
})
const email = e.channels.find((ch) => ch.channel === "email")
expect(email?.outcome).toBe("disabled")
expect(email?.resolvedBy).toBe("user_notification")
})Use a real send() when you want to check what was delivered. See Testing and dev mode.