Architecture
notifkit is eight services talking through Redis Streams, with PostgreSQL as the system of record. Every service is stateless and scales horizontally; all coordination happens in Redis. This page is the contract underneath.
The streams
Priority is separate physical streams, not a score, so a low-priority backlog can't sit 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}. Each new process joins the group and
Redis divides the work automatically.
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. 1-hour TTL (IDEMPOTENCY_TTL_SECONDS); 24 hours for a caller's x-idempotency-key. |
notif:health:{worker} | Worker heartbeat, read by /health. |
Delivery guarantees
Delivery is at-least-once. A message is acknowledged only after process() succeeds,
so if a worker dies mid-flight, the message stays in the group's pending list for another
consumer to claim.
The cost is the duplicate case. If a provider accepts a send but its response never arrives (a timeout on the way back), notifkit records a failure and retries, and the user gets it twice. No internal deduplication can prevent this; it's inherent to calling a remote API without two-phase commit.
Ordering isn't guaranteed either. Messages are processed concurrently, up to
WORKER_CONCURRENCY per worker, so two sends to one 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, or projectId : eventId without one | 24h for a caller's key, otherwise 1h |
| Engine | rawEventId : recipientId : channel | 1h, extended for scheduled sends |
| AI | enrichedEventId : recipientId : channel : ai | 1h |
| Delivery | taskId | 1h |
Marks are provisional: a stage that fails after claiming a key releases it, so a retry isn't swallowed as a duplicate.
Retries and the dead-letter queue
There's no exponential backoff. Recovery uses Redis pending-entry reclamation on a fixed interval, so timing is 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're
written. It's a safety valve against unbounded memory growth, not flow control: past the cap, the
oldest entries are discarded.
Real backpressure comes from consumers. Workers read a bounded batch and process at most
WORKER_CONCURRENCY messages at once, so when a provider slows, Delivery pulls
fewer messages, its pending list grows, and upstream stages build depth instead of overrunning
anything. Alert on stream depth.
Deployment topologies
Single process
new NotifkitServer({ services: ["all"] });
Everything in one process: right for development, and fine in production until one service's load starts affecting another's.
Split by role
Only the services array 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: "[email protected]" })],
});
Register transports wherever delivery runs, the only service that calls
send(). If you expose provider webhooks, register them on the api
deployment too, 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 pool, defaulting to DB_MAX_CONNECTIONS = 2 per process,
so a services: ["all"] process holds about sixteen connections. Multiply by
replicas before sizing Postgres: running out of max_connections is the most common
scaling wall.
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 on one database, let exactly one own migrations.
These tables live in public by default. When notifkit shares a database with
another app, set DB_SCHEMA to give them their own schema; see Installing into a
custom PostgreSQL schema.
Multi-tenancy
Every query is scoped by projectId, derived from the API key, not the request body,
so changing a field can't reach another tenant's data. Keys are hashed with SHA-256 and cached
for 60 seconds; revoking one publishes an invalidation so every API process drops it at once
rather than waiting out the TTL.
The admin key is the exception: it can act for any project, but must name one with an
x-project-id header or projectId query parameter. Requests without it
are rejected, not defaulted.