Operations
What to watch, what to alert on, and what to do at three in the morning when the 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 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
| 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. |
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 is the difference.
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.
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
| 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. |
WORKER_CONCURRENCY | 10 | Messages processed in parallel per worker. Raising it raises database and provider pressure in step. |
DB_MAX_CONNECTIONS | 2 | Pool size per service, per process. Multiply carefully. |
QUEUE_MAX_LEN | 10000000 | Stream trim threshold. |
SEGMENT_MAX_USERS | 10000 | 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. |
Constructor options beat environment variables, which beat .env, which beats the
defaults.
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.
| 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 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 rather than after the 60-second auth cache expires.
Issue read_only keys to anything that only reads — dashboards, status pages,
reporting jobs. They are rejected with 403 on any non-GET request.
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:
| 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} 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, 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. - Redis persistence on — in-flight messages and pending lists live there.
- Termination grace period above 30 seconds.
- Alerts on stream depth, delivery failure ratio, and DLQ length.
- Transports registered on every
deliverydeployment, and onapiif you use provider webhooks.