Star
Start here

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.

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
A user id is unique only within a project, so two tenants can both have a 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.

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
Green is the happy path. The Enricher runs before the Engine: expansion and data loading come first, so the Engine always evaluates one concrete user on one concrete channel. Each stream has critical, normal and low lanes, so a bulk campaign can't 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 the request rate limit, validates the body, and publishes notification.requested to the normal-priority inbound stream. You get 202 with a messageId, in a couple of milliseconds.
  2. Enricher picks it up. One user, so no expansion. It loads usr_123's profile and contacts, reads the welcome template (topic: transactional), and emits one notification.enriched message per email address on file.
  3. Engine checks the idempotency key, the transactional opt-out, that channels.email isn't false, the user's local time against quiet hours, and the hourly send count. All clear, it renders the template ({{name}} becomes Alice) and publishes notification.dispatched to the outbound stream.
  4. Delivery finds 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 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.

Next