Preferences & quiet hours
Users who feel spammed do not open the preference centre — they hit the spam button. NotifKit enforces consent inside the engine, before a template is rendered, so no caller can bypass it by forgetting a check.
The preferences object
Preferences hang off the user record as free-form JSON. There is no schema migration to add a topic — invent one and start using it.
await notifkit.updateUser("usr_123", {
timezone: "Asia/Tokyo", // quiet hours are evaluated here
preferences: {
channels: { // false = never use this channel
email: true,
push: true,
sms: false,
},
topics: { // false = opted out of this topic
transactional: true,
productUpdates: true,
marketing: false,
},
quietHours: [ // HH:MM, may wrap midnight
{ start: "22:00", end: "08:00" },
],
},
});
curl -X PATCH http://localhost:3000/v1/users/usr_123 \
-H "Authorization: Bearer $NK_KEY" \
-H "Content-Type: application/json" \
-d '{
"timezone": "Asia/Tokyo",
"preferences": {
"channels": { "email": true, "push": true, "sms": false },
"topics": {
"transactional": true,
"productUpdates": true,
"marketing": false
},
"quietHours": [{ "start": "22:00", "end": "08:00" }]
}
}'
Or against the dedicated endpoint, which replaces the whole preferences object:
curl -X PATCH http://localhost:3000/v1/users/usr_123/preferences \
-H "Authorization: Bearer $NK_KEY" \
-H "Content-Type: application/json" \
-d '{"channels":{"sms":false},"topics":{"marketing":false}}'
Five gates, in this order
| Gate | Triggered by | Outcome | critical bypasses? |
|---|---|---|---|
| Topic opt-out | Any topic on the template is false for this user |
Dropped · user_opted_out |
No |
| Channel opt-out | channels[channel] === false |
Dropped · channel_disabled |
No |
| Quiet hours | Local time falls inside a window | Deferred to the end of the window | Yes |
| Suppression | The destination is on the project's suppression list | Dropped · suppressed |
No — nothing bypasses this one |
| Per-user throttle | Hourly send count exceeded | Dropped · notification:throttled |
Yes |
Only quiet hours defers. The other four end the attempt for that channel — and because a user's refusal is not a delivery failure, they do not trigger a fallback chain.
Suppression sits apart from the other four. A preference is something the user set and can
set back; a suppression records that they clicked unsubscribe, marked you as spam, or that
the address bounced permanently. It is keyed on the address rather than the user,
so it survives the user record being deleted and recreated, and
priority: "critical" does not lift it. Removing one is a deliberate act
through the API, never an automatic retry.
Topic opt-outs key on the template
This is the piece that most often surprises people. A topic opt-out is matched against the
topic list declared on the template, not against anything in the
notify() call.
// Templates declare which topics they belong to.
await notifkit.syncTemplates({
templates: [
{ id: "password-reset", channel: "email", content: { /* … */ } }, // no topic
{ id: "weekly-digest", channel: "email", topic: ["marketing"], content: {} }, // opt-outable
{ id: "invoice", channel: "email", topic: ["billing", "transactional"], content: {} },
],
});
# Templates declare which topics they belong to.
curl -X PUT http://localhost:3000/v1/templates \
-H "Authorization: Bearer $NK_KEY" \
-H "Content-Type: application/json" \
-d '{
"templates": [
{ "id": "password-reset", "channel": "email", "content": {} },
{ "id": "weekly-digest", "channel": "email",
"topic": ["marketing"], "content": {} },
{ "id": "invoice", "channel": "email",
"topic": ["billing", "transactional"], "content": {} }
]
}'
A user with topics.marketing === false never receives weekly-digest,
on any channel. invoice carries two topics and is blocked if either is
turned off. password-reset declares no topic at all, so nothing can suppress it.
Give every template a topic except the ones a user must always receive: password resets, security alerts, legal notices, receipts. Omitting the topic is how you say "this one is not optional".
The topic field on notify() is a targeting selector — it
means "send to everyone subscribed to this topic" and is an alternative to
user or segment. It is not what opt-outs are matched against.
Same word, different job.
Quiet hours
Windows are HH:MM pairs in 24-hour form, evaluated against the user's own
timezone field. A window whose end is earlier than its start wraps midnight,
which is the common case.
priority: "critical" skips this check entirely.
Multiple windows per user are allowed, for example a lunch break as well as a night:
quietHours: [
{ start: "12:00", end: "13:00" },
{ start: "22:00", end: "08:00" },
]
Windows that touch or overlap are followed through: if the release time computed for one
window lands inside another, the engine keeps walking forward until it reaches a time that
is genuinely outside every window. A user with 08:00–12:00 and
12:00–18:00 configured is deferred once, to 18:00, rather than being woken at
noon and immediately deferred again.
An unrecognised timezone string silently falls back to "not in quiet hours" rather than
erroring — the message goes out. Use IANA names (America/New_York), not
abbreviations like EST.
Per-user throttling
Independent of anything the user configured, the engine caps how many notifications one person can receive per hour. It is a true sliding window in Redis, shared across every worker process, and scoped per project so tenants cannot throttle each other.
| Setting | Default | Effect |
|---|---|---|
RATE_LIMIT_PER_HOUR | 100 | Maximum messages per user per rolling window, for every project. |
throttleLimit | unset | Per-project override of that cap, set with PATCH /v1/projects/{id}. 0 stops all non-critical sends. |
throttleWindowHours | 1 | Per-project window length. A digest product might use 24. |
priority: "critical" | — | Bypasses the throttle completely. |
A throttled message is dropped, not queued, and emits notification:throttled with
the current count. If you are seeing that event in normal operation, the fix is almost always
to batch upstream rather than to raise the ceiling.
server.on("notification:throttled", (recipientId, count) => {
metrics.increment("notifkit.throttled", { recipientId, count });
});
This one is in-process only. The SSE endpoint at GET /v1/events/stream carries
delivery:delivered and delivery:failed — not
notification:throttled. From outside Node, throttling shows up in the delivery
log rather than as a live event.
Per-contact opt-outs
Preferences can also be attached to a single contact rather than the whole user — useful when someone has a work address and a personal one and only wants receipts at one of them.
curl -X POST http://localhost:3000/v1/users/usr_123/contacts \
-H "Authorization: Bearer $NK_KEY" \
-H "Content-Type: application/json" \
-d '{
"channel": "email",
"target": "alice+work@example.com",
"preferences": { "optedOut": true }
}'
A contact marked optedOut is skipped while the user's other addresses on the same
channel still receive the message.
Unsubscribes from the provider side
When a transport implements parseWebhook, provider events —
unsubscribed, bounced, complained, opened,
clicked — are matched back to the originating message and recorded in
message_logs against that task. Query them alongside delivery attempts:
curl -s "http://localhost:3000/v1/notifications/logs?status=bounced&limit=50" \
-H "Authorization: Bearer $NK_KEY"
Three of those events are also acted on, not just recorded. An
unsubscribed, a complained, or a hard
bounced adds the address to the suppression list, and every later send drops it
before dispatch. Soft bounces — a full mailbox, a transient outage — do not suppress; the
address is still good.
# What has been suppressed, and why
curl -s "http://localhost:3000/v1/suppressions?limit=100" -H "Authorization: Bearer $NK_KEY"
Suppression needs the address, and the delivery log records the message, not the
destination — so a transport's parseWebhook must set
recipient on the event for the opt-out to be actionable. Without it the event
is still logged, but the person keeps receiving mail. The API logs a warning naming the
transport when this happens; see the
Transport interface.
Opens and clicks are recorded but never acted on — they are reporting signal, surfaced through campaign stats rather than turned into preference changes.
One-click unsubscribe
Set PUBLIC_URL and UNSUBSCRIBE_SECRET and every email whose
template carries a topic goes out with
List-Unsubscribe headers, which is
what makes an inbox render a real unsubscribe button. Gmail and Yahoo have required this on
bulk mail since early 2024.
The link is scoped to what it came from: clicking it switches off the topics on that template for that user and leaves everything else — password resets, receipts — working. That is why the header goes only on topic-bearing mail. A template with no topic is transactional by the convention above, has nothing to scope an opt-out to, and should not be offering to switch itself off.
There is nothing to opt into. Give a template a topic and its mail becomes unsubscribable; leave the topic off and it does not. The same decision that drives topic opt-outs drives this.
Reading current state
# Just the preferences
curl -s http://localhost:3000/v1/users/usr_123/preferences -H "Authorization: Bearer $NK_KEY"
# User with contacts
curl -s http://localhost:3000/v1/users/usr_123 -H "Authorization: Bearer $NK_KEY"
# Contacts only
curl -s http://localhost:3000/v1/users/usr_123/contacts -H "Authorization: Bearer $NK_KEY"
These are the endpoints to build a preference centre on. Render the checkboxes from
GET /preferences, save with PATCH /preferences, and the engine picks
up the change on the next send — template caches are invalidated across processes over Redis
pub/sub, and user data is read fresh per message.