* feat(db): install into a configurable PostgreSQL schema
Add DB_SCHEMA (and dbOptions.schema / createDatabase({ schema }) /
runMigrations(db, { schema })) so notifkit can live in a schema other than
public. Defaults to public, where behaviour is unchanged.
For a custom schema, runMigrations creates the schema, retargets the
bundled migrations at it (rewrites the "public". qualifiers drizzle-kit
emits and sets search_path for the unqualified objects with SET LOCAL,
inside the migration transaction), refuses any migration that still
mentions public afterwards, and keeps the drizzle journal in the same
schema. Afterwards it checks via information_schema that every table of
the package's Drizzle schema exists in the target schema and throws with
the names of any that are missing. createDatabase sets search_path on
every connection.
Add a notifkit-migrate bin (next to notifkit-create-admin) that reads
DATABASE_URL and DB_SCHEMA, applies the migrations, closes the connection
and exits non-zero on failure. The README now uses it for production
migrations instead of drizzle-kit, which ignores DB_SCHEMA, and warns
that switching an existing public install to DB_SCHEMA without moving
its tables first makes it look empty.
Closes #4
* test(db): fix typecheck and lint, move schema tests under tests/
---------
Co-authored-by: Alexander Savchenko <savchenko_av@nlmk.com>
Co-authored-by: coo1estguy <70365929+coolestcodeguy@users.noreply.github.com>
24 KiB
notifkit
You shouldn't have to build a notification system.
Self-hosted notification infrastructure for product notifications. One API call handles email, SMS, push, and webhooks, with preferences, quiet hours, retries, fallback, scheduling, workflows, an admin dashboard, and delivery logs built in.
Sending one notification is easy
await sendEmail({
to: user.email,
subject: "Order Shipped",
...
});
Sending them reliably is the hard part. Users opt out. People are asleep. Push tokens die. Providers throw 503s. Channels fail and need fallback. Somewhere along the way you need timezone-aware quiet hours, future scheduling, deduplication, multi-channel templates, delivery logs, unsubscribe handling, multi-step workflows, and a dead-letter queue nobody wants to maintain.
notifkit is that machinery. Your app makes one typed call, and notifkit decides who gets the notification, which channel to use, when to send it, whether the user is allowed to receive it, and what to do when delivery fails.
import { notifkit } from "notifkit";
await notifkit.notify({
user: "usr_123",
template: "order-shipped",
channels: ["push", "email"],
fallback: true,
});
That call tries push, then email if push fails. Preferences, consent, quiet hours, retries, deduplication, throttling, template rendering, and delivery tracking all happen behind it.
How it runs
notifkit is an orchestration engine and a typed SDK.
flowchart TD
App["Your Application / AI Agent / Browser<br/>Typed SDK · REST API · MCP Server · Admin Dashboard"]
App -->|"HTTP POST /v1/notify"| API
App -->|"HTTP GET /admin"| API
API["Notifkit API Server<br/>Schema Validation · Auth · Multi-Tenancy<br/>Admin SPA · Priority Queue Ingestion"]
API --> PG
API --> REDIS
PG[("PostgreSQL — Storage<br/>Users · Preferences<br/>Templates · Workflows<br/>Delivery Logs · DLQ")]
REDIS[("Redis — Streams / ZSET<br/>Priority Queues<br/>Scheduled Sends<br/>Sliding Rate Limits")]
subgraph WORKERS["Background Workers Pipeline"]
direction LR
ENRICH["Enricher<br/>(Resolve)"] --> ENGINE["Engine<br/>(Quiet Hours)"] --> DELIVER["Delivery<br/>(Rate Limits / CB)"]
ENGINE --> SCHED["Scheduler<br/>(sendAt / QH)"]
SCHED --> DELIVER
end
REDIS -->|"consume"| ENRICH
ENRICH -.->|"read / write state"| PG
DELIVER -.->|"delivery logs"| PG
DELIVER -->|"Dispatch"| PROVIDERS
PROVIDERS["Provider Transports<br/>Email: Resend · Push: Firebase (FCM) · SMS: Twilio<br/>Chat: Slack, Telegram, Discord, WhatsApp<br/>Webhooks: Custom HTTP · Local: Console"]
classDef entry stroke:#6366f1,stroke-width:2px
classDef store stroke:#0ea5e9,stroke-width:2px
classDef work stroke:#22c55e,stroke-width:2px
class App,API,PROVIDERS entry
class PG,REDIS store
class ENRICH,ENGINE,DELIVER,SCHED work
NotifkitServer runs the HTTP REST API router (/v1/notify, /health, /metrics), the built-in admin dashboard (/admin), and the background worker pipelines: enricher, decision engine, scheduler, and delivery. NotifkitClient is the lightweight client your application uses to trigger notifications, sync templates, and manage users over HTTP.
Topologies
In a single process, the API and all workers run in the same Node.js process (services: ["all"]), which works for small and medium apps, side projects, and staging. Distributed, you run stateless API servers (services: ["api"]) behind a load balancer and scale worker pools (services: ["enricher", "engine", "delivery", "scheduler"]) horizontally across Redis Streams consumer groups.
Quickstart (needs 5 mins)
1. Install
npm install notifkit @notifkit/provider-resend
npm install -D tsx @testcontainers/postgresql @testcontainers/redis
The @testcontainers/* packages start throwaway PostgreSQL and Redis containers for local development when DATABASE_URL and REDIS_URL are not set.
Warning
In production, point notifkit at real PostgreSQL and Redis instances via
DATABASE_URLandREDIS_URL, and run withNODE_ENV=production.
2. Run the engine
server.ts starts the API and the worker pipelines. In development it auto-starts those containers, so Docker is the only prerequisite.
import { NotifkitServer } from "notifkit";
import { ResendTransport } from "@notifkit/provider-resend";
const server = new NotifkitServer({
services: ["all"],
port: 3000,
databaseUrl: process.env.DATABASE_URL,
redisUrl: process.env.REDIS_URL,
adminUser: {
email: "admin@example.com",
password: "supersecretpassword123",
},
providers: [
new ResendTransport({
apiKey: process.env.RESEND_API_KEY!,
from: "notifications@yourdomain.com",
}),
],
});
await server.start();
console.log("notifkit listening on http://localhost:3000");
from is required on ResendTransport — it is the sender for any template that does not name its own, and it has to be an address on a domain you have verified in Resend. A template can override it with its own from, so one transport can serve both no-reply@ receipts and marketing@ campaigns.
adminUser (or ADMIN_EMAIL and ADMIN_PASSWORD in the environment) seeds your initial dashboard login credentials in PostgreSQL on startup.
ADMIN_API_KEY is the root credential that mints project API keys. In production, use a long random string (openssl rand -hex 32). Locally, any string works:
ADMIN_API_KEY=supersecretkey RESEND_API_KEY=re_xxx npx tsx server.ts
3. Create a project and its API key
Every /v1/* route requires a project API key, and only the admin credential can mint one. You can create projects via the CLI helper or in the admin dashboard under Projects & API Keys (/admin/projects):
ADMIN_API_KEY=supersecretkey npx notifkit-create-project "my-app"
Project "my-app" created. Save the API key now — it is not recoverable.
NOTIFKIT_PROJECT_ID=1ce67fa1-b4a9-4985-8046-ef6018912b2a
NOTIFKIT_API_KEY=nk_live_f57c57b76d795cef89e2dbf6b6f352a36…
The server stores only a SHA-256 hash of the key, so the nk_live_… value is printed once and never again — put it in your app's .env now. Point the script at another host with NOTIFKIT_URL, and mint further keys later with POST /v1/projects/:id/keys (role: "read_only" there gets you a key that can read but not send) or directly in the web dashboard.
4. Dispatch your first notification
client.ts is your application code. It talks to the server over HTTP: register a template, register a user, and send.
// client.ts
import { NotifkitClient } from "notifkit";
const notifkit = new NotifkitClient({
baseUrl: "http://localhost:3000",
apiKey: process.env.NOTIFKIT_API_KEY!,
});
// 1. Register a template
await notifkit.syncTemplates({
templates: [
{
id: "order-shipped",
channel: "email",
content: { subject: "Order #{{orderId}} Shipped", text: "Your order is on the way!" },
},
],
});
// 2. Register a user (supports id + contacts array or object)
await notifkit.addUser("usr_123", [{ channel: "email", target: "alex@acme.com" }]);
// 3. Dispatch
await notifkit.notify({
user: "usr_123",
template: "order-shipped",
channels: ["email"],
data: { orderId: "9481" },
});
With the server still running in the first terminal, run the client in a second one:
NOTIFKIT_API_KEY=nk_live_xxx npx tsx client.ts
5. Or call the REST API directly
The Node.js SDK is optional. notifkit exposes a standard HTTP REST API, so you can dispatch notifications and manage resources from any language (cURL, Python, Go, and so on). The same project API key goes in the Authorization header (an x-api-key header works too):
curl -X POST http://localhost:3000/v1/notify \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $NOTIFKIT_API_KEY" \
-d '{
"user": "usr_123",
"template": "order-shipped",
"channels": ["email"],
"data": { "orderId": "9481" }
}'
Development vs. production
Locally, Docker is the only prerequisite: in development notifkit starts throwaway PostgreSQL and Redis containers for you. In production you need Node 22+, PostgreSQL, and Redis, and you run migrations with the bundled CLI before starting the server:
DATABASE_URL=postgres://user:pass@host:5432/db npx notifkit-migrate
notifkit-migrate reads DATABASE_URL and DB_SCHEMA (from the environment or .env), applies any pending migrations into that schema, and exits non-zero if anything fails, so it can gate a deploy. It is safe to run on every deploy. Do not point drizzle-kit at node_modules/notifkit/drizzle directly: that ignores DB_SCHEMA and always creates the tables in public. In the default public schema both use the same journal (drizzle.__drizzle_migrations), so an install migrated with drizzle-kit so far can switch to notifkit-migrate as is.
Installing into a custom PostgreSQL schema
By default notifkit creates its tables and enum types in public. To share a database with an application that already owns public, set DB_SCHEMA (or dbOptions.schema on NotifkitServer):
DB_SCHEMA=notifications
Migrations (notifkit-migrate, or autoMigrate on start-up) then create the schema if it is missing and put every notifkit object into it, and every connection notifkit opens resolves queries against that schema only. After migrating, notifkit checks that every one of its tables exists in that schema and fails with the names of any that do not. The migration journal is kept in the same schema (notifications.__drizzle_migrations), so two installs in one database never share it. Pass runMigrations(db, { schema, migrationsSchema, migrationsTable }) to run migrations yourself.
The schema is set as the search_path startup parameter. If a connection pooler in front of PostgreSQL rejects that parameter (PgBouncer does unless search_path is listed in track_extra_parameters or ignore_startup_parameters), set it on the role instead: ALTER ROLE <role> SET search_path = notifications.
Warning
Do not set
DB_SCHEMAon an install that already runs inpublic. Nothing is moved for you: notifkit creates a fresh, empty set of tables in the new schema and stops looking atpublic. The application then looks empty: admin logins and every project API key stop working, and users, templates and history are gone from its point of view (the data is still inpublic, just no longer read).To move an existing install, stop notifkit and move every notifkit table and enum type, plus the migration journal, into the new schema yourself before running
notifkit-migratewith the newDB_SCHEMA, for example:BEGIN; CREATE SCHEMA notifications; ALTER TABLE public.projects SET SCHEMA notifications; -- repeat for every table in notifkitTableNames() ALTER TYPE public.channel SET SCHEMA notifications; -- and for api_key_role, workflow_status ALTER TABLE drizzle.__drizzle_migrations SET SCHEMA notifications; COMMIT;Then
notifkit-migratefinds the journal, applies nothing it has already applied, and confirms that every table is in place.
Running in production
notifkit runs in production at my own company, delivering 100K+ notifications a day across email, push, and OTPs. I built it because I needed it and didn't want to spend months rebuilding distributed notification plumbing or pay a SaaS per alert. It runs on your servers, with your provider accounts and your data.
Reliability and resilience
Every component is tested against failure using real testcontainers:
- Crash recovery: Worker processes killed with
SIGKILLmid-stream lose zero messages. Redis Streams consumer groups (PEL) auto-reclaim and replay in-flight work. - Connection resilience: Disconnections from PostgreSQL or Redis trigger automatic backpressure and reconnects without dropping state.
- High throughput & concurrency: 10,000+ notification bursts with sliding-window rate limiters, flat memory profiles, and 24-hour idempotency deduplication.
Scope
notifkit is the durable notification layer that runs inside your own stack. It is not a marketing automation suite, and it does not replace Customer.io, OneSignal, or SendGrid. You bring your own provider accounts and pay them directly.
First-party providers cover Resend, Firebase Cloud Messaging, Slack, Twilio, Telegram, Discord, and WhatsApp. Anything else is a Transport class with a send() method.
Agent-operable
https://github.com/user-attachments/assets/4dff98bb-37d3-44b4-bf46-9607c1cd89b5
An AI agent can operate notifkit directly. Connect the notifkit MCP server (@notifkit/mcp) to Claude Code, Cursor, Claude Desktop, Gemini, or any MCP-compatible agent:
npx -y @notifkit/mcp
Ask your agent
You: Why didn't usr_9182 receive their password reset?
Agent: The notification was suppressed because usr_9182's email
address has a hard-bounce suppression from yesterday.
Your application and your AI agents use the same notification infrastructure. Through MCP an agent can:
- Send one-off notifications or campaigns to users, lists, and segments (
send_notification,send_campaign) - Diagnose delivery issues by inspecting message histories, provider responses, and quiet hours (
get_delivery_logs,get_notification) - Schedule future sends and cancel pending notifications (
list_scheduled,cancel_notification) - Check delivery, open, click, bounce, and complaint metrics (
list_campaigns,get_campaign_stats) - List, preview, and update templates with sample data (
list_templates,preview_template,upsert_template) - Look up users, contacts, preferences, and segment membership (
list_users,get_user_preferences,update_user_preferences) - Trigger workflows and inspect workflow runs (
create_workflow,trigger_workflow,get_workflow_run) - Manage bounce suppressions, check system queues, and replay dead-letter messages (
list_suppressions,get_dead_letters,replay_dead_letter)
The same task, with and without an agent
| Without an agent | With the notifkit MCP server |
|---|---|
| Query the database for contact info, open Twilio or Resend or write a throwaway script, format the payload, check the user's timezone by hand, send it, and hope it delivered. | You: "Send an urgent update to alex@acme.com that his package was lost in transit and support is rushing a replacement. Text him if push doesn't deliver." Agent: Looks up alex@acme.com, renders the template, dispatches push with SMS fallback, bypasses quiet hours because the send is urgent, tracks delivery status, and confirms it reached his phone. |
AI-assisted migration
Already have notification code scattered across your application? Point your coding agent at:
https://notifkit.dev/llms-full.txt
It can read notifkit's API from there, find ad-hoc notification code in your repository, and refactor it into notifkit calls.
Feature matrix
| Feature | Capabilities |
|---|---|
| Channels | email, sms, push, webhook, telegram, discord, whatsapp, slack, console |
| Admin Dashboard | Built-in SPA at /admin for live feeds, DLQ triage, queue metrics, workflows, templates |
| Targeting | A user, a list of users, a segment, or a topic |
| Priorities | low, normal, high, critical, on separate stream lanes |
| Scheduling | Future sends with sendAt, quiet-hours deferral, cancellation |
| Preferences | Per-user channel and topic opt-outs, quiet hours, contact-level overrides |
| Workflows | Multi-step sequences with wait, waitForEvent, and notify steps |
| Reliability | Redis Streams, 24h idempotency, retries, DLQ, provider circuit breakers |
| Templates | {{var}} interpolation with destination-aware escaping |
| AI | Optional LLM augmentation before render via the Vercel AI SDK |
| Multi-tenancy | Projects with isolated keys, data, and rate limits |
| Consent | RFC 8058 one-click unsubscribe; complaints and hard bounces suppress automatically |
| Reporting | Campaign labels with delivery and engagement totals |
| Agent operation | MCP server for sending, triage, campaigns, templates, workflows, and system operations |
| Observability | Prometheus /metrics, /health, /live, /ready, and queryable delivery logs |
Providers
Bring your own provider accounts. First-party packages:
@notifkit/provider-resend: transactional email via Resend@notifkit/provider-fcm: push notifications via Firebase Cloud Messaging@notifkit/provider-slack: Slack messages via Incoming Webhooks or the Web API@notifkit/provider-twilio: SMS via Twilio, with signature-verified delivery status callbacks@notifkit/provider-telegram: messages via a Telegram bot@notifkit/provider-discord: messages via a Discord webhook@notifkit/provider-whatsapp: messages via Meta's WhatsApp Cloud API@notifkit/provider-console: console transport for local development and testing
For anything else, implement a Transport:
class MyTransport implements Transport {
async send(message) {
// Send through SES, Postmark, APNs,
// SendGrid, a custom webhook, or anything else.
}
}
The keys, the billing, and the deliverability stay yours.
Admin dashboard
notifkit includes a web console served directly at /admin for operational management:
- Live activity feed & logs: Real-time SSE stream for worker transitions, delivery histories, and error diagnostics.
- Queue analytics & DLQ triage: Redis Streams lane gauges (
low,normal,high,critical), message replay, and dead-letter queue management. - Workflows & template studio: Visual multi-step sequence viewer (
wait,waitForEvent,notify) with execution step tracing and live template previews. - Users, projects & API keys: Manage recipient preferences, quiet hours, projects, and scoped API keys (
adminorread_only).
Accessing and running the dashboard
Once the server is running, visit http://localhost:3000/admin and sign in with the admin credentials you configured.
You can also create or update admin users at any time via the CLI:
npx notifkit-create-admin admin@example.com supersecretpassword123
Like notifkit-migrate, it reads DATABASE_URL and DB_SCHEMA.
In production, the pre-built dashboard bundle in dashboard/dist is served automatically. In development, run npm --prefix dashboard run dev to start the Vite development server with hot module replacement (the API server automatically proxies /admin requests to port 5173).
Documentation
Everything lives at notifkit.dev/docs.
| Guide | Description |
|---|---|
| Quickstart | Install to first delivered notification |
| How it works | Core concepts and the notification pipeline |
| Channels & fallback | Multicast, ordered fallback, and custom transports |
| Preferences & quiet hours | Preference, consent, and timing rules |
| Templates & AI | Interpolation, escaping, and per-channel content |
| Segments & scheduling | Fan-out, priority lanes, sendAt, and idempotency |
| Workflows | Multi-step sequences, recurring sends, and digests |
| Examples | Runnable projects |
| Architecture | Streams, delivery guarantees, topologies, and data model |
| Deployment | Docker, Compose, and production topologies |
| Operations | Health, metrics, DLQ, key rotation, and shutdown |
| Reference | API, payloads, and SDK methods |
| MCP server | Operate notifkit from an AI agent |
Why build this?
Notification infrastructure looks simple until you're responsible for it. Queues, retries, provider adapters, preference systems, quiet-hour logic, workflows, suppression handling, and operational tooling take months to build well. notifkit is what I built instead, and it's what I run.
Contributing
Issues and pull requests are welcome. Stars help other people find the project.
npm install
npm run build
npm run build:dashboard
npm test
The test suite starts its own PostgreSQL and Redis containers, so Docker is the only thing you need running.
Contact
Questions, bugs, or ideas: contact.devkitshq@gmail.com, or open an issue.
License
MIT. Do what you like with it, including commercially. See LICENSE.