← website
Reference

Reference

The complete surface: HTTP endpoints, SDK methods, payload shapes, server options, environment variables, events, and errors.

Authentication

Every route under /v1/ requires a key. Two header forms are accepted:

Authorization: Bearer nk_live_…
x-api-key: nk_live_…
Key typeScopeNotes
Project key nk_live_… One project Role admin or read_only. Read-only keys get 403 on anything that is not a GET.
ADMIN_API_KEY All projects Required for /v1/projects*. On any other route it must also carry x-project-id or ?projectId=.

The SSE endpoint also accepts ?token= in the query string, since EventSource cannot set headers.

Status codes

CodeMeaning
200Success.
201Created — users, contacts, workflows, project keys.
202Accepted and enqueued. Not a delivery confirmation.
204Deleted.
400Validation failed. The body carries issues from Zod.
401Missing or invalid key.
403Read-only key on a write, or project management with no admin key configured.
404Not found in this project.
429Project request limit exceeded. Retry-After: 60.
500Unhandled server error.

Endpoints

Notifications

Method & pathPurpose
POST /v1/notifyRequest a send. Honours x-idempotency-key.
GET /v1/notifications/logsDelivery history. Query: limit (max 100), cursor, templateId, workflowInstanceId, channel, status, taskId, search.
GET /v1/notifications/scheduledTasks parked for a future send, oldest task id first. Query: limit (default 50, max 100), cursor, channel. Returns { scheduled, nextCursor }; nextCursor is null on the last page.
GET /v1/notifications/{taskId}Latest status plus every attempt for one task.
DELETE /v1/notifications/{taskId}Cancel a scheduled task. 404 once dispatched.

Campaigns

A campaign is just a label: pass campaign to POST /v1/notify and every message the call produces is tagged with it, as is every engagement event that comes back later. Sends made without one are not attributable after the fact.

Method & pathPurpose
GET /v1/campaignsCampaign labels with message count and time range, newest activity first. Query: limit (max 100).
GET /v1/campaigns/{campaign}/statsDelivery and engagement funnel. 404 if no messages carry that label.

Stats count distinct tasks, not log rows — one task accrues a dispatch row, an attempt row, and any number of engagement rows, so counting rows would inflate each figure differently. Opening the same email twice counts once.

{
  "campaign": "spring-sale",
  "totals": {
    "sent": 200, "delivered": 194, "failed": 6,
    "opened": 71, "clicked": 12,
    "bounced": 4, "complained": 1, "unsubscribed": 3,
    "deliveryRate": 97.0, "openRate": 36.6, "clickRate": 6.19,
    "bounceRate": 2.0, "complaintRate": 0.52, "unsubscribeRate": 1.55
  },
  "byChannel": { "email": { "sent": 200, "delivered": 194, "opened": 71, "clicked": 12 } },
  "engagementTracked": true,
  "warnings": []
}

Rates are percentages, or null where the denominator is zero. openRate, clickRate, complaintRate, and unsubscribeRate are measured against delivered; deliveryRate and bounceRate against sent.

read the warnings

engagementTracked is false and warnings is populated when opens and clicks cannot be measured — an SMS or push campaign, or an email campaign whose provider webhook is not wired up. A zero openRate under those conditions means unknown, not nobody opened it. Surface the warning rather than the figure.

Unsubscribe

One-click unsubscribe, per RFC 8058. Gmail and Yahoo have required it on bulk mail since early 2024; without it bulk sends are throttled or junked, and because reputation attaches to the sending domain, that eventually drags your transactional mail down too.

These two routes are the only unauthenticated endpoints in the API. They are reached from a mail client, which has no API key — the signed token in the URL is the credential, and it authorises exactly one opt-out for one address.

Method & pathPurpose
GET /v1/unsubscribe?token=…Confirmation page for a human who clicked the link in the message body. Does not opt anyone out.
POST /v1/unsubscribe?token=…Performs the opt-out. Serves both the RFC 8058 one-click POST and the form on the page above.
why GET does nothing

Corporate mail scanners and link prescanners issue a GET against every URL in a message. If the opt-out happened on GET, those scanners would unsubscribe recipients who never clicked. The mutation lives behind the POST, which is also what the one-click spec requires.

Configuration

Headers are attached only when both of these are set. With either missing, mail still sends — it just carries no List-Unsubscribe. A missing UNSUBSCRIBE_SECRET is logged at warn each time it would have mattered: once per bulk email sent without the headers, and once per request to either unsubscribe route, where every token is rejected because there is no key to verify it against. Grep for it before assuming your links are live.

VariableNotes
PUBLIC_URLWhere an inbox can reach this API. Not HOST/PORT, which are the bind address behind your proxy.
UNSUBSCRIBE_SECRETSigns the tokens, minimum 16 characters. Effectively permanent — see below.
do not rotate

Tokens are signed, not stored, and they never expire — an unsubscribe link has to work from an inbox years later, with no session. Rotating UNSUBSCRIBE_SECRET invalidates every link already sent. A recipient whose unsubscribe link returns "not valid" reaches for the spam button instead, which costs far more reputation than the key ever protected.

What gets a header, and what an opt-out does

Headers are attached only to email whose template has at least one topic. A template with no topic is transactional by this codebase's convention — a password reset, a receipt — and those should not carry an unsubscribe button at all. That is also exactly the line mailbox providers draw: bulk mail must offer one-click, transactional mail is exempt.

SituationEffect
Template has topics, user record existsThose topics are switched off for that user. Everything else, transactional mail included, keeps flowing.
No topics, or the user record is goneThe address is added to suppressions outright. Over-honouring an opt-out is the safe direction.

The token carries the project, the user's external id, the channel, the address, and the topics — signed with HMAC-SHA256 and verified in constant time. A tampered payload or signature is rejected, and every failure returns the same response, so the endpoint cannot be used to probe which addresses exist.

Suppressions

Destinations that must not be contacted again. Rows are written automatically from provider webhooks on an unsubscribe, a spam complaint, or a hard bounce — soft bounces are a full mailbox or a transient outage and do not suppress — and by the unsubscribe endpoint when there is no topic to scope the opt-out to. The engine checks this list before dispatch, and it overrides everything else including priority: "critical".

Method & pathPurpose
GET /v1/suppressionsList. Query: limit (max 500), channel, reason.
POST /v1/suppressionsAdd one by hand. Body: { "channel", "target", "reason?" }. Idempotent.
DELETE /v1/suppressions/{channel}/{target}Remove one, re-enabling sends to that address.

reason is one of unsubscribed, complained, bounced, or manual. Targets are normalised before storage and lookup — email addresses are lower-cased, everything else is trimmed — so a suppression recorded as Bob@Example.com still blocks a send to bob@example.com.

Users and contacts

Method & pathPurpose
POST /v1/usersCreate or update a user with contacts, segments and preferences.
GET /v1/usersPaginate. Query: limit (max 100), cursor.
GET /v1/users/{id}One user, with contacts.
GET /v1/users/{id}/detailsSame, plus recent delivery logs.
PATCH /v1/users/{id}Partial update. New contact values are added; existing ones are kept.
DELETE /v1/users/{id}Delete the user and everything cascading from them.
POST /v1/users/{id}/contactsAdd one address, optionally with its own preferences.
GET /v1/users/{id}/contactsList addresses.
DELETE /v1/users/{id}/contacts/{channel}/{target}Remove one address.
GET /v1/users/{id}/preferencesPreferences only.
PATCH /v1/users/{id}/preferencesReplace the preferences object.

Templates, segments, workflows, events

Method & pathPurpose
PUT /v1/templatesUpsert a batch of templates.
GET /v1/templatesList templates in the project. Query: limit (max 100), channel, topic.
GET /v1/templates/{id}Fetch one.
DELETE /v1/templates/{id}Delete one and invalidate its cache.
GET /v1/segmentsDistinct segment tags in use.
POST /v1/workflowsCreate or replace a JSON workflow definition.
GET /v1/workflowsList definitions. Query: limit (max 100).
POST /v1/workflows/triggerStart an instance. Body: { "name", "input"?, "user"? }. Returns instanceId.
GET /v1/workflows/instances/{id}Instance status and input.
DELETE /v1/workflows/instances/{id}Cancel a running or suspended instance.
POST /v1/eventsIngest an external event. Wakes matching waitForEvent steps.
GET /v1/events/streamServer-sent events for live delivery outcomes.

Projects, system, dead letters

Method & pathAuthPurpose
POST /v1/projectsadminCreate a project. Returns the API key once.
GET /v1/projectsadminList projects.
PATCH /v1/projects/{id}adminSet rateLimitRpm, throttleLimit, throttleWindowHours.
DELETE /v1/projects/{id}adminDelete a project and all of its data.
POST /v1/projects/{id}/keysadminMint a key. Body: { "role": "admin" | "read_only" }.
GET /v1/projects/{id}/keysadminList key metadata. Never the keys themselves.
DELETE /v1/projects/{id}/keys/{keyId}adminRevoke, and invalidate the auth cache everywhere.
GET /v1/system/healthkeyDependency health with latencies.
GET /v1/system/metricskeyStream depths and delivery statistics.
GET /v1/dlqkeyMost recent 50 dead-lettered messages.
POST /v1/dlq/replaykeyBody: { "id": "<stream entry id>" }.
DELETE /v1/dlq/{id}keyDiscard one entry.
GET /health · /live · /ready · /metricsnoneProbes and Prometheus scraping.

Payload shapes

note

These are written as TypeScript interfaces, but they describe the JSON request bodies exactly — the field names and nesting are identical whether you go through the SDK or POST to the endpoint yourself. Read ? as "optional" and a | b as "one of these values". Nothing below this point in Payload shapes is Node-specific.

notify()

interface NotifyRequest {
  // Exactly one of these three is required.
  user?: string | InlineUser | Array<string | InlineUser>;
  segment?: string;
  topic?: string;

  template: string;                       // required
  channels?: ("email" | "sms" | "push" | "webhook" | "in-app")[];  // default ["email"]
  data?: Record<string, unknown>;         // {{placeholder}} values
  aiPrompts?: Record<string, string>;     // merged over the template's own
  priority?: "low" | "normal" | "high" | "critical";               // default "normal"
  fallback?: boolean;                     // channels become an ordered chain
  sendAt?: string;                        // ISO 8601, UTC
  campaign?: string;                      // groups this send for /v1/campaigns reporting
}

interface InlineUser {
  id: string;
  language?: string;
  timezone?: string;
  email?: string | string[];
  phone?: string | string[];
  pushToken?: string | string[];
  segments?: string[];
  preferences?: Preferences;
}
in-app

in-app passes validation but cannot be delivered: no endpoint creates an in-app contact, so the engine finds no address and drops the message with no_active_contacts. The four deliverable channels are email, sms, push, and webhook.

Preferences

interface Preferences {
  channels?: Record<string, boolean>;     // false disables that channel entirely
  topics?: Record<string, boolean>;       // false opts out of templates carrying that topic
  quietHours?: { start: string; end: string }[];   // "HH:MM", 24h, user's timezone
}

Template

interface Template {
  id: string;
  channel: "email" | "sms" | "push" | "webhook" | "in-app";
  topic?: string | string[];              // drives topic opt-outs
  content: Record<string, unknown>;       // free-form; strings are interpolated
  aiPrompts?: Record<string, string>;     // key becomes a template variable
}

content is free-form, but transports read known keys from it: subject, text (or body), and html (or htmlBody) on every first-party transport, plus from and replyTo on email, which override the transport's own sender for that template. See Templates & AI.

triggerWorkflow()

interface TriggerWorkflowRequest {
  name: string;                           // required — the registered workflow name
  input?: Record<string, unknown>;        // arrives as the handler's `event` argument
  user?: string | InlineUser;             // shorthand for input.user
}
two ways to name the trigger user

step.notify() sends to input.user.id unless a step names its own target, so an instance with no user has nowhere to send. Setting input.user yourself still works. The top-level user is shorthand: a string becomes input.user = { id }, and an inline user object is upserted into the project first — the same InlineUser shape notify() takes — so you can trigger a workflow for somebody the project has never seen without a separate addUser() call. Anything you already put in input.user is kept and layered over the id.

The upsert is best-effort: if it fails, the error is logged and the instance starts anyway, with input.user.id set. A 202 is therefore not a guarantee that the user record was written — call addUser() first if you need that guarantee.

Workflow steps (JSON form)

type WorkflowStep =
  | { action: "notify"; payload: NotifyRequest }
  | { action: "wait"; duration: string }                                    // "30s" "15m" "2h" "3d"
  | { action: "waitForEvent"; event: string; options?: { timeout?: string } };

Durations and timeouts are a number with an s, m, h, or d suffix. An unrecognised unit on a waitForEvent timeout throws rather than being read as zero. Any other action value is logged and skipped.

SDK

import { NotifkitClient } from "notifkit";

const client = new NotifkitClient({
  baseUrl: "https://notifkit.yourdomain.com",
  apiKey: process.env.NOTIFKIT_API_KEY,
  headers: { "x-request-source": "billing-service" },   // optional, merged into every request
  templates: [/* … */],                                 // optional; pushed by client.sync()
});
MethodEndpointReturns
notify(input)POST /v1/notify{ messageId, notificationId, target }, or a batch shape for arrays
addUser(input)POST /v1/users{ id }
updateUser(id, patch)PATCH /v1/users/:id{ id }
deleteUser(id)DELETE /v1/users/:idvoid
listUsers({ limit, cursor })GET /v1/users{ users, nextCursor }
getUser(id)GET /v1/users/:idUser record with contacts
getUserDetails(id)GET /v1/users/:id/detailsSame, plus recent message logs
getUserPreferences(id)GET /v1/users/:id/preferencesPreferences only
updateUserPreferences(id, preferences)PATCH /v1/users/:id/preferences{ id, preferences }
addContact(userId, input)POST /v1/users/:id/contacts{ userId, channel, target }
getUserContacts(userId)GET /v1/users/:id/contacts{ contacts }
deleteContact(userId, channel, target)DELETE /v1/users/:id/contacts/:channel/:targetvoid
syncTemplates({ templates })PUT /v1/templates{ synced }
sync()PUT /v1/templates{ synced } — pushes the constructor's templates
listTemplates()GET /v1/templates{ templates }
getTemplate(id)GET /v1/templates/:idTemplate record
deleteTemplate(id)DELETE /v1/templates/:idvoid
listSegments()GET /v1/segments{ segments }
createWorkflow(input)POST /v1/workflows{ name }
triggerWorkflow(input)POST /v1/workflows/trigger{ messageId, instanceId }
listWorkflows()GET /v1/workflows{ workflows }
getWorkflow(instanceId)GET /v1/workflows/instances/:idInstance record
cancelWorkflow(instanceId)DELETE /v1/workflows/instances/:idvoid
ingestEvent({ name, properties })POST /v1/events{ messageId, eventId }
getNotificationLogs(options)GET /v1/notifications/logs{ logs, nextCursor }
getNotificationStatus(taskId)GET /v1/notifications/:taskId{ status, logs }
cancelNotification(taskId)DELETE /v1/notifications/:taskId{ success }
getScheduledMessages()GET /v1/notifications/scheduled{ scheduled, nextCursor }
listCampaigns({ limit })GET /v1/campaigns{ campaigns }
getCampaignStats(campaign)GET /v1/campaigns/:campaign/stats{ totals, byChannel, warnings }
listSuppressions(options)GET /v1/suppressions{ suppressions }
createSuppression({ channel, target, reason })POST /v1/suppressions{ channel, target, reason }
deleteSuppression(channel, target)DELETE /v1/suppressions/:channel/:targetvoid
getSystemHealth()GET /v1/system/healthDependency health with latencies
getSystemMetrics()GET /v1/system/metricsStream depths and delivery statistics
getDLQMessages()GET /v1/dlq{ messages }
replayDLQMessage(id)POST /v1/dlq/replay{ success, replayedId }
deleteDLQMessage(id)DELETE /v1/dlq/:id{ success }
listProjects() · updateProject · deleteProjectGET · PATCH · DELETE /v1/projects[/:id]Admin key required
createProjectKey(id, { role }) · listProjectKeys · deleteProjectKeyPOST · GET /v1/projects/:id/keys · DELETE …/keys/:keyIdAdmin key required

Non-2xx responses are thrown as an Error carrying the server's message. There is no built-in retry — wrap calls in your own policy if you need one.

Server options

interface NotifkitOptions {
  // Required. "all" expands to every service.
  services: ("api" | "delivery" | "engine" | "enricher" |
             "scheduler" | "ai" | "workflow" | "events" | "all")[];

  redisUrl?: string;         // required when NODE_ENV=production
  databaseUrl?: string;      // required when NODE_ENV=production
  port?: number;             // default 3000
  logLevel?: "fatal" | "error" | "warn" | "info" | "debug" | "trace" | "silent";
  nodeEnv?: "development" | "test" | "production";

  providers?: Transport[];   // registered at priority 0
  autoMigrate?: boolean;     // default true
  aiModel?: LanguageModel;   // any Vercel AI SDK model
  workerConcurrency?: number;
  redisOptions?: { maxQueueLength?: number };
  dbOptions?: { maxConnections?: number };
}

start() resolves once every selected service is up; stop() shuts them down gracefully and stops any containers it started.

Environment variables

VariableDefault
NODE_ENVdevelopment
LOG_LEVELinfo
PORT3000
HOST127.0.0.1
REDIS_URLredis://localhost:6379
DATABASE_URLpostgres://platform:platform@localhost:5432/notifkit
ADMIN_API_KEYunset
WORKER_CONCURRENCY10
QUEUE_MAX_LEN10000000
DB_MAX_CONNECTIONS2
LOG_FLUSH_INTERVAL_MS500
LOG_BUFFER_MAX_SIZE5000
SEGMENT_MAX_USERS10000
RATE_LIMIT_PER_HOUR100

Precedence: constructor options → environment → .env → defaults.

Events

NotifkitServer extends Node's EventEmitter.

EventArguments
delivery:delivered(taskId, providerMessageId, channel, projectId)
delivery:failed(taskId, error, channel, projectId)
notification:throttled(recipientId, count)
notification:failed(messageId, error, eventType)
notification:skipped({ projectId, eventId, recipientId, reason })
notification:canceled({ projectId, taskId })

reason on a skip is one of user_opted_out, channel_disabled, template_not_found, or no_active_contacts.

Transport interface

interface Transport {
  readonly channel: NotificationChannel;
  readonly limits?: { limit: number; windowSeconds: number };

  send(task: NotificationDispatchedPayload): Promise<DeliveryResult>;

  webhookPath?: string;
  verifyWebhook?(rawBody: string, headers: Record<string, string | string[] | undefined>)
    : Promise<boolean> | boolean;
  parseWebhook?(body: any, rawBody?: string, headers?: Record<string, string | string[] | undefined>)
    : Promise<WebhookEvent[]>;
}

interface DeliveryResult {
  success: boolean;
  providerMessageId?: string;
  invalidToken?: boolean;    // push only — deactivates the contact
  error?: string;
}

interface WebhookEvent {
  providerMessageId: string;
  status: "opened" | "clicked" | "bounced" | "complained" | "unsubscribed";
  timestamp?: Date;

  // Required to suppress. The delivery log records the message, not the
  // destination, so without this an unsubscribe or bounce can be logged
  // but not acted on — the address keeps receiving mail.
  recipient?: string;

  // Only "hard" suppresses. Undefined is read as "soft", so a provider that
  // does not distinguish them never suppresses a working address.
  bounceType?: "hard" | "soft";

  // Kept on the log row: the clicked URL, the bounce description.
  metadata?: Record<string, unknown>;
}
import { registerTransport } from "notifkit";
registerTransport(transport, /* priority */ 10);   // higher priority is tried first

Workflow API

import { workflow } from "notifkit";

workflow("name", async ({ step, event }) => { /* … */ });

interface WorkflowStepContext {
  // Every field notify() takes. Name no target and it goes to the
  // instance's own user; name one and it wins.
  notify(payload: WorkflowNotifyInput): Promise<{
    success: boolean;
    messageId: string;
    notificationId: string;
  }>;
  wait(duration: string): Promise<void>;
  waitForEvent(
    eventName: string,
    options?: { timeout?: string; match?: Record<string, any> },   // "30s" "15m" "2h" "3d"
  ): Promise<any | null>;
  run<T>(name: string, fn: () => Promise<T> | T): Promise<T>;
}

A waitForEvent that times out resolves to null, and keeps resolving to null on every later replay of the instance — the timeout is recorded as the step's outcome, so a branch on === null stays stable across restarts. The event arriving a moment after the timer fired does not un-time-out the step.

Errors

TypeRaised when
AppErrorBase class. Carries a code.
ValidationErrorSchema validation failed. Carries a fields map.
HttpErrorThrown by API handlers; becomes a status code and body.
SuspendExecutionErrorInternal — a workflow step suspended. Never surfaced to callers.
PermanentAiErrorA model call failed in a way retrying cannot fix.

Constants

ValueWhere it applies
3 attemptsPer message, before the dead-letter queue. The Enricher allows 5.
60 secondsRecovery sweep interval, and the idle time before a pending message can be reclaimed.
24 hoursIdempotency window at every stage.
10 secondsTransport call timeout.
5 failures / 30 secondsCircuit breaker threshold and reset.
5 secondsScheduler and workflow-timer poll interval.
600 req/minDefault per-project API limit. 6 000 for the admin key.
100 messages/hourDefault per-user throttle.
10 000 usersMaximum segment or topic fan-out.
1 000 targetsChunk size when publishing a batch send.
100 rowsMaximum limit on paginated reads.