← website
Run it

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 keyWritten byRead by
notifkit:stream:inbound:critical
…:inbound:normal
…:inbound:low
API, WorkflowEnricher
notifkit:stream:enriched:critical
…:enriched:normal
…:enriched:low
Enricher, Delivery (fallback)Engine
notifkit:stream:outbound:critical
…:outbound:normal
…:outbound:low
Engine, Scheduler, AIDelivery
notifkit:stream:ai:pendingEngineAI
notifkit:stream:scheduledEngine, DeliveryScheduler
notifkit:stream:workflow:inboundAPI, Workflow timersWorkflow
notifkit:stream:events:inboundAPI, DeliveryEvents
notifkit:stream:dlqAny workerHumans, 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

KeyPurpose
notif:scheduled:zset:0…15Future-dated tasks, sharded 16 ways by the last hex digit of the task id.
notif:workflow:timersWorkflow wake-ups for wait and waitForEvent timeouts.
notif:lock:scheduler:pollEnsures 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

StageKeyWindow
EnricherprojectId : idempotencyKey24h
EnginerawEventId : recipientId : channel24h, extended for scheduled sends
AIenrichedEventId : recipientId : channel : ai24h
DeliverytaskId24h

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:

returns XREADGROUP batch of WORKER_ CONCURRENCY process() counter incremented before the attempt XACK counter deleted leaves the pending list done throws not acknowledged stays in the group's pending list XAUTOCLAIM recovery sweep, every 60 seconds retry · any worker may claim it over limit dead letter inspect · replay delete Attempt counter lives in Redis with a 2-hour TTL. Limit: 3 attempts, except the Enricher at 5.
Failure is the absence of an acknowledgement. A worker that throws does not requeue anything — it simply never acks, and the message becomes eligible for reclamation after about a minute of idleness. That is why a hard-killed process loses nothing: an unacked message is indistinguishable from a failed one.

Failure classes

FailureHandling
Provider timeout or 5xxCounts as a failed attempt. Fallback chain first, then the retry cycle.
Provider says the token is invalidContact deactivated immediately. Not retried on that address.
Provider rate limitParked with the scheduler and re-attempted when the window reopens, up to maxAttempts.
Malformed payloadLogged and skipped without an ack cycle — a message that cannot be parsed will never parse.
Poison messageCounter exceeds the limit, message is nacked into the DLQ, notification:failed is emitted.
AI 4xx or unsupported modelPermanent 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" })],
});
important

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

ServiceBound bySignal to scale on
apiRequest rateHTTP latency, 429 rate
enricherPostgres readsInbound stream depth
engineRedis round tripsEnriched stream depth
deliveryProvider latency and limitsOutbound stream depth
schedulerPoll intervalRarely — one instance handles a lot
aiModel latency and costAI stream depth
workflowInstance countWorkflow stream depth
eventsPostgres writesEvents stream depth
watch the pool

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

TableHolds
projectsTenants, with optional per-project rate and throttle overrides.
project_api_keysSHA-256 key hashes and a role. Never the key itself.
usersOne row per (project, external id). Profile attributes as JSONB.
user_contactsAddresses. Unique on (user, channel, target), with an enabled flag.
user_segmentsSegment tags.
user_channel_preferences, user_topic_preferences, quiet_hoursNormalised preference state.
templatesKeyed on (project, id). Content and AI prompts as JSONB.
message_logsDelivery 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.
suppressionsAddresses 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_outboxProvider message ids, keyed on (task, channel, destination).
scheduled_payloadsFully rendered tasks awaiting their send time.
workflow_definitions, workflow_instances, workflow_steps, workflow_waitersWorkflow 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.