← website
Start here · 03

How it works

You made a call and something arrived. This page explains what happened in between — the five things NotifKit stores, and the five services your request passed through on its way out.

The five nouns

Everything in NotifKit is one of these. Learn them once and the API stops needing to be memorised — every endpoint is CRUD on one of these five, plus notify().

Project tenant boundary · api keys 1 : n 1 : n 1 : n User your id · timezone · language preferences live here Template id · channel · content topics for opt-outs Workflow named step sequence TypeScript or JSON Contact channel + address one per endpoint Segment tag for fan-out "beta", "enterprise" topics ↔ prefs Transport code, not data registered at startup
Everything is project-scoped. A user id is only unique within a project, so two tenants can both have a usr_1 without colliding. Transports are the one exception — they are code you register on the server, shared across every project it serves.

User

A person, keyed by your identifier. Beyond identity, a user carries the rules that govern reaching them: which channels they accept, which topics they accept, and the hours they do not want to be disturbed. Storing this centrally is what lets you call notify() without writing a preference check first.

Contact

One addressable endpoint on one channel: an email address, a phone number, a push token, a webhook URL. A user has many. Passing email: ["a@x.com", "b@x.com"] creates two contacts, and an email send fans out to both. A dead push token gets deactivated automatically when the provider reports it as invalid, so it stops costing you attempts.

Template

Content, bound to a channel, addressed by id. The content field is free-form JSON — NotifKit substitutes {{placeholders}} into every string it contains and hands the result to the transport. Its topic list is what per-topic opt-outs match against.

Workflow

A named sequence of steps that can span days. Unlike notify(), which is one shot, a workflow persists its progress after every step, so it survives restarts, deploys, and crashes. Steps can send, sleep, or block on an external event.

Transport (provider)

The adapter that talks to the outside world. It is the only part of the system that knows Resend from FCM. You register transports on the server at startup; everything upstream deals in channels, never in vendors.

The pipeline

NotifKit is not a request/response service with a queue bolted on. It is five workers passing messages through Redis Streams, each doing one job and handing off. The API returns as soon as the request is durably in the first stream — everything after that is asynchronous.

your application NotifkitClient, or plain HTTP POST /v1/notify API service auth · validate · resolve project returns 202 without waiting stream: inbound · critical | normal | low Enricher expands segment or topic into users loads profile, contacts, template topics stream: enriched · one per user × channel Engine dedupe · opt-outs · quiet hours · throttle renders the template, picks the address stream: outbound · ready to send Delivery provider rate limit · circuit breaker 10s timeout · fallback to next channel transport.send() Provider Resend · FCM · your own Transport PostgreSQL users · contacts · templates Scheduler sendAt · quiet-hours defer releases when due AI worker aiPrompts → variables then rejoins Events worker writes message_logs Dead letter after retries are exhausted synchronous asynchronous, from here on
Read it top to bottom. The green path is the happy one. Note that the Enricher runs before the Engine — expansion and data loading happen first, so the Engine always evaluates a single concrete user on a single concrete channel. Each stream is split into critical, normal, and low lanes so a bulk campaign cannot delay a password reset.

What each stage actually does

StageInputWorkOutput
API HTTP request Authenticates the key, enforces the per-project request rate limit, validates the body, upserts any inline user, and publishes one event per target. inbound
Enricher inbound Turns a segment or topic into a concrete user list, loads each profile and its contacts in batches, reads the template's topics, and emits one message per user per channel. enriched
Engine enriched Applies the gate sequence: dedupe, topic opt-out, channel opt-out, quiet hours, per-user throttle. Renders the template, resolves the destination address, and drops anything on the suppression list before it can be dispatched. outbound, or a deferral
Delivery outbound Checks the provider's own rate limit, calls transport.send() behind a circuit breaker and a 10-second timeout, and rolls over to the next channel on total failure. provider call + events
Scheduler scheduled Parks future-dated tasks in a sharded Redis sorted set and releases them straight to outbound when due. Polls every five seconds. outbound
AI ai_pending Runs each prompt through your configured model, merges the results into the template variables, renders, and rejoins the main path. outbound
Workflow workflow Executes step handlers, persisting each step's output so a resumed run replays without re-sending. Holds a renewing lock per instance. inbound
Events events Batches delivery history into Postgres and wakes workflows that are blocked on waitForEvent. message_logs

Following one call through

Take the request from the quickstart:

await notifkit.notify({
  user: "usr_123",
  template: "welcome",
  channels: ["email"],
  data: { name: "Alice", company: "Acme" },
});
  1. API hashes your bearer token, finds the project, checks you are under the request rate limit, validates the body, and publishes notification.requested to the normal-priority inbound stream. You get 202 with a messageId. Total elapsed: a couple of milliseconds.
  2. Enricher picks it up. The target is a single user, so no expansion is needed. It loads usr_123's profile and contacts, reads the welcome template to learn it carries the transactional topic, and emits one notification.enriched message per email address on file.
  3. Engine checks its idempotency key, confirms the user has not opted out of transactional, confirms channels.email is not false, checks the local clock against the quiet-hours windows, and checks the hourly send count. All clear. It renders the template — {{name}} becomes Alice — and publishes notification.dispatched to the outbound stream.
  4. Delivery takes it, looks up the transport registered for email, and calls send(). On success it emits delivery:delivered and queues a log row.
  5. Events flushes that row into message_logs, which is what GET /v1/notifications/logs reads.

Two properties worth internalising

Silence is a valid outcome

A gate that rejects a message does not raise an error — your notify() call has already returned 202 by then. The message is dropped, a notification:skipped event fires with a reason, and the server logs it at info. This is deliberate: opting out is a normal outcome, not an exception. It does mean a debugging session usually starts in the server log rather than in your app's error handler.

Delivery is at-least-once

Messages are acknowledged only after successful processing. A worker that dies mid-flight leaves its message pending, and a sibling reclaims it a minute later. The cost of that guarantee is the duplicate case: if a provider accepts your email but its response never reaches NotifKit, the retry sends a second one. Idempotency guards catch the common repeats, but nothing can catch that one — plan for it in anything that must be exactly-once.

A sixth noun, sort of: the campaign

A campaign is not a stored object with a lifecycle — it is a label. Pass campaign to notify() and every message that call fans out into is tagged with it, as is every open, click, bounce, and unsubscribe the provider reports back later. That is the whole mechanism.

It earns a mention here because of what it implies: attribution is decided at send time and cannot be reconstructed afterwards. The five nouns above describe a single notification's journey; the label is the only thing that says which journeys belonged to the same decision. See Segments & scheduling for the send side and the reference for the statistics it unlocks.

Next