Preferences & quiet hours
Users who feel spammed don't open the preference centre; they hit the spam button. notifkit enforces consent in the engine, before rendering, so no caller can skip it by forgetting a check.
The preferences object
Preferences are free-form JSON on the user record. Adding a topic needs no migration: invent one and use 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, "slack": true, "whatsapp": true },
"topics": {
"transactional": true,
"productUpdates": true,
"marketing": false
},
"quietHours": [{ "start": "22:00", "end": "08:00" }]
}
}'
Or use 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 on that channel, and since refusal isn't a delivery failure, they do not trigger fallback.
Suppression stands apart. A preference is something the user set and can undo; a suppression
records an unsubscribe click, a spam report, or a permanent bounce. It's keyed on the
address, not the user, so it survives the user being deleted and recreated, and
priority: "critical" doesn't lift it. Removing one is a deliberate act through
the API, never an automatic retry.
Topic opt-outs key on the template
This surprises people most: a topic opt-out matches the topic list on the
template, not 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 has two topics and is blocked if either is off.
password-reset has no topic, so nothing can suppress it.
Give every template a topic except ones a user must always receive: password resets, security alerts, legal notices, receipts. No topic means "this one isn't optional".
topic on notify() is a targeting selector: "send to everyone
subscribed to this topic", an alternative to user or segment. Opt-outs
don't match against it. Same word, different job.
Quiet hours
Windows are 24-hour HH:MM pairs in the user's own timezone. A window
ending before it starts wraps midnight, the common case.
priority: "critical" skips this check entirely.
Users can have several windows, say a lunch break as well as the night:
quietHours: [
{ start: "12:00", end: "13:00" },
{ start: "22:00", end: "08:00" },
]
Touching or overlapping windows are followed through: if one window's release time lands in
another, the engine keeps walking forward until it's outside every window. A user with
08:00–12:00 and 12:00–18:00 is deferred once, to 18:00, not woken at
noon and deferred again.
An unrecognised timezone silently counts as "not in quiet hours" instead of erroring, so the
message goes out. Use IANA names (America/New_York), not abbreviations like
EST.
Per-user throttling
Regardless of user settings, the engine caps notifications per person per hour: a true sliding window in Redis, shared across worker processes and scoped per project so tenants can't 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 see it in normal operation, batch upstream rather than raising the
ceiling.
server.on("notification:throttled", (recipientId, count) => {
metrics.increment("notifkit.throttled", { recipientId, count });
});
This event is in-process only. The SSE endpoint GET /v1/events/stream carries
delivery:delivered and delivery:failed, not
notification:throttled; outside Node, throttling shows in the delivery log, not as
a live event.
Per-contact opt-outs
Preferences can also attach to one contact instead of the whole user, say when someone wants receipts at their work address but not their personal one.
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": "[email protected]",
"preferences": { "optedOut": true }
}'
A contact marked optedOut is skipped; the user's other addresses on that channel
still receive the message.
Unsubscribes from the provider side
When a transport implements parseWebhook, provider events
(unsubscribed, bounced, complained, opened,
clicked) are matched to the original message and recorded in
message_logs against its task. Query them alongside delivery attempts:
curl -s "http://localhost:3000/v1/notifications/logs?status=bounced&limit=50" \
-H "Authorization: Bearer $NK_KEY"
Three trigger action: unsubscribed, 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) don't
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, but the delivery log records the message, not the
destination, so parseWebhook must set recipient on the event for the
opt-out to work. Without it the event is logged but the person keeps getting mail, and the API
logs a warning naming the transport. See the
Transport interface.
Opens and clicks are recorded but never acted on: they're reporting signal, surfaced through campaign stats, never turned into preference changes.
One-click unsubscribe
Set PUBLIC_URL and UNSUBSCRIBE_SECRET and every email whose
template has a topic carries
List-Unsubscribe headers, so inboxes
show a real unsubscribe button. Gmail and Yahoo have required this on bulk mail since early 2024.
The link is scoped to its source: clicking switches off the topics on that template for that user, leaving everything else (password resets, receipts) working. That's why only topic-bearing mail gets the header: a template with no topic is transactional by convention, has nothing to scope an opt-out to, and shouldn't offer to switch itself off.
Nothing to opt into: give a template a topic and its mail becomes unsubscribable; leave it off and it doesn't. The same choice drives topic opt-outs.
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"
Build a preference centre on these. Render checkboxes from GET /preferences, save with
PATCH /preferences, and the engine uses the change on the next send. Template caches
are invalidated across processes over Redis pub/sub, and user data is read fresh per message.