Quickstart
Seven steps, nothing assumed. You end with a running notifkit server, a project, a template, a user, and a notification landing in your terminal. About ten minutes, or one if you scaffold it.
The one-command version
create-notifkit writes the walkthrough below into a new directory: a server, a
script that creates the project, and a script that sends. It also puts a random
ADMIN_API_KEY in .env. Needs Node 22+ and Docker running.
npm create notifkit@latest my-app # or: npx notifkit create my-app
cd my-app
npm run dev # starts the server; leave it running
In a second terminal:
npm run setup # creates a project and writes NOTIFKIT_API_KEY to .env
npm run send # syncs a template, adds a user, sends to them
The notification prints in the server terminal, since the default provider is the
console transport. For a real one, pass
--provider and fill in the credentials it lists in .env:
| Flag | Channel | Fill in |
|---|---|---|
--provider console | push | Nothing. This is the default |
--provider resend | email | RESEND_API_KEY, RESEND_FROM, TO_EMAIL |
--provider twilio | sms | TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM, TO_PHONE |
--provider fcm | push | FCM_SERVICE_ACCOUNT_JSON, TO_PUSH_TOKEN |
Other flags: --no-install skips installing dependencies; --force
writes into a non-empty directory. Pass . as the name to use the current
directory. pnpm, yarn and bun work too: pnpm create notifkit my-app.
The generated server.ts, setup.ts and send.ts are
the steps below, compressed. Read on to see what each does, or to add notifkit to an
existing app.
Two processes, not one
Most early mix-ups come from missing this: notifkit is a server you run, and your application talks to it over HTTP. They are separate processes, can live on separate machines, and your application needn't be 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.
The path you are about to walk
Left to right: the first two boxes you do once; the rest is your application, and only
notify runs on every event:
/v1/* endpoint is project-scoped and needs an API key. No project means no
key, and every call answers 401. That's why the one-off step exists, and it's
the one 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 a 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.tsAll three files share a directory to keep the walkthrough short, but they are two separate programs:
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 / notifyRun them as two processes:
npx tsx server.tsin one terminal,npx tsx app.tsin another. In production they're two deployments, often two repositories. Ifapp.tsisn't JavaScript,notifkit.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 one process: right for development, and one config change from 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. Look 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 upOnly the probe routes (
/health,/live,/ready,/metrics) work without a key. Everything under/v1/needs one, which 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 bootstrap, so it's the one endpoint that takes the admin key instead of a project key. The response holds the only copy of the new API key: it's stored as a SHA-256 hash and can't be read back.
ADMIN_API_KEY=dev-admin-key npx notifkit-create-project "demo"Project "demo" created. Save the API key now — it is not recoverable. NOTIFKIT_PROJECT_ID=6f1c9c1e-6d5e-4d0c-9a2b-3f77e5b8a1d4 NOTIFKIT_API_KEY=nk_live_9f3c…Keep it in your shell for the rest of the walkthrough:
export NK_KEY="nk_live_9f3c…"ShortcutOr skip the
curl: with the MCP server configured, ask your agent to set up the project and hand you the keys. The dashboard can create them too.GotchaLost the key? It can't be recovered; mint a new one with
POST /v1/projects/{id}/keys. Keys areadminorread_only. A read-only key gets403on any non-GETrequest, so use it for dashboards. -
Point your application at the server your app From here on it's your code calling the server. Set it up once.
The remaining steps have two tabs. HTTP is a plain request from any language, and what the SDK sends under the hood. TypeScript uses
NotifkitClient, a typed wrapper over the same endpoints.On the HTTP tab, the
$NK_KEYyou exported in the previous step is all you need. Skip ahead.On the TypeScript tab, this is the
notifkitobject every later snippet calls. It belongs in your application, in 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 });GotchaDon't send notifications with the
serverobject from step 2.NotifkitServeris the infrastructure;NotifkitClientis how you talk to it. Different classes, different jobs, and in production usually different deployments. -
Sync a template your app Content lives in notifkit, not your service code; sends reference it by id.
A template is an id, a channel and a
contentobject. notifkit doesn't care which keyscontenthas: it substitutes{{placeholders}}in every string. The transport decides which keys it reads; Resend, FCM and console 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 }PUTupserts the whole list, so it's safe on every deploy. That's how templates are meant to live in version control with your app.NotePer-topic opt-outs key on the
topicarray. A user withtopics.transactional = falsenever receives this template on any channel. Templates with no topic can't be opted out of, which is what you want for password resets. -
Register a user your app A user is an id, addresses, and rules for when to contact them. Wire it into your signup handler.
Use your own database's user id. Addresses passed as
email,phoneorpushTokenbecome contacts, the endpoints notifkit delivers to. Each takes a 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", "contacts": [{ "channel": "email", "target": "[email protected]" }], "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("usr_123", [ { channel: "email", target: "[email protected]" } ], { 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" }It's an upsert: calling it again with the same id updates the record. Calling it from your signup and profile-update handlers is usually all the integration users need.
NotequietHoursareHH:MMwindows in the user's owntimezone, and may wrap midnight. With no timezone set, UTC is used. -
Send it your app The call you'll make over and over, 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 Acceptedimmediately. It's an enqueue, not a delivery receipt:{ "messageId": "1737054981234-0", "notificationId": "0f8c2b7e-1a4d-4c93-9f2e-7d1b6a5c8e30", "target": { "type": "user", "userId": "usr_123" } }Within a second or so, the server terminal shows:
INFO (EnricherWorker): event enriched {"target":"user"} INFO (EngineWorker): task dispatched {"taskId":"…","recipientId":"usr_123"} ┌───────────────── 📲 PUSH NOTIFICATION ───────────────── │ to token : [email protected] │ 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's the whole loop: placeholders filled, preferences checked, rendered payload handed to a transport.
NoteThe console transport prints a push-shaped banner whatever the channel. It's a debugging aid, not a faithful renderer; the
contentline is what matters.
Confirm it, from the outside
Terminal output suits a demo; delivery history is what you'll 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
}
Filter by templateId, channel, status or
workflowInstanceId; page with cursor.
Reacting to deliveries instead of polling server
Two ways to hear about deliveries as they happen, depending on where your code sits. From
outside the server, in any language, subscribe to the SSE stream. From
inside server.ts, NotifkitServer is an
EventEmitter, so attach listeners to the server object from step 2.
The in-process route gets one extra event the stream doesn't 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. In a real service they live in three places, and only the last 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": "[email protected]",
"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: "[email protected]",
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 takes an inline user object instead of an id, as in user: { id: "usr_123", email: "[email protected]" }, which upserts the
user and their contacts as part of the send. Handy for one-off transactional mail with no
separate registration step.
When nothing arrives
The pipeline drops instead of throwing, so a silent send is the normal failure mode. The
server always logs the reason at info level: 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. |