Preferences & unsubscribe

Every send checks a (recipientId, notificationId) preference row before it writes to a channel. If the recipient has opted out, the channel is skipped and reported in result.skipped with a reason.

Per-channel granularity

Users disable email for one notification while keeping inbox on — no all-or-nothing toggles.

Layered resolution

App defaults → category → notification → tenant → user. Most specific wins, with a full audit trail.

One-click unsubscribe

HMAC-signed email links that work without a session. RFC 8058 compliant for mail client support.

Global & category toggles

Master switches for "turn off all email" or "mute social notifications" — with per-notification overrides.

Preferences are per-channel, per-notification. A user can disable email for "comment mentions" while keeping inbox on — and still receive emails for "order shipped." No all-or-nothing toggles.

Reading & writing preferences

// Server-side:
await notify.preferences.update({
  recipientId: user.id,
  notificationId: "comment_mentioned",
  channels: { email: false },
})

const prefs = await notify.preferences.list(user.id)
// Client-side (React):
import { usePreferences } from "@notifykitjs/react"

function Settings() {
  const { items, update, isEnabled } = usePreferences()

  return (
    <input
      type="checkbox"
      checked={isEnabled("comment_mentioned", "email")}
      onChange={(e) =>
        update({
          notificationId: "comment_mentioned",
          channels: { email: e.target.checked },
        })
      }
    />
  )
}

The React hook is optimistic — toggling the checkbox updates the UI before the server confirms, then reverts on error.

Auto-generating a preferences UI

The public GET /api/notifykit/notifications route returns every registered notification's ID, channels, category, and payload schema. Drive your settings table off it:

const meta = await client.notifications.list()
// [
//   { id: "comment_mentioned", channels: ["inbox","email"], category: "social" },
//   { id: "order_shipped", channels: ["email"], category: "billing" },
// ]

Add a notification in lib/notifykit.ts and it shows up in the UI automatically.

Organizing with categories

As your notification count grows, a flat list becomes unusable. Use category to group notifications in your settings UI:

// In your notification definitions:
notification({ id: "comment_mentioned", category: "activity", ... })
notification({ id: "task_assigned",     category: "activity", ... })
notification({ id: "new_follower",      category: "social",   ... })
notification({ id: "post_liked",        category: "social",   ... })
notification({ id: "invoice_paid",      category: "billing",  ... })

Then group the metadata response in your UI by category:

const meta = await client.notifications.list()
const grouped = Object.groupBy(meta, n => n.category ?? "other")
// { activity: [...], social: [...], billing: [...] }
CategoryUX patternDefault recommendation
activityShow all channels — users want fine controlInbox + email on by default
socialShow toggle per notification — lower urgencyInbox on, email off by default (use defaultChannels)
billingShow as greyed-out / locked (required)required: true — users can't opt out
CAN-SPAM compliance. If you send marketing emails, use classification: "marketing" to distinguish them from transactional emails. Marketing notifications should always be opt-in (set defaultChannels: { email: false }) and must include an unsubscribe link.

Required notifications

Mark a notification as required: true to bypass preference checks. Use for transactional notifications like password resets, 2FA codes, or billing receipts:

notification({
  id: "password_reset",
  payload: { resetUrl: "string" },
  channels: [email({ subject: "Reset password", body: "{{resetUrl}}" })],
  required: true, // always delivers regardless of preferences
})

Unsubscribe links

When you pass an unsubscribe config to createNotifyKit(), every email template can reference {{_unsubscribeUrl}}. It expands to an HMAC-signed URL bound to the specific recipient and notification.

createNotifyKit({
  // ...
  unsubscribe: {
    secret: process.env.NOTIFYKIT_SECRET!,
    baseUrl: "https://app.com/api/notifykit",
  },
})

// In your email template:
email({
  subject: "...",
  body: "...\n\nUnsubscribe: {{_unsubscribeUrl}}",
})

How unsubscribe works

1
User clicks link in email

GET /unsubscribe?token=... — verifies HMAC, flips email: false, renders a confirmation page.

2
Mail client one-click (RFC 8058)

POST /unsubscribe — same verification via the List-Unsubscribe-Post header. Returns 200.

Security properties

PropertyWhy it matters
HMAC-SHA256, timing-safe comparePrevents brute-force forgery and timing side-channels
Signature is the authWorks from email clients without a login session
No expiryRFC 8058 requires links to remain valid indefinitely
Per-notification granularityUnsubscribing from one notification doesn't kill others

Preference resolution layers

When the engine checks whether a channel should fire, it walks through layers from broadest to most specific. Each layer can override the one above it — the most specific match wins:

1
App default

defaults.channels — the baseline for all notifications across the app.

2
Category default

defaults.categories[cat] — override for a specific category (e.g. "marketing" emails off).

3
Notification default

notification.defaultChannels — per-notification override set by the developer.

4
Tenant setting

tenantDefaults(tenantId) — per-org overrides (e.g. free plans have email off).

5
User preference

Global → category → notification-specific. The most specific user choice always wins.

Overrides bypass the hierarchy. required: true forces delivery regardless of preferences. Missing destination (no email/phone) always skips — even for required notifications.

Building a full settings page

Combine notifications.list() (metadata) with usePreferences() (user state) for a complete settings UI:

import { usePreferences } from "@notifykitjs/react"
import { useEffect, useState } from "react"

function NotificationSettings() {
  const { isEnabled, update } = usePreferences()
  const [notifications, setNotifications] = useState([])

  useEffect(() => {
    fetch("/api/notifykit/notifications")
      .then(r => r.json())
      .then(body => setNotifications(body.data))
  }, [])

  return (
    <table>
      <thead>
        <tr><th>Notification</th><th>Inbox</th><th>Email</th></tr>
      </thead>
      <tbody>
        {notifications.map(n => (
          <tr key={n.id}>
            <td>{n.description || n.id}</td>
            {n.channels.map(ch => (
              <td key={ch}>
                <input
                  type="checkbox"
                  checked={isEnabled(n.id, ch)}
                  disabled={n.required}
                  onChange={e => update({
                    notificationId: n.id,
                    channels: { [ch]: e.target.checked },
                  })}
                />
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  )
}
Required notifications show as disabled checkboxes. The required field from metadata tells you which rows the user can't toggle. Show them greyed out rather than hiding them — users should understand what they'll always receive.

Global channel toggles

Users often want a master switch: "turn off all email" without unchecking every notification individually. Implement this with a global preference that overrides per-notification settings:

// Set a global channel preference (disables email for everything):
await notify.preferences.update({
  recipientId: user.id,
  notificationId: "*",       // wildcard = applies to all notifications
  channels: { email: false },
})

// Per-notification preferences still exist but are masked:
// User had email ON for "comment_mentioned" — global OFF wins.
PatternKeyBehavior
Global offnotificationId: "*"Disables a channel for all notifications (except required: true)
Category offcategory: "social"Disables a channel for all notifications in that category
Per-notificationnotificationId: "comment_mentioned"Disables a channel for one specific notification

Resolution is most-specific-wins. A per-notification ON overrides a category OFF, which overrides a global OFF:

// Typical settings page layout:
//
// ┌─ Email ─────────────────────────────────────┐
// │ [x] Receive email notifications (global)    │
// │                                             │
// │  Activity                                   │
// │    [ ] Comments         ← user disabled     │
// │    [x] Task assigned                        │
// │                                             │
// │  Social                                     │
// │    [x] New followers                        │
// │    [x] Post reactions                       │
// └─────────────────────────────────────────────┘

function SettingsPage() {
  const { isEnabled, update } = usePreferences()

  // Global toggle
  const emailGlobalOn = isEnabled("*", "email")

  return (
    <>
      <label>
        <input
          type="checkbox"
          checked={emailGlobalOn}
          onChange={(e) => update({
            notificationId: "*",
            channels: { email: e.target.checked },
          })}
        />
        Receive email notifications
      </label>

      {/* Per-notification toggles (visually dimmed when global is off) */}
      <fieldset disabled={!emailGlobalOn}>
        {notifications.map(n => (
          <label key={n.id}>
            <input
              type="checkbox"
              checked={isEnabled(n.id, "email")}
              onChange={(e) => update({
                notificationId: n.id,
                channels: { email: e.target.checked },
              })}
            />
            {n.description}
          </label>
        ))}
      </fieldset>
    </>
  )
}
Disable the fieldset when global is off. Users shouldn't toggle individual notifications while the master switch is off — it's confusing. Use a <fieldset disabled> to grey out the per-notification toggles until the global is re-enabled.

Resolution with global toggles

1
Check required

If required: true → always deliver. Skip all preference checks.

2
Check per-notification

If user has a specific preference for this (recipient, notification, channel) → use it.

3
Check category

If user has a category-level preference → use it.

4
Check global

If user has notificationId: "*" → use it.

5
Fall through to defaults

Tenant defaults → notification defaults → app defaults.

Global OFF doesn't affect required notifications. Password resets, security alerts, and other required notifications still deliver even when the user has disabled all email. This is intentional — communicate it clearly in your UI ("Security notifications will always be sent regardless of this setting").

Common pitfalls

Most preference bugs come from the same handful of mistakes. Check this table before reaching for explain():

SymptomLikely causeFix
User opts out but still gets emailsrequired: true on the notification definitionRemove required or explain to user that this notification can't be disabled
New notification sends to nobodydefaultChannels set to { email: false, inbox: false }Set at least one channel to true in defaults, or omit defaultChannels to inherit app defaults
Global toggle off but one notification still deliversPer-notification preference is explicitly true (most-specific wins)Expected behavior — show this in your UI so users understand the override
Tenant users can't receive email even when opted inTenant default disables email at layer 4, and no user preference at layer 5 overrides itUser must explicitly set email: true — an absent preference doesn't override a tenant OFF
Category OFF doesn't apply to a notificationNotification is missing category field — it falls outside the category filterAdd category to the notification definition
Absent ≠ false. A user with no preference record inherits defaults. A user with an explicit false overrides defaults. If you delete a preference row (rather than setting it to true), the user falls back to whatever the default is — which may not be what they expect.

Testing preference resolution

Preference bugs are invisible until a user reports "I never got that email." Write tests that cover your resolution hierarchy before that happens. Use preferences.explain() for the full trail, or explain() (the send dry-run) to verify the combined effect on delivery.

import { describe, it, expect, beforeEach } from "vitest"
import { createNotifyKit, memoryAdapter, fakeEmailProvider, notification, channel } from "@notifykitjs/core"

const inbox = channel.inbox()
const email = channel.email()

const commentMentioned = notification({
  id: "comment_mentioned",
  category: "activity",
  payload: { actorName: "string", postUrl: "string" },
  channels: [
    inbox({ title: "{{actorName}} mentioned you" }),
    email({ subject: "{{actorName}} mentioned you", body: "Open {{postUrl}}" }),
  ],
})

const passwordReset = notification({
  id: "password_reset",
  payload: { resetUrl: "string" },
  channels: [email({ subject: "Reset password", body: "{{resetUrl}}" })],
  required: true,
})

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

describe("preference resolution", () => {
  let notify: ReturnType<typeof createTestNotify>

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

  it("delivers all channels by default (no preferences set)", 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")
  })

  it("user opt-out disables a single channel", 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.inbox.outcome).toBe("deliver") // unaffected
  })

  it("required notifications ignore user opt-out", async () => {
    await notify.preferences.update({
      recipientId: "user_1",
      notificationId: "password_reset",
      channels: { email: false },
    })

    const e = await notify.explain({
      recipientId: "user_1",
      notificationId: "password_reset",
      payload: { resetUrl: "/reset/abc" },
    })
    expect(e.channels.email.outcome).toBe("deliver") // required overrides
  })

  it("global OFF disables email for all non-required notifications", async () => {
    await notify.preferences.update({
      recipientId: "user_1",
      notificationId: "*",
      channels: { email: false },
    })

    const mention = await notify.explain({
      recipientId: "user_1",
      notificationId: "comment_mentioned",
      payload: { actorName: "Rey", postUrl: "/p/1" },
    })
    expect(mention.channels.email.outcome).toBe("disabled")

    const reset = await notify.explain({
      recipientId: "user_1",
      notificationId: "password_reset",
      payload: { resetUrl: "/reset/abc" },
    })
    expect(reset.channels.email.outcome).toBe("deliver") // required wins
  })

  it("per-notification ON overrides global OFF", async () => {
    // Global: email off
    await notify.preferences.update({
      recipientId: "user_1",
      notificationId: "*",
      channels: { email: false },
    })
    // Exception: this specific notification stays on
    await notify.preferences.update({
      recipientId: "user_1",
      notificationId: "comment_mentioned",
      channels: { email: true },
    })

    const e = await notify.explain({
      recipientId: "user_1",
      notificationId: "comment_mentioned",
      payload: { actorName: "Rey", postUrl: "/p/1" },
    })
    expect(e.channels.email.outcome).toBe("deliver")
  })
})
Test caseWhat it catchesCommon mistake
Default delivery (no prefs set)App defaults are correctdefaultChannels accidentally set to false
Single channel opt-outGranular control worksOpt-out disabling all channels instead of just the target
Required bypasses opt-outTransactional notifications always deliverrequired not being checked before preferences
Global OFF applies broadlyWildcard preference worksWildcard only matching literal "*" ID
Specific overrides globalMost-specific-wins resolutionGlobal preference taking precedence over specific
Add tenant tests if you use multi-tenancy. The hierarchy adds a tenant layer between defaults and user preferences. Test that: tenant OFF + user ON = delivers (user wins), and tenant OFF + no user preference = doesn't deliver (tenant wins over app default). See Multi-tenancy for the full isolation test pattern.

Debugging with preferences.explain()

When a test fails or a user reports unexpected behavior, use preferences.explain() to see the full resolution trail — every layer that was checked, which one won, and why:

const trail = await notify.preferences.explain({
  recipientId: "user_1",
  notificationId: "comment_mentioned",
})

// trail.email → {
//   outcome: "disabled",
//   resolvedBy: "user_notification",   ← which layer decided
//   trail: [
//     { layer: "app_default",          value: true },
//     { layer: "notification_default", value: true },
//     { layer: "tenant_setting",       value: null },   ← no tenant override
//     { layer: "user_global",          value: null },   ← no global pref
//     { layer: "user_notification",    value: false },  ← ✓ this one won
//   ]
// }
resolvedBy valueMeaningHow to fix (if unexpected)
app_defaultNo overrides at any level — using the base configCheck defaults.channels in createNotifyKit()
notification_defaultThe notification's defaultChannels overrode the app defaultCheck the notification definition's defaultChannels field
tenant_settingThe tenant's tenantDefaults() function overrodeCheck what tenantDefaults(tenantId) returns for this org
user_globalUser has a wildcard (*) preference setUser turned off all email — check their global settings page
user_categoryUser toggled the entire category offUser disabled all "social" or "activity" notifications
user_notificationUser explicitly set this specific (notification, channel) pairThis is intentional — user made a specific choice
required_overrideNotification is required: true — all preferences ignoredExpected for transactional notifications
Wire this into your support tooling. When a support agent investigates "user didn't get the email," they can call preferences.explain() from an admin endpoint and immediately see which layer blocked delivery — no guesswork, no checking multiple database tables manually. See Explain & dry run for the full send-level dry run.