Quickstart
Seven steps, in order, with nothing assumed. At the end you will have a running NotifKit server, a project, a template, a user, and a notification you can watch land in your terminal. Budget about ten minutes.
Two processes, not one
Almost every early mix-up comes from missing this: NotifKit is a server you run, and your application talks to it over HTTP. They are separate processes. They can live on separate machines, and your application does not have to be written in JavaScript.
| The NotifKit server | Your application | |
|---|---|---|
| What it is | NotifkitServer — the API plus eight workers |
Your existing backend, in any language |
| File in this guide | server.ts |
app.ts, or a curl in your terminal |
| Must be Node? | Yes — it is a Node service | No. It is an HTTP client |
| How often it runs | Started once, stays up | Every signup, every send |
| Talks to | Postgres, Redis, your providers | The NotifKit server, on port 3000 |
Each step below is tagged with where it runs: server is the NotifKit process, your app is your code, and one-off is a bootstrap task you do once by hand and never automate.
The path you are about to walk
Read this left to right. The first two boxes you do once and never think about again; the
rest is your application, and only notify runs on every event:
/v1/* endpoint is project-scoped and
requires an API key. Without a project you have no key, and every call answers
401. This is the step most people miss.
Before you start
| Requirement | Why |
|---|---|
| Node.js ≥ 22 | The package targets Node 22 and uses native fetch and ESM. |
| npm ≥ 10 | Workspace resolution for the provider packages. |
| Docker Desktop | Optional. If you do not give NotifKit a Postgres and Redis URL, it starts throwaway containers for you — which needs Docker running and the two @testcontainers/* packages installed in step 1. If you already run both locally, you need neither. |
-
Install and lay out the files setup One package for the engine, one for the transport that prints to your terminal.
mkdir notifkit-demo && cd notifkit-demo npm init -y npm pkg set type=module npm install notifkit @notifkit/provider-console # tsx runs the TypeScript directly. The testcontainers packages are what let # the server start its own Postgres and Redis — NotifKit keeps them external, # so they do not arrive with it. Skip them if you already run both and will # pass their URLs instead. npm install -D tsx @testcontainers/postgresql @testcontainers/redis # The three files this guide builds: touch server.ts notifkit.ts app.tsKeeping all three in one directory makes the walkthrough short to run. They are still two separate programs, and the split is worth seeing before you write any of them:
notifkit-demo/ ├── server.ts ← the NotifKit server · step 2 · runs on its own, stays up │ NotifkitServer + providers │ ├── notifkit.ts ← the client · step 4 · your app's handle on the server │ NotifkitClient({ baseUrl, apiKey }) │ └── app.ts ← your application · steps 5–7 · imports notifkit.ts syncTemplates / addUser / notifyYou will run these as two processes:
npx tsx server.tsin one terminal, andnpx tsx app.tsin another. In production they are two deployments — often two repositories, andapp.tsis frequently not JavaScript at all, in which casenotifkit.tsdisappears and you make the HTTP calls directly. -
Start the server server One process runs all eight services. It also creates its own database schema.
NotifkitServeris the whole engine.services: ["all"]runs the API and every worker in a single process — right for development, and a config change away from splitting into separate deployments later.import { NotifkitServer } from "notifkit"; import { ConsoleTransport } from "@notifkit/provider-console"; // Enables POST /v1/projects. Without it, project creation returns 403. process.env.ADMIN_API_KEY = "dev-admin-key"; const server = new NotifkitServer({ services: ["all"], nodeEnv: "development", port: 3000, // Omit redisUrl and databaseUrl and NotifKit starts throwaway Postgres + // Redis containers for you. That path is gated on nodeEnv: any value but // "production" gets containers, and "production" requires both URLs and // fails startup without them. // ConsoleTransport defaults to the "push" channel — we want email. providers: [new ConsoleTransport({ channel: "email" })], }); await server.start(); console.log("notifkit listening on http://localhost:3000");npx tsx server.tsStartup is chatty on purpose. You are looking for these lines:
INFO (server): No databaseUrl provided, spinning up PostgreSQL container... INFO (server): Running database migrations... INFO (server): Database migrations complete INFO (server): Registered 1 custom providers INFO (delivery): delivery starting {"channels":["email"]} INFO (api): api server listening {"port":3000,"host":"127.0.0.1"}Confirm from a second terminal:
curl -s http://localhost:3000/health | jq{ "service": "api", "status": "ok", "redis": true, "database": true, "workers": { "enricher": { "state": "running", "processedCount": 0 }, "engine": { "state": "running", "processedCount": 0 }, "delivery": { "state": "running", "processedCount": 0 } } }heads upThe probe routes —
/health,/live,/readyand/metrics— are the only ones that work without a key. Everything under/v1/needs one, which is exactly what the next step gets you. -
Create a project and grab the API key one-off A project is the tenant boundary. Every user, template, and log row belongs to one.
Project creation is the one endpoint authenticated with the admin key rather than a project key — it is the bootstrap. The response contains the only copy of the new API key you will ever see; it is stored as a SHA-256 hash and cannot be read back.
curl -s -X POST http://localhost:3000/v1/projects \ -H "Authorization: Bearer dev-admin-key" \ -H "Content-Type: application/json" \ -d '{"name":"demo"}'{ "id": "6f1c9c1e-6d5e-4d0c-9a2b-3f77e5b8a1d4", "apiKey": "nk_live_9f3c…" }Keep it in your shell for the rest of this walkthrough:
export NK_KEY="nk_live_9f3c…"shortcutOr skip the
curl: with the MCP server configured, tell your agent to set the project up and hand you the API keys. The dashboard can create them too.gotchaLost the key? You cannot recover it — mint a new one with
POST /v1/projects/{id}/keys. Keys can beadminorread_only; a read-only key gets403on any non-GETrequest, which makes it the right choice for dashboards. -
Point your application at the server your app Everything from here on is your code calling the server. Set it up once.
The remaining steps each show two tabs. HTTP is a plain request — usable from any language, and what the SDK sends under the hood. TypeScript uses
NotifkitClient, a typed wrapper over those same endpoints.If you are on the HTTP tab, you already have everything you need: the
$NK_KEYyou exported in the previous step. Skip ahead.If you are on the TypeScript tab, this is the
notifkitobject that every later snippet calls. It belongs in your application — a different file, and usually a different process, fromserver.ts:import { NotifkitClient } from "notifkit"; // Note: NotifkitClient, not NotifkitServer. This is a client for the // API you started in step 2 — it holds no database or Redis connection. export const notifkit = new NotifkitClient({ baseUrl: "http://localhost:3000", apiKey: process.env.NOTIFKIT_API_KEY, // the nk_live_… key from step 3 });gotchaDo not reach for the
serverobject from step 2 to send notifications.NotifkitServeris the infrastructure;NotifkitClientis how you talk to it. They are different classes with different jobs, and in production they are usually different deployments. -
Sync a template your app Content lives in NotifKit, not in your service code. Sending references it by id.
A template is an id, a channel, and a
contentobject. NotifKit does not care what keys you put incontent— it walks the whole object and substitutes{{placeholders}}in every string it finds. The transport decides which keys it reads. The Resend, FCM, and console transports all look forsubject,text(orbody), andhtml(orhtmlBody).curl -s -X PUT http://localhost:3000/v1/templates \ -H "Authorization: Bearer $NK_KEY" \ -H "Content-Type: application/json" \ -d '{ "templates": [{ "id": "welcome", "channel": "email", "topic": ["transactional"], "content": { "subject": "Welcome aboard, {{name}}", "text": "Hi {{name}}, thanks for joining {{company}}.", "html": "<h1>Hi {{name}}</h1><p>Thanks for joining {{company}}.</p>" } }] }'await notifkit.syncTemplates({ templates: [{ id: "welcome", channel: "email", topic: ["transactional"], content: { subject: "Welcome aboard, {{name}}", text: "Hi {{name}}, thanks for joining {{company}}.", html: "<h1>Hi {{name}}</h1><p>Thanks for joining {{company}}.</p>", }, }], });{ "synced": 1 }PUTis an upsert over the whole list, so this is safe to run on every deploy — that is the intended way to keep templates in version control alongside your app.noteThe
topicarray is what per-topic opt-outs key on. A user who has settopics.transactional = falsewill never receive this template, on any channel. Templates with no topic cannot be opted out of — which is what you want for password resets. -
Register a user your app A user is an id plus addresses plus rules about when they may be contacted. Wire this into your signup handler.
Use your own identifier — whatever your database calls the user. Addresses passed as
email,phone, orpushTokenbecome contacts: the concrete endpoints NotifKit delivers to. Each accepts a single string or an array.curl -s -X POST http://localhost:3000/v1/users \ -H "Authorization: Bearer $NK_KEY" \ -H "Content-Type: application/json" \ -d '{ "id": "usr_123", "email": "alice@example.com", "timezone": "America/New_York", "preferences": { "channels": { "email": true, "sms": false }, "topics": { "transactional": true, "marketing": false }, "quietHours": [{ "start": "22:00", "end": "08:00" }] } }'await notifkit.addUser({ id: "usr_123", email: "alice@example.com", timezone: "America/New_York", preferences: { channels: { email: true, sms: false }, topics: { transactional: true, marketing: false }, quietHours: [{ start: "22:00", end: "08:00" }], }, });{ "id": "usr_123" }This is an upsert, so calling it again with the same id updates the record. Wiring it into your signup handler and your profile-update handler is usually all the integration a user record needs.
notequietHoursareHH:MMwindows evaluated in the user's owntimezone, and windows may wrap midnight. A window with no timezone set falls back to UTC. -
Send it your app The one call you will make over and over, from wherever the event happens.
curl -s -X POST http://localhost:3000/v1/notify \ -H "Authorization: Bearer $NK_KEY" \ -H "Content-Type: application/json" \ -d '{ "user": "usr_123", "template": "welcome", "channels": ["email"], "data": { "name": "Alice", "company": "Acme" } }'await notifkit.notify({ user: "usr_123", template: "welcome", channels: ["email"], data: { name: "Alice", company: "Acme" }, });You get
202 Acceptedback immediately — this is an enqueue, not a delivery receipt:{ "messageId": "1737054981234-0", "notificationId": "0f8c2b7e-1a4d-4c93-9f2e-7d1b6a5c8e30", "target": { "type": "user", "userId": "usr_123" } }Now look at the terminal running your server. Within a second or so:
INFO (EnricherWorker): event enriched {"target":"user"} INFO (EngineWorker): task dispatched {"taskId":"…","recipientId":"usr_123"} ┌───────────────── 📲 PUSH NOTIFICATION ───────────────── │ to token : alice@example.com │ recipient: usr_123 │ priority : normal │ content : {"subject":"Welcome aboard, Alice","text":"Hi Alice, thanks │ for joining Acme.","html":"<h1>Hi Alice</h1>…"} │ taskId : 0f8c2b7e-… └─────────────────────────────────────────────────────────── INFO (DeliveryWorker): notification delivered {"channel":"email"}That is the whole loop. The placeholders are filled, the user's preferences were checked, and the rendered payload reached a transport.
noteThe console transport prints a push-shaped banner regardless of channel — it is a debugging aid, not a channel-accurate renderer. The
contentline is the part that matters.
Confirm it, from the outside
Terminal output is fine for a demo; delivery history is what you will actually use. Every attempt is written to Postgres and readable per project:
curl -s "http://localhost:3000/v1/notifications/logs?limit=5" \
-H "Authorization: Bearer $NK_KEY" | jq
const { logs, nextCursor } = await notifkit.getNotificationLogs({ limit: 5 });
{
"logs": [
{
"taskId": "0f8c2b7e-…",
"templateId": "welcome",
"channel": "email",
"status": "delivered",
"attempt": 1,
"providerMessageId": "console-1737054981567",
"timestamp": "2026-01-16T18:36:21.567Z"
}
],
"nextCursor": null
}
You can filter by templateId, channel, status, or
workflowInstanceId, and page with cursor.
Reacting to deliveries instead of polling server
There are two ways to hear about a delivery as it happens, and which one you get depends on
where your code sits. From outside the server — any language — subscribe to
the SSE stream. From inside server.ts,
NotifkitServer is an EventEmitter, so you can attach listeners
directly to the server object from step 2. The in-process route sees one extra
event the stream does not carry:
# From outside the server process, subscribe to the SSE stream.
# It carries delivery:delivered and delivery:failed — not notification:skipped.
curl -N "http://localhost:3000/v1/events/stream?token=$NK_KEY"
# event: delivery:delivered
# data: {"taskId":"0f8c…","providerMessageId":"console-173…","channel":"email"}
server.on("delivery:delivered", (taskId, providerMessageId, channel) => {
console.log(`${channel} delivered`, taskId, providerMessageId);
});
server.on("delivery:failed", (taskId, error, channel) => {
console.error(`${channel} failed`, taskId, error);
});
server.on("notification:skipped", ({ recipientId, reason }) => {
// reason: "user_opted_out" | "channel_disabled" | "template_not_found" | "no_active_contacts"
console.warn("skipped", recipientId, reason);
});
Where each call belongs in your app your app
The walkthrough ran the three application calls back to back so you could watch a message land. In a real service they live in three different places, and only the last one runs per event:
| Call | Runs | Where it goes |
|---|---|---|
syncTemplates · PUT /v1/templates |
Once per deploy | A migration or post-deploy hook, so templates stay in version control |
addUser · POST /v1/users |
Once per user, then on change | Your signup handler and your profile-update handler |
notify · POST /v1/notify |
Every time something happens | Wherever the event occurs — checkout, password reset, alerting |
API=http://localhost:3000
AUTH=(-H "Authorization: Bearer $NK_KEY" -H "Content-Type: application/json")
# ── Deploy-time: push templates from version control.
curl -s -X PUT $API/v1/templates "${AUTH[@]}" \
-d '{
"templates": [{
"id": "welcome",
"channel": "email",
"topic": ["transactional"],
"content": {
"subject": "Welcome aboard, {{name}}",
"text": "Hi {{name}}, thanks for joining {{company}}."
}
}]
}'
# ── Signup handler: upsert the user.
curl -s -X POST $API/v1/users "${AUTH[@]}" \
-d '{
"id": "usr_123",
"email": "alice@example.com",
"timezone": "America/New_York",
"preferences": { "channels": { "email": true } }
}'
# ── Anywhere something happens: send.
curl -s -X POST $API/v1/notify "${AUTH[@]}" \
-d '{
"user": "usr_123",
"template": "welcome",
"channels": ["email"],
"data": { "name": "Alice", "company": "Acme" }
}'
import { notifkit } from "./notifkit.js"; // the client from step 4
// ── Deploy-time: push templates from version control.
await notifkit.syncTemplates({
templates: [{
id: "welcome",
channel: "email",
topic: ["transactional"],
content: {
subject: "Welcome aboard, {{name}}",
text: "Hi {{name}}, thanks for joining {{company}}.",
},
}],
});
// ── Signup handler: upsert the user.
await notifkit.addUser({
id: "usr_123",
email: "alice@example.com",
timezone: "America/New_York",
preferences: { channels: { email: true } },
});
// ── Anywhere something happens: send.
const { messageId } = await notifkit.notify({
user: "usr_123",
template: "welcome",
channels: ["email"],
data: { name: "Alice", company: "Acme" },
});
notify() also accepts an inline user object instead of an id
— user: { id: "usr_123", email: "alice@example.com" } — which upserts the
user and their contacts as part of the send. Handy for one-off transactional mail where you
do not want a separate registration step.
When nothing arrives
Because the pipeline drops rather than throws, a silent send is the normal failure mode.
The server logs the reason at info level every time — start there.
| What you see | Cause | Fix |
|---|---|---|
401 unauthorized |
Missing or wrong API key | Send Authorization: Bearer nk_live_…. The admin key only works on /v1/projects unless you also send x-project-id. |
403 on project creation |
ADMIN_API_KEY is not set |
Set it before server.start(), or put it in .env. |
202, then "template not found" |
Template id mismatch, or synced under a different project | GET /v1/templates with the same key you send with. |
"no active contacts for channel" |
The user has no address on that channel | GET /v1/users/{id}/contacts. Add one with POST /v1/users/{id}/contacts. |
"user disabled notification channel" |
preferences.channels[channel] === false |
Intended behaviour. Flip the preference or pick another channel. |
"user is in quiet hours — deferring" |
Local time falls inside a quiet window | It will send when the window ends. Use priority: "critical" to punch through. |
"no transport registered for channel" |
No provider registered for that channel | Pass one in providers: []. ConsoleTransport defaults to push — set { channel: "email" }. |
"user throttled — dropping" |
Per-user hourly cap hit (100 by default) | Raise RATE_LIMIT_PER_HOUR, or send as critical, which bypasses it. |
| Server will not start | Docker is not running and no URLs were given | Start Docker, or pass redisUrl and databaseUrl explicitly. |
Cannot find package '@testcontainers/redis' |
No URLs were given and the container packages are not installed | Run npm install -D @testcontainers/postgresql @testcontainers/redis, or pass both URLs. |