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 with it, as is every engagement event that
comes back later. Sends made without one are not attributable after the fact.
| 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 — 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.
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 & 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 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.
| 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 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.
| 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. 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 & 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 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 & 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
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 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
}
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()
});
| Method | Endpoint | Returns |
|---|---|---|
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/: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 } |
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 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
| 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 |
WORKER_CONCURRENCY | 10 |
QUEUE_MAX_LEN | 10000000 |
DB_MAX_CONNECTIONS | 2 |
LOG_FLUSH_INTERVAL_MS | 500 |
LOG_BUFFER_MAX_SIZE | 5000 |
SEGMENT_MAX_USERS | 10000 |
RATE_LIMIT_PER_HOUR | 100 |
Precedence: constructor options → environment → .env → defaults.
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 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
| 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 | Idempotency window at every stage. |
| 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. |