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 type | Scope | Notes |
|---|---|---|
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
| Code | Meaning |
|---|---|
200 | Success. |
201 | Created: users, contacts, workflows, project keys. |
202 | Accepted and enqueued. Not a delivery confirmation. |
204 | Deleted. |
400 | Validation failed. The body carries issues from Zod. |
401 | Missing or invalid key. |
403 | Read-only key on a write, or project management with no admin key configured. |
404 | Not found in this project. |
429 | Project request limit exceeded. Retry-After: 60. |
500 | Unhandled server error. |
Endpoints
Notifications
| Method & path | Purpose |
|---|---|
POST /v1/notify | Request a send. Honours x-idempotency-key. |
GET /v1/notifications/logs | Delivery history. Query: limit (max 100), cursor, templateId, workflowInstanceId, channel, status, taskId, search. |
GET /v1/notifications/scheduled | Tasks 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, along with every later engagement event. Sends
without one can't be attributed afterwards.
| Method & path | Purpose |
|---|---|
GET /v1/campaigns | Campaign labels with message count and time range, newest activity first. Query: limit (max 100). |
GET /v1/campaigns/{campaign}/stats | Delivery and engagement funnel. 404 if no messages carry that label. |
Stats count distinct tasks, not log rows. A task gets 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.
When opens and clicks can't be measured (an SMS or push campaign, or email without the provider
webhook wired up), engagementTracked is false and
warnings is populated. A zero openRate then means unknown,
not nobody opened it. Show the warning, not 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 since reputation attaches to the sending domain, your transactional mail eventually suffers too.
These two routes are the API's only unauthenticated endpoints, since a mail client has no API key. The signed token in the URL is the credential, authorising exactly one opt-out for one address.
| Method & path | Purpose |
|---|---|
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. |
Corporate mail scanners and link prescanners GET every URL in a message. An opt-out
on GET would unsubscribe recipients who never clicked, so the change happens on
POST, as the one-click spec requires.
Configuration
Headers are attached only when both are set. With either missing, mail still
sends, without List-Unsubscribe. A missing UNSUBSCRIBE_SECRET logs at
warn whenever it matters: once per bulk email sent without the headers, and once per
request to either unsubscribe route, where every token is rejected for lack of a key. Grep for
it before assuming your links work.
| Variable | Notes |
|---|---|
PUBLIC_URL | Where an inbox can reach this API. Not HOST/PORT, which are the bind address behind your proxy. |
UNSUBSCRIBE_SECRET | Signs the tokens, minimum 16 characters. Effectively permanent; see below. |
Tokens are signed, not stored, and never expire: an unsubscribe link must work from an inbox
years later, with no session. Rotating UNSUBSCRIBE_SECRET invalidates every link
already sent. A recipient whose link says "not valid" hits the spam button instead, costing far
more reputation than the key protected.
What gets a header, and what an opt-out does
Headers go only on email whose template has at least one topic. A template with no topic is transactional by convention (a password reset, a receipt) and shouldn't carry an unsubscribe button. Mailbox providers draw the same line: bulk mail must offer one-click; transactional mail is exempt.
| Situation | Effect |
|---|---|
| Template has topics, user record exists | Those topics are switched off for that user. Everything else, transactional mail included, keeps flowing. |
| No topics, or the user record is gone | The 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. Tampered payloads or signatures are rejected, and every failure returns the same response, so the endpoint can't be used to probe which addresses exist.
Suppressions
Destinations never to contact again. Rows are written automatically from provider webhooks on an
unsubscribe, spam complaint or hard bounce (soft bounces, a full mailbox or transient
outage, don't suppress), and by the unsubscribe endpoint when there's no topic to scope the
opt-out to. The engine checks the list before dispatch, overriding everything, including
priority: "critical".
| Method & path | Purpose |
|---|---|
GET /v1/suppressions | List. Query: limit (max 500), channel, reason. |
POST /v1/suppressions | Add 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 for storage and lookup (email
lower-cased, everything else trimmed), so a suppression for [email protected] still
blocks [email protected].
Users and contacts
| Method & path | Purpose |
|---|---|
POST /v1/users | Create or update a user with contacts, segments and preferences. |
GET /v1/users | Paginate. Query: limit (max 100), cursor. |
GET /v1/users/{id} | One user, with contacts. |
GET /v1/users/{id}/details | Same, 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}/contacts | Add one address, optionally with its own preferences. |
GET /v1/users/{id}/contacts | List addresses. |
DELETE /v1/users/{id}/contacts/{channel}/{target} | Remove one address. |
GET /v1/users/{id}/preferences | Preferences only. |
PATCH /v1/users/{id}/preferences | Replace the preferences object. |
Templates, segments, workflows, events
| Method & path | Purpose |
|---|---|
PUT /v1/templates | Upsert a batch of templates. |
GET /v1/templates | List 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/segments | Distinct segment tags in use. |
POST /v1/workflows | Create or replace a JSON workflow definition. |
GET /v1/workflows | List definitions. Query: limit (max 100). |
POST /v1/workflows/trigger | Start 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/events | Ingest an external event. Wakes matching waitForEvent steps. |
GET /v1/events/stream | Server-sent events for live delivery outcomes. |
Projects, system, dead letters
| Method & path | Auth | Purpose |
|---|---|---|
POST /v1/projects | admin | Create a project. Returns the API key once. |
GET /v1/projects | admin | List projects. |
PATCH /v1/projects/{id} | admin | Set rateLimitRpm, throttleLimit, throttleWindowHours. |
DELETE /v1/projects/{id} | admin | Delete a project and all of its data. |
POST /v1/projects/{id}/keys | admin | Mint a key. Body: { "role": "admin" | "read_only" }. |
GET /v1/projects/{id}/keys | admin | List key metadata. Never the keys themselves. |
DELETE /v1/projects/{id}/keys/{keyId} | admin | Revoke, and invalidate the auth cache everywhere. |
GET /v1/system/health | key | Dependency health with latencies. |
GET /v1/system/metrics | key | Stream depths and delivery statistics. |
GET /v1/dlq | key | Most recent 50 dead-lettered messages. |
POST /v1/dlq/replay | key | Body: { "id": "<stream entry id>" }. |
DELETE /v1/dlq/{id} | key | Discard one entry. |
GET /health · /live · /ready · /metrics | none | Probes and Prometheus scraping. |
Payload shapes
Written as TypeScript interfaces, these describe the JSON request bodies
exactly: same field names and nesting via the SDK or a direct POST. ? means
optional; a | b means one of these values. Nothing 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?: Channel[]; // 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
}
type Channel =
| "email" | "sms" | "push" | "webhook"
| "slack" | "whatsapp" | "telegram" | "discord"
| "in-app";
interface InlineUser {
id: string;
language?: string;
timezone?: string;
email?: string | string[];
phone?: string | string[];
pushToken?: string | string[];
segments?: string[];
preferences?: Preferences;
}
No first-party transport ships for in-app; a send on it fails with
no_transport (or falls back to the next channel) unless you register one.
webhook also needs a transport you write; the other seven channels each have a
provider package. See
Routing for what each one's contact target is.
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: Channel;
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: subject,
text (or body) and html (or htmlBody) on
every first-party transport, plus from and replyTo on email, which
override the transport's 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
}
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 works. The top-level user is shorthand: a string becomes
input.user = { id }, and an inline user object (the InlineUser shape
notify() takes) is upserted first, so you can trigger a workflow for someone new
without a separate addUser() call. Anything already 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. So a 202 doesn't guarantee the user was written;
call addUser() first if you need that.
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 unknown unit on a waitForEvent timeout throws rather than
reading 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()
});
| Method | Endpoint | Returns |
|---|---|---|
notify(input) | POST /v1/notify | { messageId, notificationId, target }, or a batch shape for arrays |
addUser(id, contacts, options?)Aliases: upsertUser, identify | POST /v1/users | { id } |
updateUser(id, patch) | PATCH /v1/users/:id | { id } |
deleteUser(id) | DELETE /v1/users/:id | void |
listUsers({ limit, cursor }) | GET /v1/users | { users, nextCursor } |
getUser(id) | GET /v1/users/:id | User record with contacts |
getUserDetails(id) | GET /v1/users/:id/details | Same, plus recent message logs |
getUserPreferences(id) | GET /v1/users/:id/preferences | Preferences only |
updateUserPreferences(id, preferences) | PATCH /v1/users/:id/preferences | { id, preferences } |
addContact(userId, input) | POST /v1/users/:id/contacts | { userId, channel, target } |
addContacts(userId, contacts) | POST /v1/users/:id/contacts | Array of contact objects |
getUserContacts(userId) | GET /v1/users/:id/contacts | { contacts } |
deleteContact(userId, channel, target) | DELETE /v1/users/:id/contacts/:channel/:target | void |
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/:id | Template record |
deleteTemplate(id) | DELETE /v1/templates/:id | void |
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/:id | Instance record |
cancelWorkflow(instanceId) | DELETE /v1/workflows/instances/:id | void |
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/:target | void |
getSystemHealth() | GET /v1/system/health | Dependency health with latencies |
getSystemMetrics() | GET /v1/system/metrics | Stream 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 · deleteProject | GET · PATCH · DELETE /v1/projects[/:id] | Admin key required |
createProjectKey(id, { role }) · listProjectKeys · deleteProjectKey | POST · GET /v1/projects/:id/keys · DELETE …/keys/:keyId | Admin key required |
Non-2xx responses throw an Error with the server's message. There's no
built-in retry; add your own policy if needed.
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;
deliveryConcurrency?: number; // default workerConcurrency
fusedPipeline?: boolean; // in-memory hand-off when enricher, engine and delivery share a process
adminUser?: { email: string; password: string; username?: string }; // first dashboard login
redisOptions?: { maxQueueLength?: number }; // QUEUE_MAX_LEN: backlog cap, see Operations
dbOptions?: { maxConnections?: number; schema?: string }; // schema: DB_SCHEMA, default "public"
}
start() resolves once every selected service is up; stop() shuts
them down gracefully and stops any containers it started.
Environment variables
| Variable | Default |
|---|---|
NODE_ENV | development |
LOG_LEVEL | info |
PORT | 3000 |
HOST | 127.0.0.1 |
REDIS_URL | redis://localhost:6379 |
DATABASE_URL | postgres://platform:platform@localhost:5432/notifkit |
ADMIN_API_KEY | unset |
ADMIN_EMAIL / ADMIN_PASSWORD / ADMIN_USERNAME | unset |
CORS_ORIGIN | unset |
TRUST_PROXY | false |
WORKER_CONCURRENCY | 10 |
DELIVERY_CONCURRENCY | WORKER_CONCURRENCY |
PIPELINE_FUSED | false |
QUEUE_MAX_LEN | 100000 |
IDEMPOTENCY_TTL_SECONDS | 3600 |
DB_MAX_CONNECTIONS | 2 |
DB_SCHEMA | public |
LOG_FLUSH_INTERVAL_MS | 500 |
LOG_BUFFER_MAX_SIZE | 5000 |
LOG_RETENTION_DAYS | unset |
SEGMENT_MAX_USERS | 100000 (10000 up to 0.1.16) |
RATE_LIMIT_PER_HOUR | 100 |
Precedence: constructor options → environment → .env → defaults. What each one does
is in Operations.
Events
NotifkitServer extends Node's EventEmitter.
| Event | Arguments |
|---|---|
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 to
null again on every replay: the timeout is recorded as the step's outcome, so a branch on
=== null stays stable across restarts. An event arriving just after the timer
doesn't undo the timeout.
Errors
| Type | Raised when |
|---|---|
AppError | Base class. Carries a code. |
ValidationError | Schema validation failed. Carries a fields map. |
HttpError | Thrown by API handlers; becomes a status code and body. |
SuspendExecutionError | Internal: a workflow step suspended. Never surfaced to callers. |
PermanentAiError | A model call failed in a way retrying cannot fix. |
Constants
| Value | Where it applies |
|---|---|
| 3 attempts | Per message, before the dead-letter queue. The Enricher allows 5. |
| 60 seconds | Recovery sweep interval, and the idle time before a pending message can be reclaimed. |
| 24 hours | Window for a caller's x-idempotency-key. |
| 1 hour | Idempotency window for everything else, so a redelivery is not sent twice (IDEMPOTENCY_TTL_SECONDS). |
| 10 seconds | Transport call timeout. |
| 5 failures / 30 seconds | Circuit breaker threshold and reset. |
| 5 seconds | Scheduler and workflow-timer poll interval. |
| 600 req/min | Default per-project API limit. 6 000 for the admin key. |
| 100 messages/hour | Default per-user throttle. |
| 10 000 users | Maximum segment or topic fan-out. |
| 1 000 targets | Chunk size when publishing a batch send. |
| 100 rows | Maximum limit on paginated reads. |