← website
Build

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

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 for that channel — and because a user's refusal is not a delivery failure, they do not trigger a fallback chain.

suppression is not a preference

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.

rule of thumb

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

not the same

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.

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.
Deferred, not dropped. The engine resolves the end of the window back to a UTC instant in the user's own zone, stamps the task with that release time, and hands it to the scheduler. Nothing is lost — it simply arrives at a civil hour. Because the release time is derived from the local wall clock rather than from a fixed offset, a window that straddles a DST change still ends at the hour the user configured. Sending with 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.

gotcha

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.

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 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 });
});
note

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

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.

no configuration per template

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.