Let users choose what reaches them.
Categories with sensible defaults, a preference centre in your settings page, quiet hours, and an unsubscribe link that does what it says.
1
Design the categories
Users choose per category, not per type, so keep categories few and named the way a user thinks (“Billing”, not invoice.paid). Up to 50 per workspace.
// A channel left out of defaults is "on". "mandatory" cannot be turned off
// by the recipient (the switch shows locked); use it sparingly.
await notify.categories.create({
key: "security",
name: "Security",
description: "Sign-ins and password changes",
transactional: true, // no List-Unsubscribe header on its emails
defaults: { inbox: "mandatory", email: "mandatory", push: "on", webpush: "on" },
ordinal: 0,
});
await notify.categories.create({
key: "digest",
name: "Weekly digest",
defaults: { email: "on", push: "off", webpush: "off" },
ordinal: 10,
});2
The drop-in preference centre
A table with a switch per category and channel. Each switch saves only its own cell, at once, and rolls back with a message if the save fails. Mandatory cells are locked with the reason. It also offers “turn off all optional notifications”, the time zone and quiet hours.
"use client";
import { NotifyPreferences, NotifyProvider } from "@productcraft/notify-react";
import "@productcraft/notify-react/styles.css";
export function NotificationSettings({ userId }: { userId: string }) {
return (
<NotifyProvider key={userId} getToken={() => fetch("/api/notify-token").then((r) => r.text())}>
{/* A switch per category × channel, mandatory cells locked,
"turn off all optional notifications", time zone and quiet hours. */}
<NotifyPreferences channels={["inbox", "email", "push", "webpush"]} />
</NotifyProvider>
);
}3
Or your own UI
import { createNotifyClient, resolvePreference } from "@productcraft/notify-js";
const notify = createNotifyClient({ getToken: () => fetch("/api/notify-token").then((r) => r.text()) });
const prefs = await notify.preferences.get();
for (const category of prefs.categories) {
const email = resolvePreference(prefs, category.key, "email");
console.log(category.name, email.enabled, email.mandatory ? "(required)" : "");
}
// Only this cell changes, so two open tabs never overwrite each other.
await notify.preferences.update([{ scope: "category", key: "digest", channel: "email", enabled: false }], {
timezone: "Europe/Lisbon",
quiet_hours: { start: "22:00", end: "08:00" },
});4
From your backend
Support can change a user’s preferences with the API key. Every change, from any source (the user, your backend, an unsubscribe link, a spam complaint), is kept as consent evidence.
// Each change is recorded with its source, as consent evidence.
await notify.recipients.preferences.update("user_123", {
preferences: [{ scope: "category", key: "digest", channel: "email", enabled: false }],
});
const current = await notify.recipients.preferences.retrieve("user_123");
console.log(current.preferences, current.unsubscribed_all);5
Email unsubscribe
Every email of a non-transactional, non-critical category carries a one-click List-Unsubscribe header, as Gmail and Yahoo require of bulk senders. Using it turns that category’s email off for that recipient, nothing more: security mail still arrives.