Architecture
NotifKit is eight services communicating through Redis Streams, with PostgreSQL as the system of record. Every service is stateless and horizontally scalable; all coordination happens in Redis. This page is the contract underneath that.
The streams
Priority is expressed as separate physical streams rather than a score, so a worker draining a low-priority backlog physically cannot be in front of a critical message.
| Redis key | Written by | Read by |
|---|---|---|
notifkit:stream:inbound:critical…:inbound:normal…:inbound:low | API, Workflow | Enricher |
notifkit:stream:enriched:critical…:enriched:normal…:enriched:low | Enricher, Delivery (fallback) | Engine |
notifkit:stream:outbound:critical…:outbound:normal…:outbound:low | Engine, Scheduler, AI | Delivery |
notifkit:stream:ai:pending | Engine | AI |
notifkit:stream:scheduled | Engine, Delivery | Scheduler |
notifkit:stream:workflow:inbound | API, Workflow timers | Workflow |
notifkit:stream:events:inbound | API, Delivery | Events |
notifkit:stream:dlq | Any worker | Humans, via /v1/dlq |
Each consumer reads through a named group — notifkit:group:enricher,
notifkit:group:engine, notifkit:group:delivery, and so on — with
consumer set to {service}-{pid}. Adding a process adds a consumer to
the group and Redis divides the work; no configuration, no rebalance step.
Sorted sets and locks
| Key | Purpose |
|---|---|
notif:scheduled:zset:0…15 | Future-dated tasks, sharded 16 ways by the last hex digit of the task id. |
notif:workflow:timers | Workflow wake-ups for wait and waitForEvent timeouts. |
notif:lock:scheduler:poll | Ensures one scheduler polls at a time. 30-second TTL. |
lock:workflow:{instanceId} | Serialises execution of one workflow instance. 60-second TTL, renewed while running. |
throttle:{projectId}:user:{userId} | Sliding-window send counter per user. |
notif:processed:{worker}:* | Idempotency markers. 24-hour TTL. |
notif:health:{worker} | Worker heartbeat, read by /health. |
Delivery guarantees
At-least-once. A message is acknowledged only after process()
returns successfully. If a worker dies mid-flight the message stays in the group's pending
list and another consumer claims it.
Not exactly-once. If a provider accepts a send but its response never arrives — a timeout on the return path — NotifKit records a failure and retries. The user receives it twice. No amount of internal deduplication can prevent this; it is inherent to calling a remote API without a two-phase commit.
Ordering is not guaranteed. Messages are processed concurrently, up to
WORKER_CONCURRENCY per worker. Two sends to the same user may arrive in either
order. If order matters, use a workflow, which is serialised per instance.
Where deduplication happens
| Stage | Key | Window |
|---|---|---|
| Enricher | projectId : idempotencyKey | 24h |
| Engine | rawEventId : recipientId : channel | 24h, extended for scheduled sends |
| AI | enrichedEventId : recipientId : channel : ai | 24h |
| Delivery | taskId | 24h |
Marks are provisional: a stage that fails after claiming a key releases it, so a retry is not swallowed as a duplicate.
Retries and the dead-letter queue
There is no exponential backoff. Recovery is driven by Redis pending-entry reclamation on a fixed interval, which makes the timing predictable and the mechanism crash-safe:
Failure classes
| Failure | Handling |
|---|---|
| Provider timeout or 5xx | Counts as a failed attempt. Fallback chain first, then the retry cycle. |
| Provider says the token is invalid | Contact deactivated immediately. Not retried on that address. |
| Provider rate limit | Parked with the scheduler and re-attempted when the window reopens, up to maxAttempts. |
| Malformed payload | Logged and skipped without an ack cycle — a message that cannot be parsed will never parse. |
| Poison message | Counter exceeds the limit, message is nacked into the DLQ, notification:failed is emitted. |
| AI 4xx or unsupported model | Permanent error, no retry — retrying would just re-bill the same rejection. |
Backpressure
Streams are trimmed to QUEUE_MAX_LEN (10 million entries by default) as they are
written. That is a safety valve against unbounded memory growth, not a flow-control mechanism
— past the cap, the oldest entries are discarded.
Real backpressure comes from the consumer side. Workers read a bounded batch and process at
most WORKER_CONCURRENCY messages at once, so when a provider slows down the
Delivery worker pulls fewer messages, its pending list grows, and the upstream stages
accumulate depth rather than overrunning anything. Stream depth is the number to alert on.
Deployment topologies
Single process
new NotifkitServer({ services: ["all"] });
Everything in one process. Correct for development, and fine in production up to the point where one service's load starts interfering with another's.
Split by role
The services array is the only thing that differs between deployments. Point them
at the same Redis and Postgres and they form one system.
// Deployment 1 — behind your load balancer, scaled on request rate.
new NotifkitServer({
services: ["api"],
redisUrl: process.env.REDIS_URL,
databaseUrl: process.env.DATABASE_URL,
nodeEnv: "production",
});
// Deployment 2 — the pipeline, scaled on stream depth.
new NotifkitServer({
services: ["enricher", "engine", "scheduler", "events"],
redisUrl: process.env.REDIS_URL,
databaseUrl: process.env.DATABASE_URL,
nodeEnv: "production",
autoMigrate: false, // exactly one deployment should own migrations
workerConcurrency: 25,
});
// Deployment 3 — the part that talks to providers, scaled on their limits.
new NotifkitServer({
services: ["delivery"],
redisUrl: process.env.REDIS_URL,
databaseUrl: process.env.DATABASE_URL,
nodeEnv: "production",
autoMigrate: false,
providers: [new ResendTransport({ apiKey: RESEND_KEY, from: "hi@acme.com" })],
});
Transports must be registered wherever delivery runs — that is the only service
that calls send(). If you also expose provider webhooks, the api
deployment needs the same transports registered, because that is where the webhook routes
are mounted.
What scales on what
| Service | Bound by | Signal to scale on |
|---|---|---|
api | Request rate | HTTP latency, 429 rate |
enricher | Postgres reads | Inbound stream depth |
engine | Redis round trips | Enriched stream depth |
delivery | Provider latency and limits | Outbound stream depth |
scheduler | Poll interval | Rarely — one instance handles a lot |
ai | Model latency and cost | AI stream depth |
workflow | Instance count | Workflow stream depth |
events | Postgres writes | Events stream depth |
Each service opens its own connection pool, defaulting to DB_MAX_CONNECTIONS = 2
per process. A services: ["all"] process therefore holds around sixteen
connections. Multiply by your replica count before sizing Postgres —
max_connections exhaustion is the most common scaling wall here.
The data model
| Table | Holds |
|---|---|
projects | Tenants, with optional per-project rate and throttle overrides. |
project_api_keys | SHA-256 key hashes and a role. Never the key itself. |
users | One row per (project, external id). Profile attributes as JSONB. |
user_contacts | Addresses. Unique on (user, channel, target), with an enabled flag. |
user_segments | Segment tags. |
user_channel_preferences, user_topic_preferences, quiet_hours | Normalised preference state. |
templates | Keyed on (project, id). Content and AI prompts as JSONB. |
message_logs | Delivery attempts and engagement events. Unique on (task, channel, attempt, kind). campaign_id groups a send for reporting; metadata holds provider detail such as the clicked URL. |
suppressions | Addresses that must not be contacted again, unique on (project, channel, target). Written from provider webhooks and the unsubscribe endpoint; read by the engine before dispatch. |
delivery_outbox | Provider message ids, keyed on (task, channel, destination). |
scheduled_payloads | Fully rendered tasks awaiting their send time. |
workflow_definitions, workflow_instances, workflow_steps, workflow_waiters | Workflow state and step outputs. |
Migrations run on startup unless you pass autoMigrate: false. With several
deployments sharing one database, let exactly one of them own migrations.
Multi-tenancy
Every query is scoped by projectId, which is derived from the API key rather than
from anything in the request body — a caller cannot address another tenant's data by changing
a field. Keys are hashed with SHA-256 and cached for 60 seconds; revoking one publishes an
invalidation so every API process drops it immediately instead of waiting out the TTL.
The admin key is the exception: it can act on behalf of any project, but must name one with an
x-project-id header or a projectId query parameter. Requests without
it are rejected rather than defaulted.