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.
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: [...] }| Category | UX pattern | Default recommendation |
|---|---|---|
| activity | Show all channels — users want fine control | Inbox + email on by default |
| social | Show toggle per notification — lower urgency | Inbox on, email off by default (use defaultChannels) |
| billing | Show as greyed-out / locked (required) | required: true — users can't opt out |
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
GET /unsubscribe?token=... — verifies HMAC, flips email: false, renders a confirmation page.
POST /unsubscribe — same verification via the List-Unsubscribe-Post header. Returns 200.
Security properties
| Property | Why it matters |
|---|---|
| HMAC-SHA256, timing-safe compare | Prevents brute-force forgery and timing side-channels |
| Signature is the auth | Works from email clients without a login session |
| No expiry | RFC 8058 requires links to remain valid indefinitely |
| Per-notification granularity | Unsubscribing 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:
defaults.channels — the baseline for all notifications across the app.
defaults.categories[cat] — override for a specific category (e.g. "marketing" emails off).
notification.defaultChannels — per-notification override set by the developer.
tenantDefaults(tenantId) — per-org overrides (e.g. free plans have email off).
Global → category → notification-specific. The most specific user choice always wins.
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 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.| Pattern | Key | Behavior |
|---|---|---|
| Global off | notificationId: "*" | Disables a channel for all notifications (except required: true) |
| Category off | category: "social" | Disables a channel for all notifications in that category |
| Per-notification | notificationId: "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>
</>
)
}<fieldset disabled> to grey out the per-notification toggles until the global is re-enabled.Resolution with global toggles
If required: true → always deliver. Skip all preference checks.
If user has a specific preference for this (recipient, notification, channel) → use it.
If user has a category-level preference → use it.
If user has notificationId: "*" → use it.
Tenant defaults → notification defaults → app defaults.
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():
| Symptom | Likely cause | Fix |
|---|---|---|
| User opts out but still gets emails | required: true on the notification definition | Remove required or explain to user that this notification can't be disabled |
| New notification sends to nobody | defaultChannels 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 delivers | Per-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 in | Tenant default disables email at layer 4, and no user preference at layer 5 overrides it | User must explicitly set email: true — an absent preference doesn't override a tenant OFF |
| Category OFF doesn't apply to a notification | Notification is missing category field — it falls outside the category filter | Add category to the notification definition |
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 case | What it catches | Common mistake |
|---|---|---|
| Default delivery (no prefs set) | App defaults are correct | defaultChannels accidentally set to false |
| Single channel opt-out | Granular control works | Opt-out disabling all channels instead of just the target |
| Required bypasses opt-out | Transactional notifications always deliver | required not being checked before preferences |
| Global OFF applies broadly | Wildcard preference works | Wildcard only matching literal "*" ID |
| Specific overrides global | Most-specific-wins resolution | Global preference taking precedence over specific |
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 value | Meaning | How to fix (if unexpected) |
|---|---|---|
app_default | No overrides at any level — using the base config | Check defaults.channels in createNotifyKit() |
notification_default | The notification's defaultChannels overrode the app default | Check the notification definition's defaultChannels field |
tenant_setting | The tenant's tenantDefaults() function overrode | Check what tenantDefaults(tenantId) returns for this org |
user_global | User has a wildcard (*) preference set | User turned off all email — check their global settings page |
user_category | User toggled the entire category off | User disabled all "social" or "activity" notifications |
user_notification | User explicitly set this specific (notification, channel) pair | This is intentional — user made a specific choice |
required_override | Notification is required: true — all preferences ignored | Expected for transactional notifications |
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.