Operations
What to watch, what to alert on, and what to do at 3am when notifications stop.
Health surface
| Endpoint | Auth | Answers | Use for |
|---|---|---|---|
GET /live | none | Always 200 while the process is up | Liveness probe |
GET /ready | none | 200 if Redis and Postgres respond, else 503 | Readiness probe |
GET /health | none | Dependencies plus every worker's heartbeat. 503 if any is unhealthy | Dashboards, paging |
GET /metrics | none | Prometheus exposition | Scraping |
GET /v1/system/health | key | The same, with Redis and Postgres latencies | In-app status pages |
GET /v1/system/metrics | key | Per-stream depth and delivery success rate | Triage |
/health and /metrics are deliberately unauthenticated so probes and
scrapers need no secrets, so keep them off the public internet. Restrict them at your ingress,
or bind the API to an internal interface and publish only what you mean to.
A worker with no heartbeat reports
{ status: "unknown", message: "No report received from worker" }. In a split
deployment that's expected, since the API can't see workers you didn't deploy. In a
services: ["all"] process it means the worker died.
Metrics
| Metric | Labels | Watch for |
|---|---|---|
notifkit_messages_published_total | channel, priority | Send volume. A sudden spike is often a loop in your code. |
notifkit_messages_processed_total | worker, status | The error ratio per worker. |
notifkit_delivery_success_total | channel | Baseline throughput. |
notifkit_delivery_failed_total | channel, reason | reason separates provider_error from invalid_token and push_error. |
notifkit_queue_size | stream | Sustained growth means a worker cannot keep up. |
notifkit_pending_acks | group | Rising means messages are being claimed and failing. |
notifkit_worker_active_tasks | worker | Pinned at WORKER_CONCURRENCY means saturated. |
Default Node.js process metrics (heap, event loop lag, GC) are exported alongside these.
Alerts worth having
| Condition | Means |
|---|---|
notifkit_queue_size rising for 10 minutes on one stream | The worker reading it is under-provisioned. Scale that service. |
notifkit_queue_size above 80% of QUEUE_MAX_LEN | Workers are well behind. Scale them, and check Redis memory. Before 0.1.15 the next burst trims queued messages unsent. |
delivery_failed_total / delivery_success_total > 5% | A provider is degraded, or credentials expired. |
| DLQ length > 0 | Something is failing every attempt. Always worth a look. |
/health returning 503 | Redis or Postgres is unreachable, or a worker is dead. |
pending_acks climbing while queue_size is flat | Messages are being retried in a loop, likely a poison payload. |
When nothing is arriving
202. The log line tells them apart, so most incidents end at step three.
The dead-letter queue
A message that exhausts its attempts moves to notifkit:stream:dlq. Nothing there is
retried 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 republishes the entry to the inbound stream at its original priority and removes it from the DLQ. Fix the cause first (a bad template, missing transport, expired credential), or it lands right back.
The DLQ endpoints work on the shared stream, unfiltered by project. Treat them as operator tools and give only admin-role keys to whatever calls them.
Configuration
| Variable | Default | Effect |
|---|---|---|
NODE_ENV | development | production requires explicit Redis and database URLs and disables the throwaway containers. |
LOG_LEVEL | info | Skip reasons are logged at info: do not run production at warn or you lose them. |
PORT / HOST | 3000 / 127.0.0.1 | Set HOST=0.0.0.0 to accept connections from outside the container. |
REDIS_URL | redis://localhost:6379 | Every service must point at the same instance. |
DATABASE_URL | local Postgres | Same database across services. |
ADMIN_API_KEY | unset | Enables project management. Leave unset in production deployments that do not need it. |
ADMIN_EMAIL / ADMIN_PASSWORD / ADMIN_USERNAME | unset | Creates 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_ORIGIN | unset | Comma-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_PROXY | false | Set 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_CONCURRENCY | 10 | Messages processed in parallel per worker. Raising it raises database and provider pressure in step. |
DELIVERY_CONCURRENCY | WORKER_CONCURRENCY | In-flight sends per delivery worker, when provider latency calls for a different number than the other stages. |
PIPELINE_FUSED | false | When 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_CONNECTIONS | 2 | Pool size per service, per process. Multiply carefully. |
DB_SCHEMA | public | PostgreSQL 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_LEN | 100000 | Backlog size per stream that triggers a warning, logged at 80%. From 0.1.15 nothing is trimmed; before that, Redis trimmed the oldest queued messages, delivered or not. See the callout below. |
IDEMPOTENCY_TTL_SECONDS | 3600 | How 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_USERS | 100000 (10000 up to 0.1.16) | A segment resolving to more than this is refused outright. |
RATE_LIMIT_PER_HOUR | 100 | Per-user hourly send cap. critical bypasses it. |
LOG_FLUSH_INTERVAL_MS | 500 | How often delivery history is batched into Postgres. |
LOG_BUFFER_MAX_SIZE | 5000 | Buffered log rows before the oldest are dropped to protect memory. |
LOG_RETENTION_DAYS | unset | Days of delivery logs and outbox rows to keep. When set, the scheduler deletes older rows once an hour, in small batches. Unset keeps them forever. |
Constructor options beat environment variables, which beat .env, which beats defaults.
From notifkit 0.1.15, QUEUE_MAX_LEN only sets when a warning is logged (at 80%).
Nothing is trimmed, so a backlog of any size is kept until it's sent. The real limit is Redis
memory, about 1 KB per queued message; see
sizing Redis.
Before 0.1.15, every publish trimmed its stream to roughly this length. If 300,000
notify() calls arrived while workers were down, the default kept the newest
100,000 and dropped the rest unsent. On those versions, upgrade or raise the cap above your
largest expected burst.
Per-project limits
Each project has its own API request ceiling: 600 requests per minute by default, 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.
| Field | Overrides | Null means |
|---|---|---|
rateLimitRpm | API requests per minute for this project | 600 |
throttleLimit | Messages per user per window | RATE_LIMIT_PER_HOUR |
throttleWindowHours | Length of that window | 1 hour |
Changes apply 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 at once.
throttleLimit of 0 stops all non-critical sends for the project, a
usable kill switch; critical priority still bypasses the throttle.
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 invalidates the cache in every API process, so it takes effect immediately, not after the 60-second auth cache expires.
Give read_only keys to anything that only reads: dashboards, status pages, reporting
jobs. They get 403 on any non-GET request.
UNSUBSCRIBE_SECRET isn't an API key; don't rotate it on the same schedule. It signs
unsubscribe tokens that live in inboxes indefinitely;
changing it invalidates every link already sent, and recipients with a broken link hit the spam
button instead.
Suppressions and deliverability
The suppression list records people who asked you to stop, plus addresses that no longer exist.
It's checked before every dispatch, and priority: "critical" doesn't 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":"[email protected]","reason":"manual"}'
Watch two numbers per campaign, both from GET /v1/campaigns/{campaign}/stats:
| Figure | Watch for |
|---|---|
complaintRate | Above 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. |
bounceRate | A 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. |
DELETE /v1/suppressions/{channel}/{target} is for genuine cases: a typo'd address,
someone asking to re-subscribe. Clearing suppressions to improve a delivery figure re-mails
people who unsubscribed or complained: 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 get up to 30 seconds to finish, buffered log rows are
flushed, and connections close. Anything unfinished at the deadline stays unacked and another
worker reclaims it, so nothing is lost.
Give your orchestrator a termination grace period above 30 seconds so it doesn't
SIGKILL mid-flush.
// Shutting down explicitly, e.g. from a test harness.
await server.stop();
Production checklist
NODE_ENV=production, withREDIS_URLandDATABASE_URLset explicitly.autoMigrate: falseeverywhere except the one deployment that owns migrations./healthand/metricsunreachable from the public internet.ADMIN_API_KEYunset on deployments that do not manage projects.- Postgres
max_connectionssized forservices × processes × DB_MAX_CONNECTIONS. - Recommended: Redis persistence (AOF) on. In-flight messages and pending lists live there, so without it a restart loses whatever's queued. Managed Redis without AOF, such as ElastiCache, works with that trade-off.
- Termination grace period above 30 seconds.
- notifkit 0.1.15 or later, so a large backlog is never trimmed. On older versions,
QUEUE_MAX_LENabove your worst expected backlog. TRUST_PROXY=trueif, and only if, notifkit sits behind a reverse proxy.- Alerts on stream depth, delivery failure ratio, and DLQ length.
- Transports registered on every
deliverydeployment, and onapiif you use provider webhooks.