Channels

A channel is a delivery mechanism. Each notification definition lists the channels it should be sent through. NotifyKit ships four channel types — inbox, email, SMS, and webhook.

ChannelTypeHow it delivers
InboxPullWrites a row — user fetches it via hook or API
EmailPushSends via your email provider (Resend, Postmark, etc.)
SMSPushSends via your SMS provider (Twilio, Vonage, etc.)
WebhookPushPOSTs a signed JSON payload to a URL
Pull vs push matters. Pull channels (inbox) are never affected by quiet hours or delivery failures — the item is simply there when the user looks. Push channels (email, SMS, webhook) go through the full pipeline: queue, retry, quiet hours, fallback.

At a glance

Minimal working config for each channel type. Copy the one you need, then scroll down for options and advanced patterns:

Inbox

Pull channel — writes a row, user fetches it.

inbox({ title: "{{actorName}} mentioned you", body: "In {{postTitle}}", actionUrl: "{{postUrl}}", })

Email

Push channel — queued to your email provider.

email({ subject: "{{actorName}} mentioned you", body: "Open {{postUrl}}\n\n{{_unsubscribeUrl}}", })

SMS

Push channel — sent via Twilio/Vonage/etc.

sms({ body: "Your code is {{code}}", })

Webhook

Push channel — signed POST to a URL.

webhook({ url: "https://hooks.slack.com/...", })

Inbox

The inbox channel writes a row to your database. It's user-pulled (the recipient fetches it via the React hook or REST API), so it's never subject to quiet hours or delivery failures.

PropertyRequiredDescription
titleYesMain text shown in the inbox list
bodyNoSecondary text or preview
actionUrlNoLink destination when the item is clicked
import { channel } from "@notifykitjs/core"

const inbox = channel.inbox()

inbox({
  title: "{{actorName}} mentioned you",
  body: "In {{postTitle}}",
  actionUrl: "{{postUrl}}",
})

Inbox items support read/unread state, archiving, and deletion. All mutations publish realtime events when a realtime adapter is configured.

Email

The email channel queues a delivery job that your configured email provider sends.

PropertyRequiredDescription
subjectYesEmail subject line (supports template variables)
bodyYesPlain text body (supports template variables)
const email = channel.email()

email({
  subject: "{{actorName}} mentioned you in {{postTitle}}",
  body: "Open {{postUrl}} to reply.\n\nUnsubscribe: {{_unsubscribeUrl}}",
})
Built-in variable. {{_unsubscribeUrl}} is injected automatically — it links to the one-click unsubscribe handler. Always include it in email bodies.

SMS

The SMS channel sends a text message through your configured SMS provider.

PropertyRequiredDescription
bodyYesMessage text (max ~160 chars recommended)
const sms = channel.sms()

sms({
  body: "Your verification code is {{code}}",
})
Recipient requires phone. If the recipient doesn't have a phone number set, the SMS channel resolves to "unavailable" and is skipped.

Webhook

The webhook channel POSTs a signed JSON envelope to a URL. Use it for Slack integrations, custom destinations, or forwarding to external services.

PropertyRequiredDescription
urlYesDestination endpoint
headersNoAdditional HTTP headers
const webhook = channel.webhook()

webhook({
  url: "https://hooks.slack.com/services/T.../B.../xxx",
  headers: { "Content-Type": "application/json" },
})

Signature verification

When a secret is configured on the webhook provider, every request includes an x-notifykit-signature header.

1
NotifyKit signs

HMAC-SHA256 of the raw JSON body with your shared secret.

2
Header sent

x-notifykit-signature: sha256=<hex> included on every POST.

3
Receiver verifies

Compute HMAC-SHA256 of the raw body with the same secret. Compare to header value.

Webhook payload format

Every webhook delivery POSTs a JSON envelope with a consistent shape. Your receiver can rely on this structure regardless of the notification type:

// What your endpoint receives:
{
  "event": "notification.delivered",
  "notificationId": "deploy_completed",
  "recipientId": "user_123",
  "payload": {
    "projectName": "api-gateway",
    "status": "succeeded",
    "sha": "abc123f"
  },
  "metadata": {
    "sentAt": "2026-06-27T14:30:00.000Z",
    "deliveryId": "del_abc123",
    "attempt": 1
  }
}
FieldTypeDescription
eventstringAlways "notification.delivered" for webhook deliveries
notificationIdstringThe notification definition ID — use for routing on the receiver
recipientIdstringWho this notification is for
payloadobjectYour full typed payload, as passed to send()
metadata.sentAtISO 8601When the delivery was dispatched
metadata.deliveryIdstringUnique delivery ID — use for idempotency on the receiver
metadata.attemptnumberRetry attempt (1 = first try, 2+ = retry)

Verifying signatures (receiver code)

When your webhook provider has a secret configured, verify the x-notifykit-signature header before processing:

// Your webhook receiver (any framework)
import { createHmac, timingSafeEqual } from "node:crypto"

const WEBHOOK_SECRET = process.env.NOTIFYKIT_WEBHOOK_SECRET!

function verifySignature(rawBody: string, signatureHeader: string): boolean {
  const expected = createHmac("sha256", WEBHOOK_SECRET)
    .update(rawBody)
    .digest("hex")

  const received = signatureHeader.replace("sha256=", "")

  return timingSafeEqual(
    Buffer.from(expected, "hex"),
    Buffer.from(received, "hex"),
  )
}

// Express / Node.js example:
app.post("/webhooks/notifykit", (req, res) => {
  const signature = req.headers["x-notifykit-signature"]
  if (!signature || !verifySignature(req.rawBody, signature)) {
    return res.status(401).json({ error: "Invalid signature" })
  }

  const { notificationId, payload, metadata } = req.body
  // Process the webhook...
  res.status(200).json({ received: true })
})

// Next.js Route Handler:
export async function POST(request: Request) {
  const rawBody = await request.text()
  const signature = request.headers.get("x-notifykit-signature")

  if (!signature || !verifySignature(rawBody, signature)) {
    return Response.json({ error: "Invalid signature" }, { status: 401 })
  }

  const body = JSON.parse(rawBody)
  // Process...
  return Response.json({ received: true })
}
Security detailWhy
timingSafeEqualPrevents timing attacks — a naive === comparison leaks info about which bytes matched
Verify on raw bodyThe HMAC is computed over the raw string, not a parsed-and-re-serialized object
Return 401 on failureNotifyKit retries on 5xx but not 4xx — a 401 tells it to stop immediately
Check metadata.deliveryIdUse as an idempotency key on the receiver to handle retries gracefully
Always use the raw body for verification. If your framework parses the body before you can access the raw string (e.g. Express with json() middleware), configure a raw body parser for your webhook route. Parsing and re-serializing can change key ordering or whitespace, breaking the signature.

Formatting for external services

External services like Slack and Discord expect specific payload shapes. Use render() on the webhook channel to transform your payload into the format each service expects:

// Slack: expects { text } or { blocks }
notification({
  id: "deploy_completed",
  payload: {
    projectName: "string",
    status: "string",
    sha: "string",
    actorName: "string",
  },
  channels: [
    inbox({ title: "Deploy {{status}}: {{projectName}}" }),
    webhook({
      url: "https://hooks.slack.com/services/T.../B.../xxx",
      render: (payload) => ({
        body: JSON.stringify({
          text: `Deploy *${payload.status}*: ${payload.projectName} (${payload.sha.slice(0, 7)})`,
          blocks: [
            {
              type: "section",
              text: {
                type: "mrkdwn",
                text: `*${payload.projectName}* deployed by ${payload.actorName}`,
              },
            },
            {
              type: "context",
              elements: [
                { type: "mrkdwn", text: `Status: ${payload.status} | SHA: \`${payload.sha.slice(0, 7)}\`` },
              ],
            },
          ],
        }),
      }),
    }),
  ],
})

// Discord: expects { content } or { embeds }
webhook({
  url: "https://discord.com/api/webhooks/123/abc",
  render: (payload) => ({
    body: JSON.stringify({
      content: `Deploy **${payload.status}**: ${payload.projectName}`,
      embeds: [{
        title: payload.projectName,
        description: `Deployed by ${payload.actorName}`,
        color: payload.status === "succeeded" ? 0x00ff00 : 0xff0000,
        fields: [
          { name: "SHA", value: `\`${payload.sha.slice(0, 7)}\``, inline: true },
          { name: "Status", value: payload.status, inline: true },
        ],
      }],
    }),
  }),
})
ServiceRequired fieldRich formatting
Slacktext (fallback)blocks array with Block Kit elements
Discordcontent (plain text)embeds array with title, description, color, fields
Microsoft Teamstext or @type: MessageCardAdaptive Cards via attachments
Custom endpointWhatever your API expectsFull control via render()
Per-user webhook URLs. For integrations where each user configures their own endpoint (like a Slack incoming webhook per workspace), store the URL on the recipient or in the payload and use url: "{{webhookUrl}}" with a condition that skips when no URL is set. See conditional channels below.

Channel behavior during send

Key insight. Inbox always delivers immediately (it's just a DB write). Everything else goes through the queue and can be deferred, retried, or skipped.
ChannelDeliveryQuiet hoursPreferencesRetriesFallback
InboxImmediate (DB write)Not affectedRespects opt-outN/A (can't fail)N/A
EmailVia queue + providerDeferred until window endsRespects opt-outUp to 5 with backoffSupported
SMSVia queue + providerDeferred until window endsRespects opt-outUp to 5 with backoffSupported
WebhookVia queue + providerDeferred until window endsRespects opt-outUp to 5 with backoffSupported

Retry count and backoff are configured globally via Providers & queues. Fallback channels are configured per-notification — see Fallback channels.

Choosing channels for your notifications

Not every notification needs every channel. Match urgency to intrusiveness — higher urgency means more channels and less user control:

Critical

Act now. User must see this immediately regardless of context. Multiple channels ensure delivery.

ChannelsEmail + SMS + Inbox
Configrequired: true + fallback
ExamplesSecurity alert, 2FA, payment failed

Important

Act soon. User should know within minutes but can choose how they're reached.

ChannelsEmail + Inbox
ConfigDefault — user can opt out
ExamplesTeam invite, mention, task assigned

Informational

FYI. Nice to know but not worth an interruption. User checks on their own time.

ChannelsInbox only
ConfigdefaultChannels: { email: false }
ExamplesNew follower, post liked, deploy OK

System-to-system

Machine consumer. No human reads this directly — it triggers automation or syncs state.

ChannelsWebhook (+ Inbox as audit)
ConfigRate-limited, signed payload
ExamplesSlack alert, CI webhook, analytics
Start with inbox + email, expand from there. Most apps only need two channels at launch. Add SMS when you have time-critical notifications, and webhooks when you integrate with external services. Users can always turn off channels they don't want via preferences.

Conditional channels

Sometimes a channel should only fire based on runtime data — SMS only for high-severity alerts, webhook only when a URL is configured, or email only for external users. Use a condition function on any channel to gate delivery at send time:

notification({
  id: "incident_alert",
  payload: {
    severity: "string",    // "low" | "medium" | "high" | "critical"
    title: "string",
    dashboardUrl: "string",
  },
  channels: [
    // Inbox: always fires
    inbox({
      title: "Incident: {{title}}",
      actionUrl: "{{dashboardUrl}}",
    }),

    // Email: fires for medium+ severity
    email({
      condition: (payload) => payload.severity !== "low",
      subject: "[{{severity}}] {{title}}",
      body: "View dashboard: {{dashboardUrl}}\n\nUnsubscribe: {{_unsubscribeUrl}}",
    }),

    // SMS: fires only for critical
    sms({
      condition: (payload) => payload.severity === "critical",
      body: "CRITICAL: {{title}} — check dashboard immediately",
    }),
  ],
})

When a condition returns false, the channel is skipped with reason: "condition_false" in the result — no delivery attempt, no retry, no fallback trigger.

PatternConditionUse case
Severity escalation(p) => p.severity === "critical"Only page oncall for critical incidents, not every warning
Feature flag(p) => p.smsEnabled === trueGradually roll out SMS to users who opted into the beta
Payload presence(p) => !!p.webhookUrlOnly POST webhook if the user has configured an endpoint
User type(p) => p.userType === "external"Email external collaborators but only inbox internal team
// Dynamic webhook URL from payload:
notification({
  id: "deploy_completed",
  payload: {
    projectName: "string",
    status: "string",
    webhookUrl: "string",  // empty string if not configured
  },
  channels: [
    inbox({
      title: "Deploy {{status}}: {{projectName}}",
    }),
    webhook({
      condition: (payload) => !!payload.webhookUrl,
      url: "{{webhookUrl}}",   // dynamic — comes from payload
    }),
  ],
})
Conditions vs preferences. Conditions are developer-controlled routing logic ("only SMS for critical"). Preferences are user-controlled opt-in/out ("I don't want email"). Both are checked at send time — conditions first, then preferences. A channel that fails its condition is never checked against preferences.
Conditions don't trigger fallbacks. A condition_false skip is intentional routing, not a failure. Fallbacks only fire on delivery failures (channel.failed) or missing destinations (missing_address). If you need a backup for a conditionally-skipped channel, define the backup as a separate channel with the inverse condition.

Putting it together

Here's a single notification that uses three channels. Each renders the same payload differently based on the medium:

notification({
  id: "task_assigned",
  payload: {
    assignerName: "string",
    taskTitle: "string",
    taskUrl: "string",
    projectName: "string",
  },
  channels: [
    inbox({
      title: "{{assignerName}} assigned you a task",
      body: "{{taskTitle}} in {{projectName}}",
      actionUrl: "{{taskUrl}}",
    }),
    email({
      subject: "New task: {{taskTitle}}",
      body: "{{assignerName}} assigned you '{{taskTitle}}' in {{projectName}}.\n\nOpen it: {{taskUrl}}\n\nUnsubscribe: {{_unsubscribeUrl}}",
    }),
    webhook({
      url: "https://hooks.slack.com/services/T.../B.../xxx",
    }),
  ],
  // Pipeline options (all optional):
  required: false,              // user can opt out per channel
  defaultChannels: { inbox: true, email: true, webhook: true },
})
What happens at send timeInboxEmailWebhook
Preferences checkedUser opted out? → skipUser opted out? → skipUser opted out? → skip
Destination resolvedAlways available (DB write)Needs email on recipientURL hardcoded in config
Quiet hoursDelivers immediatelyDeferred if in windowDeferred if in window
DeliveryInstant DB writeQueued → providerQueued → POST
Each channel is independent. If email fails after retries, the inbox item and webhook delivery are unaffected. Use fallbacks to fire an alternate channel when one fails.

Template syntax

Channel strings support {{variable}} interpolation. Variables come from the payload you pass to send(), plus a few built-in variables injected by the engine.

// Definition:
notification({
  id: "comment_mentioned",
  payload: { actorName: "string", postTitle: "string", postUrl: "string" },
  channels: [
    inbox({ title: "{{actorName}} mentioned you", body: "In {{postTitle}}", actionUrl: "{{postUrl}}" }),
    email({ subject: "{{actorName}} mentioned you in {{postTitle}}", body: "Open {{postUrl}}" }),
  ],
})

// Send:
await notify.send({
  recipientId: user.id,
  notificationId: "comment_mentioned",
  payload: { actorName: "Rey", postTitle: "Launch Plan", postUrl: "/posts/42" },
})
// → inbox title resolves to "Rey mentioned you"
// → email subject resolves to "Rey mentioned you in Launch Plan"
Variable typeSourceExamples
Payload fieldsYour payload object{{actorName}}, {{orderNumber}}, {{postUrl}}
Built-in: unsubscribeEngine (requires unsubscribe config){{_unsubscribeUrl}} — HMAC-signed one-click link
Built-in: recipientRecipient record fields{{_recipient.name}}, {{_recipient.email}}
Missing variables render as empty strings. If you reference {{foo}} but don't pass foo in the payload, the template outputs "" — no error is thrown at runtime. Use payload validation (typed schemas) to catch missing fields at send time.

Built-in variables

Variables prefixed with _ are injected by the engine and don't need to be in your payload schema:

VariableAvailable inValue
{{_unsubscribeUrl}}Email onlySigned URL to unsubscribe from this notification. Requires unsubscribe config.
{{_recipient.name}}All channelsRecipient's name field from upsertRecipient()
{{_recipient.email}}All channelsRecipient's email address
{{_notificationId}}All channelsThe notification's ID (useful in webhook payloads for routing)
// Using built-in variables in an email:
email({
  subject: "Hi {{_recipient.name}}, {{actorName}} mentioned you",
  body: `Open {{postUrl}} to reply.

Unsubscribe: {{_unsubscribeUrl}}`,
})
Templates are intentionally simple. No conditionals, no loops, no filters. If you need complex rendering (HTML emails, i18n, conditional sections), use a render() function on your channel config instead — it receives the full typed payload and returns the final string.

Using render() for complex templates

When {{variable}} interpolation isn't enough — HTML emails, conditional sections, loops, or i18n — use a render() function instead. It receives the typed payload and returns the final string for each field.

ApproachSupportsBest for
Template strings ({{var}})Variable substitution onlySimple notifications — title, subject, short body
render() functionConditionals, loops, HTML, i18n, any JS logicHTML emails, dynamic content, multi-language, complex formatting

Basic render()

Pass a render function that returns the channel fields. It receives the full typed payload:

notification({
  id: "order_shipped",
  payload: {
    orderNumber: "string",
    itemCount: "number",
    trackingUrl: "string",
    expedited: "boolean",
  },
  channels: [
    inbox({
      render: (payload) => ({
        title: `Order ${payload.orderNumber} shipped`,
        body: payload.expedited
          ? `${payload.itemCount} items — expedited shipping`
          : `${payload.itemCount} items on the way`,
        actionUrl: payload.trackingUrl,
      }),
    }),
    email({
      render: (payload) => ({
        subject: `Your order ${payload.orderNumber} has shipped`,
        body: [
          `Hi! Your order with ${payload.itemCount} item${payload.itemCount > 1 ? "s" : ""} is on the way.`,
          payload.expedited ? "Expedited delivery — arriving in 1-2 days." : "",
          `Track your package: ${payload.trackingUrl}`,
          "",
          `Unsubscribe: {{_unsubscribeUrl}}`,
        ].filter(Boolean).join("\n"),
      }),
    }),
  ],
})
render() and templates can mix. Inside a render() return value, you can still use {{_unsubscribeUrl}} — built-in variables are interpolated after your function returns. Only payload variables need to be handled in the function.

HTML emails

For rich HTML emails, use render() with any templating approach — template literals, React email, or a library like mjml:

import { renderEmailHtml } from "@/lib/email-templates"

notification({
  id: "weekly_digest",
  payload: {
    recipientName: "string",
    highlights: "string",
    unreadCount: "number",
    weekOf: "string",
  },
  channels: [
    email({
      render: (payload) => ({
        subject: `Your weekly digest — ${payload.weekOf}`,
        body: renderEmailHtml({
          heading: `Hey ${payload.recipientName}, here's your week`,
          sections: [
            { title: "Highlights", content: payload.highlights },
            { title: "Unread", content: `${payload.unreadCount} notifications waiting` },
          ],
          footer: { unsubscribeUrl: "{{_unsubscribeUrl}}" },
        }),
        html: true,
      }),
    }),
  ],
})

Internationalization (i18n)

Use render() with your i18n library to send notifications in the recipient's language:

import { t } from "@/lib/i18n"

notification({
  id: "comment_mentioned",
  payload: {
    actorName: "string",
    postTitle: "string",
    postUrl: "string",
    locale: "string",
  },
  channels: [
    inbox({
      render: (payload) => ({
        title: t(payload.locale, "mention.title", { actor: payload.actorName }),
        body: t(payload.locale, "mention.body", { post: payload.postTitle }),
        actionUrl: payload.postUrl,
      }),
    }),
    email({
      render: (payload) => ({
        subject: t(payload.locale, "mention.email_subject", {
          actor: payload.actorName,
          post: payload.postTitle,
        }),
        body: t(payload.locale, "mention.email_body", {
          actor: payload.actorName,
          url: payload.postUrl,
        }),
      }),
    }),
  ],
})
PatternHow to pass localeTrade-off
Locale in payloadInclude locale: user.locale at send timeSimple — works with any i18n library. Payload grows slightly.
Locale on recipientStore on the recipient record, access via {{_recipient.locale}}Cleaner payloads, but requires a custom recipient field.
render() is sync. It cannot be async — no database queries or API calls inside it. Load all the data you need into the payload at send time, then render from that. This keeps the delivery pipeline fast and predictable.

Testing channels

Template strings and render() functions are the most common source of notification bugs — a typo in a variable name produces a blank field silently. Test at three levels:

LevelWhat you verifyHow
Unitrender() output for edge-case payloadsCall the function directly in a test file
IntegrationFull template resolution including built-in variablesexplain() with a real payload — no delivery
E2EActual delivery reaches the destinationDev mode with test providers (console sink, Mailpit, etc.)

Unit-testing render() functions

Extract render() functions so they're importable, then test edge cases directly:

// notifications/order-shipped.ts
export const orderShippedInbox = (payload) => ({
  title: `Order ${payload.orderNumber} shipped`,
  body: payload.itemCount === 1
    ? "1 item on the way"
    : `${payload.itemCount} items on the way`,
  actionUrl: payload.trackingUrl,
})

// notifications/order-shipped.test.ts
import { orderShippedInbox } from "./order-shipped"

test("singular item text", () => {
  const result = orderShippedInbox({
    orderNumber: "ABC-123",
    itemCount: 1,
    trackingUrl: "/track/abc",
    expedited: false,
  })
  expect(result.body).toBe("1 item on the way")
})

test("plural item text", () => {
  const result = orderShippedInbox({
    orderNumber: "ABC-123",
    itemCount: 5,
    trackingUrl: "/track/abc",
    expedited: false,
  })
  expect(result.body).toBe("5 items on the way")
})

Integration testing with explain()

explain() dry-runs the full pipeline — template resolution, preference checks, channel evaluation — without writing any records or triggering providers. Use it to verify the final rendered output:

import { notify } from "./notifykit"

test("comment_mentioned resolves all template variables", async () => {
  const result = await notify.explain({
    recipientId: "user_test",
    notificationId: "comment_mentioned",
    payload: { actorName: "Rey", postTitle: "Launch Plan", postUrl: "/posts/42" },
  })

  // Verify no unresolved {{variables}} remain
  const inboxChannel = result.channels.find(c => c.name === "inbox")
  expect(inboxChannel.rendered.title).toBe("Rey mentioned you")
  expect(inboxChannel.rendered.title).not.toMatch(/\{\{/)

  // Verify channel was not skipped
  expect(inboxChannel.status).toBe("would_deliver")
})
Catch blank fields in CI. Add an assertion that no rendered field is empty or contains {{ — this catches payload/template mismatches before they reach users. Pattern: expect(rendered.title).not.toMatch(/\{\{|^$/)

Dev mode: inspect without real providers

In dev mode, channel deliveries are logged to the console instead of hitting real providers. This lets you iterate on templates visually:

// notifykit.config.ts
import { createNotifyKit } from "@notifykitjs/core"
import { memoryAdapter } from "@notifykitjs/core"

export const notify = createNotifyKit({
  database: memoryAdapter(),
  devMode: process.env.NODE_ENV !== "production",
  // In dev mode:
  // - Email renders to console (subject + body)
  // - SMS renders to console (body)
  // - Webhook logs the full payload without POSTing
  // - Inbox writes to the in-memory DB as normal
})

Sample dev mode console output:

// Console when devMode is enabled:
// ┌─────────────────────────────────────────────────
// │ 📧 EMAIL → user_123
// │ Subject: Rey mentioned you in Launch Plan
// │ Body: Open /posts/42 to reply.
// │
// │ Unsubscribe: http://localhost:3000/unsubscribe?token=dev_xxx
// └─────────────────────────────────────────────────
// ┌─────────────────────────────────────────────────
// │ 📥 INBOX → user_123
// │ Title: Rey mentioned you
// │ Body: In Launch Plan
// │ Action: /posts/42
// └─────────────────────────────────────────────────
Dev mode still runs the full pipeline. Preferences, quiet hours, deduplication, and digests all apply — only the final provider call is replaced with a console log. This means dev mode catches pipeline bugs, not just template bugs.

Debugging channel delivery

When a notification doesn't arrive, work through these checks in order. Most issues resolve at step 1 or 2:

1
Check result.skipped

Was the channel skipped entirely? reason tells you why: preferences, missing address, rate limit, or dedup match.

2
Check result.deferredChannels

Was it deferred by quiet hours? The delivery will fire when the window ends — call flushScheduledSends() to release manually.

3
Check result.deliveries

Was it attempted but failed? Look at status and error. Provider errors (5xx, timeout) are retried automatically.

4
Check result.digested / result.rateLimited

Was the send absorbed? Digested sends deliver later; rate-limited sends are silently dropped.

5
Use explain()

Dry-run the same send. Shows the full resolution trail — preferences, quiet hours, channel evaluation — without writing any records.

The table below maps specific symptoms to their root cause:

SymptomChannelLikely causeFix
Item not in inboxInboxRecipient opted out via preferencesCheck result.skipped — look for reason: "preference_opt_out"
Email never arrivesEmailNo email on recipient recordCheck result.skipped for reason: "missing_destination"
Email delayed by hoursEmailRecipient is in quiet hours windowCheck result.deferredChannels — delivery will happen when window ends
SMS not deliveredSMSProvider returned error (invalid number, region blocked)Check result.deliveries for the SMS entry — error field has provider message
Webhook returns 4xx/5xxWebhookEndpoint down, auth expired, or payload rejectedCheck result.deliveries — retries happen automatically up to 5 times
Nothing sent at allAllDeduplication key matched a recent sendCheck result.idempotent — the original send's result is returned
Send returns but no delivery recordsAllNotification was digestedCheck result.digested === true — it will deliver when the digest window fires

Inspecting the result programmatically

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

// Was anything skipped?
for (const skip of result.skipped) {
  console.log(`Channel ${skip.channel} skipped: ${skip.reason}`)
  // → "Channel email skipped: missing_destination"
  // → "Channel sms skipped: preference_opt_out"
}

// Did any delivery fail after retries?
const failures = result.deliveries.filter(d => d.status === "failed")
for (const fail of failures) {
  console.log(`${fail.channel} failed: ${fail.error}`)
  // → "email failed: 550 mailbox not found"
}

// Was it deferred by quiet hours?
if (result.deferredChannels.length > 0) {
  console.log(`Deferred: ${result.deferredChannels.join(", ")}`)
  // → "Deferred: email, sms"
}
Use the delivery.failed hook for monitoring. Instead of checking every send result manually, set up a hook that fires on failures and routes them to your error tracker (Sentry, Datadog, etc.).
Template errors are silent. If a {{variable}} is missing from the payload, the template renders an empty string — no error is thrown. If your messages look blank or incomplete, verify the payload fields match your template variables exactly.