Preferences and unsubscribe
Per-channel preferences with layered defaults, required notifications, and signed one-click unsubscribe links.
How It Works
A preference is an on or off switch for one channel. Before a send writes anything, each of the notification’s channels is resolved against the recipient’s preferences and your defaults. Channels that come out off are skipped and listed in result.skipped with the reason preferences_disabled. If every channel is off and no fallback applies, the send stops there: it is recorded as suppressed and the notification.suppressed hook fires.
Preferences are checked right after validation, idempotency, and deduplication, and before rate limits, digests, and quiet hours. See the pipeline.
Reading and Writing
On the server, preferences are keyed by recipient and notification:
await notify.preferences.update({
recipientId: user.id,
notificationId: "comment_mentioned",
channels: { email: false }, // inbox is untouched
})
const prefs = await notify.preferences.list(user.id)channels only needs the channels you are changing; a channel left out has no preference and falls through to the defaults. In the browser, usePreferences() from @notifykitjs/react reads and writes the signed-in user’s preferences through the handler’s /preferences routes:
const { isEnabled, update } = usePreferences()
<input
type="checkbox"
checked={isEnabled("comment_mentioned", "email")}
onChange={(e) =>
update({
notificationId: "comment_mentioned",
channels: { email: e.target.checked },
})
}
/>Resolution Layers
Each channel is resolved through these layers in order. A layer with no value for the channel is skipped, and the last layer that has one wins, so the most specific setting decides:
| Layer | Set with |
|---|---|
| 1. App default | defaults.channels on createNotifyKit(); every channel is on if unset |
| 2. Notification default | defaultChannels on the notification |
| 3. Category default | defaults.categories[category] |
| 4. Tenant setting | tenantDefaults(tenantId), for sends scoped to a tenant |
| 5. User, global | preferences.updateGlobal() |
| 6. User, category | preferences.updateCategory() |
| 7. User, notification | preferences.update() |
Two rules then override the result:
- Required: a notification with
required: trueforces every channel on, whatever the layers said. - No destination: email with no
emailon the recipient, or SMS with nophone, is always off, even for required notifications. It is skipped asmissing_address.
createNotifyKit({
// ...
defaults: {
channels: { sms: false }, // SMS opt-in everywhere
categories: { marketing: { email: false } }, // marketing email opt-in
},
tenantDefaults: async (tenantId) =>
(await isFreePlan(tenantId)) ? { sms: false } : null,
})Global and Category Switches
For a “turn off all email” switch, or muting a whole category, set a preference at that level instead of touching every notification:
await notify.preferences.updateGlobal({
recipientId: user.id,
channels: { email: false },
})
await notify.preferences.updateCategory({
recipientId: user.id,
category: "social",
channels: { inbox: false },
})Because the most specific layer wins, a per-notification email: true still beats a global email: false. If your UI has a master switch, grey out the individual toggles while it is off so that is not surprising. A category switch only affects notifications that set that category. The React client has matching updateGlobal() and updateCategory() calls.
Required Notifications
notification({
id: "password_reset",
payload: { resetUrl: "string" },
channels: [email({ subject: "Reset your password", body: "{{resetUrl}}" })],
required: true,
})Use required for messages people cannot opt out of: password resets, security alerts, receipts. It overrides every preference, including a global “all email off”, so say so in your settings page and show these rows as locked rather than hiding them. A required notification cannot put {{_unsubscribeUrl}} in its email; that is a startup error, since the link could not work.
Building a Settings Page
The handler’s public GET /notifications route lists every registered notification with its id, channels, description, category, required, defaultChannels, and classification, without templates or providers. Drive the settings table from it and a new notification appears there as soon as you define it:
const client = useNotifyKitClient()
const notifications = await client.notifications.list()
const byCategory = Object.groupBy(notifications, (n) => n.category ?? "other")Give each notification a description for the label. For marketing messages, set classification: "marketing" and defaultChannels: { email: false } so email is opt-in.
Unsubscribe Links
Turn on unsubscribe links with a secret and the URL your handler is mounted at. The secret must be at least 32 characters:
createNotifyKit({
// ...
unsubscribe: {
secret: process.env.NOTIFYKIT_SECRET!,
baseUrl: "https://app.example.com/api/notifykit",
},
})
email({
subject: "{{actorName}} mentioned you",
body: "Open {{postUrl}}.<br><a href=\"{{_unsubscribeUrl}}\">Unsubscribe</a>",
})Pass the same secret to the handler as unsubscribeSecret; without it, the route answers 404. Each link is <baseUrl>/unsubscribe?token=…, where the token is an HMAC-SHA256 signature over the recipient, the notification, and the tenant and workspace if any. It needs no login and does not expire.
- 01
GET /unsubscribe
Opening the link verifies the token and shows a small confirmation page with a button. Nothing changes yet, so link scanners in mail servers cannot unsubscribe people by prefetching.
- 02
POST /unsubscribe
Confirming, or a mail client’s one-click unsubscribe (RFC 8058, which POSTs to the same URL), sets
email: falsefor that recipient and notification only. Other notifications keep emailing.
To rotate the secret, note that existing links stop working once the old secret is gone. verifyUnsubscribeToken() accepts an array of secrets if you verify tokens yourself.
Debugging
notify.preferences.explain() returns, per channel, whether it is allowed, which layer decided, and the whole trail of layer values:
const result = await notify.preferences.explain({
recipientId: "user_123",
notificationId: "comment_mentioned",
})
// channels: [{ channel: "email", allowed: false, resolvedBy: "user_global",
// reason: "email is disabled by user global preferences", trail: [...] }]| resolvedBy | Means |
|---|---|
user_notification | The user turned this channel off (or on) for this notification |
user_category | The user switched the whole category |
user_global | The user switched the channel off everywhere |
tenant_setting | Your tenantDefaults() decided |
category_default, notification_default, app_default | One of your defaults decided |
required_override | The notification is required |
destination_unavailable | The recipient has no email address or phone number |
To see preferences combined with everything else a send would do, use explain().