← website
Start here · 02

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 serverYour 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:

notifkit server one-off your application run server services: ["all"] runs all 8 services + migrates the db project POST /v1/projects returns nk_live_… shown once template PUT /v1/templates per deploy {{placeholders}} user POST /v1/users at signup id + preferences notify POST /v1/notify per event returns instantly verify /notifications/logs delivery history per message Only the first box has to be Node. Everything in "your application" is a plain HTTP call.
Why the one-off step exists. Every /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

RequirementWhy
Node.js ≥ 22The package targets Node 22 and uses native fetch and ESM.
npm ≥ 10Workspace resolution for the provider packages.
Docker DesktopOptional. 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.
  1. 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.ts

    Keeping 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 / notify

    You will run these as two processes: npx tsx server.ts in one terminal, and npx tsx app.ts in another. In production they are two deployments — often two repositories, and app.ts is frequently not JavaScript at all, in which case notifkit.ts disappears and you make the HTTP calls directly.

  2. Start the server server One process runs all eight services. It also creates its own database schema.

    NotifkitServer is 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.ts

    Startup 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 up

    The probe routes — /health, /live, /ready and /metrics — are the only ones that work without a key. Everything under /v1/ needs one, which is exactly what the next step gets you.

  3. 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…"
    shortcut

    Or 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.

    gotcha

    Lost the key? You cannot recover it — mint a new one with POST /v1/projects/{id}/keys. Keys can be admin or read_only; a read-only key gets 403 on any non-GET request, which makes it the right choice for dashboards.

  4. 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_KEY you exported in the previous step. Skip ahead.

    If you are on the TypeScript tab, this is the notifkit object that every later snippet calls. It belongs in your application — a different file, and usually a different process, from server.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
    });
    gotcha

    Do not reach for the server object from step 2 to send notifications. NotifkitServer is the infrastructure; NotifkitClient is how you talk to it. They are different classes with different jobs, and in production they are usually different deployments.

  5. 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 content object. NotifKit does not care what keys you put in content — 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 for subject, text (or body), and html (or htmlBody).

    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 }

    PUT is 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.

    note

    The topic array is what per-topic opt-outs key on. A user who has set topics.transactional = false will never receive this template, on any channel. Templates with no topic cannot be opted out of — which is what you want for password resets.

  6. 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, or pushToken become 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.

    note

    quietHours are HH:MM windows evaluated in the user's own timezone, and windows may wrap midnight. A window with no timezone set falls back to UTC.

  7. 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 Accepted back 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.

    note

    The console transport prints a push-shaped banner regardless of channel — it is a debugging aid, not a channel-accurate renderer. The content line 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:

CallRunsWhere 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" },
});
shortcut

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 seeCauseFix
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.

Next