BackCore concepts

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:

LayerSet with
1. App defaultdefaults.channels on createNotifyKit(); every channel is on if unset
2. Notification defaultdefaultChannels on the notification
3. Category defaultdefaults.categories[category]
4. Tenant settingtenantDefaults(tenantId), for sends scoped to a tenant
5. User, globalpreferences.updateGlobal()
6. User, categorypreferences.updateCategory()
7. User, notificationpreferences.update()

Two rules then override the result:

  • Required: a notification with required: true forces every channel on, whatever the layers said.
  • No destination: email with no email on the recipient, or SMS with no phone, is always off, even for required notifications. It is skipped as missing_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.

  1. 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.

  2. 02

    POST /unsubscribe

    Confirming, or a mail client’s one-click unsubscribe (RFC 8058, which POSTs to the same URL), sets email: false for 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: [...] }]
resolvedByMeans
user_notificationThe user turned this channel off (or on) for this notification
user_categoryThe user switched the whole category
user_globalThe user switched the channel off everywhere
tenant_settingYour tenantDefaults() decided
category_default, notification_default, app_defaultOne of your defaults decided
required_overrideThe notification is required
destination_unavailableThe recipient has no email address or phone number

To see preferences combined with everything else a send would do, use explain().