Star
Build

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

GateTriggered byOutcomecritical 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 is not a preference

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.

Rule of thumb

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

Not the same

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.

00:00 04:00 08:00 12:00 16:00 20:00 24:00 quiet quiet reachable · 08:00 – 22:00 local time notify() at 23:30 held, then released at 08:00 next morning Timeline is the user's local clock, taken from their timezone field. Default is UTC.
Quiet hours delay a message, never discard it. The engine converts the window's end in the user's zone to a UTC instant, stamps the task with that release time, and hands it to the scheduler, so it arrives at a civil hour. The release time comes from the local wall clock, not a fixed offset, so a window spanning a DST change still ends at the configured hour. 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.

Gotcha

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.

SettingDefaultEffect
RATE_LIMIT_PER_HOUR100Maximum messages per user per rolling window, for every project.
throttleLimitunsetPer-project override of that cap, set with PATCH /v1/projects/{id}. 0 stops all non-critical sends.
throttleWindowHours1Per-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 });
});
Note

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"
The transport has to say who

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.

No configuration per template

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.