How it works
What happens between your call and the delivery: the five things notifkit stores, and the five services a request passes through.
The five nouns
Everything in notifkit is one of these, and every endpoint is CRUD on one of them, plus
notify(). Learn these and you know the API.
usr_1. Transports are the exception: code registered on the server and shared
by every project it serves.
User
A person, keyed by your identifier, plus the rules for reaching them: accepted
channels, accepted topics, and quiet hours. Because these live centrally, you call
notify() without writing a preference check first.
Contact
One address on one channel: an email address, phone number, push token or webhook URL. A user
has many. email: ["[email protected]", "[email protected]"] creates two contacts, and an email send
fans out to both. A push token the provider reports as invalid is deactivated automatically,
so it stops costing attempts.
Template
Content for one channel, addressed by id. content is free-form JSON: notifkit fills
{{placeholders}} in every string and hands the result to the transport. Per-topic
opt-outs match its topic list.
Workflow
A named sequence of steps that can span days. Unlike one-shot notify(), it saves
progress after every step, so it survives restarts, deploys and crashes. Steps can send, sleep,
or wait for an external event.
Transport (provider)
The adapter to the outside world, and the only part that knows Resend from FCM. You register transports on the server at startup; everything upstream deals in channels, not vendors.
The pipeline
Underneath, notifkit is five workers passing messages through Redis Streams, one job each. The API returns once the request is durably in the first stream; everything after 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 the request rate limit, validates the body, and publishes
notification.requestedto the normal-priority inbound stream. You get202with amessageId, in a couple of milliseconds. - Enricher picks it up. One user, so no expansion. It loads
usr_123's profile and contacts, reads thewelcometemplate (topic:transactional), and emits onenotification.enrichedmessage per email address on file. - Engine checks the idempotency key, the
transactionalopt-out, thatchannels.emailisn'tfalse, the user's local time against quiet hours, and the hourly send count. All clear, it renders the template ({{name}}becomesAlice) and publishesnotification.dispatchedto the outbound stream. - Delivery finds the transport registered for
emailand callssend(). On success it emitsdelivery:deliveredand queues a log row. - Events flushes that row into
message_logs, which is whatGET /v1/notifications/logsreads.
Two consequences
A rejected message does not raise an error
By the time a gate rejects a message, notify() has already returned
202. The message is dropped, a notification:skipped event fires with
a reason, and the server logs it at info. Opting out is a normal outcome, so
debugging usually starts in the server log, not your app's error handler.
Delivery is at-least-once
Messages are acknowledged only after successful processing. If a worker dies mid-flight, its message stays pending and a sibling reclaims it a minute later. The cost 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 common repeats, but not that one; plan for it wherever exactly-once matters.
A sixth noun, sort of: the campaign
A campaign is just a label, with no stored object or lifecycle. Pass
campaign to notify() and every message the call fans out into is
tagged, along with every open, click, bounce and unsubscribe the provider reports later.
The catch: attribution is decided at send time and can't be reconstructed later. The five nouns describe one notification's journey; the label is the only thing tying journeys to the same decision. See Segments & scheduling for the send side and the reference for the statistics it unlocks.