← back
Star
Run it

Operations

What to watch, what to alert on, and what to do at three in the morning when the notifications stop.

Health surface

EndpointAuthAnswersUse for
GET /livenoneAlways 200 while the process is upLiveness probe
GET /readynone200 if Redis and Postgres respond, else 503Readiness probe
GET /healthnoneDependencies plus every worker's heartbeat. 503 if any is unhealthyDashboards, paging
GET /metricsnonePrometheus expositionScraping
GET /v1/system/healthkeyThe same, with Redis and Postgres latenciesIn-app status pages
GET /v1/system/metricskeyPer-stream depth and delivery success rateTriage
security

/health and /metrics are deliberately unauthenticated so probes and scrapers work without secrets, which means they must not be reachable from the public internet. Restrict them at your ingress, or bind the API to an internal interface and publish only what you intend to.

A worker reports { status: "unknown", message: "No report received from worker" } when its heartbeat is missing. In a split deployment that is expected: the API process cannot see workers you did not deploy. In a services: ["all"] process it means that worker died.

Metrics

MetricLabelsWatch for
notifkit_messages_published_totalchannel, prioritySend volume. A sudden spike is often a loop in your code.
notifkit_messages_processed_totalworker, statusThe error ratio per worker.
notifkit_delivery_success_totalchannelBaseline throughput.
notifkit_delivery_failed_totalchannel, reasonreason separates provider_error from invalid_token and push_error.
notifkit_queue_sizestreamSustained growth means a worker cannot keep up.
notifkit_pending_acksgroupRising means messages are being claimed and failing.
notifkit_worker_active_tasksworkerPinned at WORKER_CONCURRENCY means saturated.

Default Node.js process metrics (heap, event loop lag, GC) are exported alongside these.

Alerts worth having

ConditionMeans
notifkit_queue_size rising for 10 minutes on one streamThe worker reading it is under-provisioned. Scale that service.
notifkit_queue_size above 80% of QUEUE_MAX_LENThe next burst will trim queued messages unsent. Scale workers or raise the cap now.
delivery_failed_total / delivery_success_total > 5%A provider is degraded, or credentials expired.
DLQ length > 0Something is failing every attempt. Always worth a look.
/health returning 503Redis or Postgres is unreachable, or a worker is dead.
pending_acks climbing while queue_size is flatMessages are being retried in a loop, likely a poison payload.

When nothing is arriving

check in this order GET /health are the dependencies up? redis or database false Nothing downstream matters until this is fixed. GET /v1/system/metrics where is the backlog? one stream deep, the rest empty Its reader is the bottleneck. Scale that service. server logs, info level was it dropped on purpose? "opted out" · "channel disabled" · "no active contacts" Working as intended. Fix the data, not the system. GET /v1/dlq did it fail every attempt? entries present Read the error, fix the cause, then replay.
From the caller's side a notification that was deliberately dropped looks exactly like one that was lost, because both returned 202. The log line is what tells them apart, which is why most incidents end at step three.

The dead-letter queue

A message that exhausts its attempts is moved to notifkit:stream:dlq. Nothing is retried from there automatically: a poison payload would just loop.

# Most recent 50
curl -s http://localhost:3000/v1/dlq -H "Authorization: Bearer $NK_KEY" | jq

# Put one back at the front of the pipeline
curl -X POST http://localhost:3000/v1/dlq/replay \
  -H "Authorization: Bearer $NK_KEY" \
  -H "Content-Type: application/json" \
  -d '{"id":"1737054981234-0"}'

# Discard one
curl -X DELETE http://localhost:3000/v1/dlq/1737054981234-0 -H "Authorization: Bearer $NK_KEY"

A replay re-publishes the entry to the inbound stream at its original priority and removes it from the DLQ. Fix the underlying cause first (a bad template, a missing transport, an expired credential), or the same message lands right back.

note

The DLQ endpoints operate on the shared stream and are not filtered by project. Treat them as operator tools and issue only admin-role keys to whatever calls them.

Configuration

VariableDefaultEffect
NODE_ENVdevelopmentproduction requires explicit Redis and database URLs and disables the throwaway containers.
LOG_LEVELinfoSkip reasons are logged at info: do not run production at warn or you lose them.
PORT / HOST3000 / 127.0.0.1Set HOST=0.0.0.0 to accept connections from outside the container.
REDIS_URLredis://localhost:6379Every service must point at the same instance.
DATABASE_URLlocal PostgresSame database across services.
ADMIN_API_KEYunsetEnables project management. Leave unset in production deployments that do not need it.
ADMIN_EMAIL / ADMIN_PASSWORD / ADMIN_USERNAMEunsetCreates the first dashboard login on startup, if no user with that email exists yet. Password at least 6 characters; username optional. Applied once: changing the password here later does not change an existing user's.
CORS_ORIGINunsetComma-separated origins allowed to call the session routes (/v1/auth, /v1/projects, /v1/system, /v1/dlq) from a browser. Unset allows none cross-origin; every other route allows any origin.
TRUST_PROXYfalseSet true behind a reverse proxy, so the per-IP limits on login and unsubscribe see the real client from X-Forwarded-For. Leave it off when clients reach notifkit directly: they could set that header themselves and dodge the limits.
WORKER_CONCURRENCY10Messages processed in parallel per worker. Raising it raises database and provider pressure in step.
DELIVERY_CONCURRENCYWORKER_CONCURRENCYIn-flight sends per delivery worker, when provider latency calls for a different number than the other stages.
PIPELINE_FUSEDfalseWhen enricher, engine and delivery run in one process, hand single-recipient sends between them in memory instead of through the streams. Saves two stream round trips per message; a crash replays the chain from the enricher. Fan-outs, scheduled sends, AI and retries still use the streams.
DB_MAX_CONNECTIONS2Pool size per service, per process. Multiply carefully.
DB_SCHEMApublicPostgreSQL schema that holds notifkit's tables, for sharing a database with an app that owns public. Same value on every service. Changing it on a running install does not move data: see Deployment.
QUEUE_MAX_LEN100000Cap on each stream's length, which is effectively its unprocessed backlog. Past it, Redis trims the oldest queued messages, delivered or not. A warning is logged at 80%. Size it above your worst backlog; see the callout below.
IDEMPOTENCY_TTL_SECONDS3600How long each stage remembers a message it handled. Must outlast redelivery (about 10 minutes) plus your worst stream backlog. A caller's x-idempotency-key is kept 24 hours regardless. Costs a few hundred bytes of Redis per message for as long as it lasts.
SEGMENT_MAX_USERS10000A segment resolving to more than this is refused outright.
RATE_LIMIT_PER_HOUR100Per-user hourly send cap. critical bypasses it.
LOG_FLUSH_INTERVAL_MS500How often delivery history is batched into Postgres.
LOG_BUFFER_MAX_SIZE5000Buffered log rows before the oldest are dropped to protect memory.

Constructor options beat environment variables, which beat .env, which beats the defaults.

backlog cap

QUEUE_MAX_LEN is the one setting that can lose messages. Every publish trims its stream to roughly this length, and acknowledged entries are already pruned, so what gets trimmed is work nobody has done yet. If 300,000 notify() calls arrive while the workers are down, the default keeps the newest 100,000 and drops the rest unsent. Raise it to cover the largest burst you expect to queue faster than you deliver; each queued message costs Redis memory, so size Redis with it. Alert on notifkit_queue_size before it gets close.

Per-project limits

Each project has its own API request ceiling: 600 requests per minute by default, enforced as a sliding window in Redis. Over it, callers get 429 with Retry-After: 60.

curl -X PATCH http://localhost:3000/v1/projects/{projectId} \
  -H "Authorization: Bearer $ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"rateLimitRpm": 3000, "throttleLimit": 50, "throttleWindowHours": 24}'

Requests made with the admin key are allowed 6 000 per minute.

FieldOverridesNull means
rateLimitRpmAPI requests per minute for this project600
throttleLimitMessages per user per windowRATE_LIMIT_PER_HOUR
throttleWindowHoursLength of that window1 hour

Changes take effect on the next notification: the engine caches these per project for 60 seconds, and a PATCH publishes an invalidation so every worker drops its copy immediately. Setting throttleLimit to 0 stops all non-critical sends for that project: a usable kill switch. critical priority still bypasses the throttle regardless.

Key rotation

# Mint the replacement
curl -X POST http://localhost:3000/v1/projects/{projectId}/keys \
  -H "Authorization: Bearer $ADMIN_API_KEY" \
  -H "Content-Type: application/json" -d '{"role":"admin"}'

# List what exists — returns id, role, and createdAt. Never the key itself.
# Take the id of the key you are retiring; that is the {keyId} below.
curl -s http://localhost:3000/v1/projects/{projectId}/keys -H "Authorization: Bearer $ADMIN_API_KEY"

# Revoke the old one
curl -X DELETE http://localhost:3000/v1/projects/{projectId}/keys/{keyId} \
  -H "Authorization: Bearer $ADMIN_API_KEY"

Deploy the new key, then revoke the old one. Revocation publishes a cache invalidation to every API process, so it takes effect immediately instead of after the 60-second auth cache expires.

Give read_only keys to anything that only reads: dashboards, status pages, reporting jobs. They are rejected with 403 on any non-GET request.

not part of rotation

UNSUBSCRIBE_SECRET is not an API key and must not be rotated on the same schedule. It signs unsubscribe tokens that live in inboxes indefinitely; changing it invalidates every link already sent, and a recipient whose unsubscribe link fails reaches for the spam button instead.

Suppressions and deliverability

The suppression list is the record of people who asked to stop hearing from you, plus addresses that no longer exist. It is consulted before every dispatch and priority: "critical" does not override it.

# Everything suppressed, newest first
curl -s "http://localhost:3000/v1/suppressions?limit=200" -H "Authorization: Bearer $NK_KEY"

# Only the hard bounces — a spike here usually means a stale or bought list
curl -s "http://localhost:3000/v1/suppressions?reason=bounced" -H "Authorization: Bearer $NK_KEY"

# Someone asked to be removed by phone or reply
curl -X POST http://localhost:3000/v1/suppressions -H "Authorization: Bearer $NK_KEY" \
  -H "Content-Type: application/json" \
  -d '{"channel":"email","target":"person@example.com","reason":"manual"}'

Two numbers are worth watching per campaign, both from GET /v1/campaigns/{campaign}/stats:

FigureWatch for
complaintRateAbove roughly 0.3% mailbox providers begin throttling or junking you. This is the one that ends deliverability, and it lands on the whole sending domain: transactional mail included.
bounceRateA sustained climb means the list is stale. Hard bounces suppress themselves, so a rate that stays high implies new bad addresses arriving faster than they are removed.
never bulk-clear suppressions

DELETE /v1/suppressions/{channel}/{target} exists for the genuine case: a typo'd address, someone asking to be re-subscribed. Clearing suppressions to make a delivery figure look better re-mails people who unsubscribed or complained, which is both the fastest route to a blocked domain and, for unsubscribes, unlawful in most jurisdictions.

Shutdown

SIGINT and SIGTERM trigger a graceful stop: the HTTP server closes, consumers stop reading, in-flight messages are allowed to finish (up to 30 seconds), buffered log rows are flushed, and connections close. Anything unfinished at the deadline is left unacked and reclaimed by another worker: nothing is lost.

Give your orchestrator a termination grace period above 30 seconds so it does not SIGKILL mid-flush.

// Shutting down explicitly, e.g. from a test harness.
await server.stop();

Production checklist

  • NODE_ENV=production, with REDIS_URL and DATABASE_URL set explicitly.
  • autoMigrate: false everywhere except the one deployment that owns migrations.
  • /health and /metrics unreachable from the public internet.
  • ADMIN_API_KEY unset on deployments that do not manage projects.
  • Postgres max_connections sized for services × processes × DB_MAX_CONNECTIONS.
  • Recommended: Redis persistence on (AOF). In-flight messages and pending lists live there, so without it a Redis restart loses whatever is still queued. Managed Redis without AOF, such as ElastiCache, works with that trade-off.
  • Termination grace period above 30 seconds.
  • QUEUE_MAX_LEN above your worst expected backlog, since anything past it is trimmed unsent.
  • TRUST_PROXY=true if, and only if, notifkit sits behind a reverse proxy.
  • Alerts on stream depth, delivery failure ratio, and DLQ length.
  • Transports registered on every delivery deployment, and on api if you use provider webhooks.