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().
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.
What each stage actually does
| Stage | Input | Work | Output |
|---|---|---|---|
| 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" },
});
- API hashes your bearer token, finds the project, checks you are under the request rate limit, validates the body, and publishes
notification.requestedto the normal-priority inbound stream. You get202with amessageId. Total elapsed: a couple of milliseconds. - Enricher picks it up. The target is a single user, so no expansion is needed. It loads
usr_123's profile and contacts, reads thewelcometemplate to learn it carries thetransactionaltopic, and emits onenotification.enrichedmessage per email address on file. - Engine checks its idempotency key, confirms the user has not opted out of
transactional, confirmschannels.emailis notfalse, checks the local clock against the quiet-hours windows, and checks the hourly send count. All clear. It renders the template —{{name}}becomesAlice— and publishesnotification.dispatchedto the outbound stream. - Delivery takes it, looks up the transport registered for
email, and callssend(). On success it emitsdelivery:deliveredand queues a log row. - Events flushes that row into
message_logs, which is whatGET /v1/notifications/logsreads.
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.