Star
Run it

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 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}. Each new process joins the group and Redis divides the work automatically.

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. 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

StageKeyWindow
EnricherprojectId : idempotencyKey, or projectId : eventId without one24h for a caller's key, otherwise 1h
EnginerawEventId : recipientId : channel1h, extended for scheduled sends
AIenrichedEventId : recipientId : channel : ai1h
DeliverytaskId1h

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:

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.
A worker that throws requeues nothing; it just never acks, and the message becomes reclaimable after about a minute idle. That's why a hard-killed process loses nothing: to the rest of the system, an unacked message and a failed one look the same.

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'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]" })],
});
Important

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

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 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

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 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.