Segments & scheduling
One notify() call can address one person or fifty thousand, and it can land now
or next Tuesday. Both are the same request with a different target and an optional timestamp.
Four ways to say who
Exactly one of user, segment, or topic is required.
Supplying none — or more than one — is a validation error.
// 1 — a single known user
await notifkit.notify({ user: "usr_123", template: "receipt", channels: ["email"] });
// 2 — an inline user, upserted as part of the send
await notifkit.notify({
user: { id: "usr_999", email: "new@example.com", timezone: "Europe/Berlin" },
template: "welcome",
channels: ["email"],
});
// 3 — an explicit list (ids and inline objects can be mixed)
await notifkit.notify({
user: ["usr_1", "usr_2", { id: "usr_3", email: "c@example.com" }],
template: "maintenance-window",
channels: ["email"],
});
// 4 — everyone carrying a segment tag
await notifkit.notify({ segment: "beta", template: "feature-preview", channels: ["email"] });
// 5 — everyone subscribed to a topic
await notifkit.notify({ topic: "product-updates", template: "changelog", channels: ["email"] });
NK=http://localhost:3000/v1/notify
AUTH=(-H "Authorization: Bearer $NK_KEY" -H "Content-Type: application/json")
# 1 — a single known user
curl -X POST $NK "${AUTH[@]}" \
-d '{"user":"usr_123","template":"receipt","channels":["email"]}'
# 2 — an inline user, upserted as part of the send
curl -X POST $NK "${AUTH[@]}" \
-d '{
"user": { "id": "usr_999", "email": "new@example.com", "timezone": "Europe/Berlin" },
"template": "welcome",
"channels": ["email"]
}'
# 3 — an explicit list (ids and inline objects can be mixed)
curl -X POST $NK "${AUTH[@]}" \
-d '{
"user": ["usr_1", "usr_2", { "id": "usr_3", "email": "c@example.com" }],
"template": "maintenance-window",
"channels": ["email"]
}'
# 4 — everyone carrying a segment tag
curl -X POST $NK "${AUTH[@]}" \
-d '{"segment":"beta","template":"feature-preview","channels":["email"]}'
# 5 — everyone subscribed to a topic
curl -X POST $NK "${AUTH[@]}" \
-d '{"topic":"product-updates","template":"changelog","channels":["email"]}'
Segments
A segment is just a tag on a user. There is no query language and no rule builder — you decide membership in your own system and write the tags across.
await notifkit.addUser({
id: "usr_123",
email: "alice@example.com",
segments: ["beta", "enterprise", "eu"],
});
curl -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",
"segments": ["beta", "enterprise", "eu"]
}'
The tag list is replaced wholesale on update, so send the full set each time. To see which tags exist in a project:
curl -s http://localhost:3000/v1/segments -H "Authorization: Bearer $NK_KEY"
# { "segments": ["beta", "enterprise", "eu"] }
Because segment membership lives on the user record, it is resolved at send time. A user
tagged beta a second before you call notify() is included.
What a fan-out actually produces
The unit of delivery is not the notification — it is the task: one message, to one address, on one channel. A single segment send can produce a great many of them.
The fan-out ceiling
A segment resolving to more users than SEGMENT_MAX_USERS (10 000 by default) is
refused as a whole: the enricher logs an error, emits notification.failed, and
sends nothing. This is a guard against an accidental "everyone" tag, not a rate limit.
For genuinely larger audiences, page through your own user list and send explicit batches:
let cursor: string | null = null;
do {
const { users, nextCursor } = await notifkit.listUsers({ limit: 100, cursor: cursor ?? undefined });
await notifkit.notify({
user: users.map((u) => u.userId), // batched into one request
template: "changelog",
channels: ["email"],
priority: "low", // keep the low lane out of the way of real-time traffic
});
cursor = nextCursor;
} while (cursor);
cursor=""
while :; do
page=$(curl -s "http://localhost:3000/v1/users?limit=100&cursor=$cursor" \
-H "Authorization: Bearer $NK_KEY")
# batched into one request
ids=$(echo "$page" | jq -c '[.users[].userId]')
curl -s -X POST http://localhost:3000/v1/notify \
-H "Authorization: Bearer $NK_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n --argjson u "$ids" '{
user: $u,
template: "changelog",
channels: ["email"],
priority: "low"
}')"
cursor=$(echo "$page" | jq -r '.nextCursor // empty')
[ -z "$cursor" ] && break
done
The per-user hourly throttle still applies to every recipient of a bulk send, and the project-wide API rate limit (600 requests per minute by default) applies to the requests you make while paging. Batch users into fewer, larger calls rather than looping one at a time.
Batch responses look different
// single target
{ "messageId": "1737054981234-0", "notificationId": "0f8c2b7e-…", "target": { "type": "user", "userId": "usr_123" } }
// array target
{ "messageIds": ["…-0", "…-1", "…-2"], "notificationIdsBase": "0f8c2b7e-…", "batchSize": 3 }
Each recipient in a batch gets its own notification id, suffixed with its index. Targets are chunked internally at 1 000 per publish, so a 50 000-id array is one HTTP call and fifty stream batches.
Priority lanes
Every stream is split into three lanes, chosen by the priority field. A backlog
in one lane does not delay another — which is the whole reason a marketing blast cannot sit in
front of a password reset.
| Priority | Lane | Behaviour |
|---|---|---|
critical | critical | Bypasses quiet hours and the per-user throttle. |
high | normal | Fully gated. Shares the normal lane. |
normal (default) | normal | Fully gated. |
low | low | Fully gated. The right choice for bulk sends. |
Scheduling
Add sendAt as an ISO 8601 timestamp. The engine writes the fully rendered task to
Postgres, parks a pointer in a sharded Redis sorted set, and the scheduler releases it when
due — polling every five seconds.
const { notificationId } = await notifkit.notify({
user: "usr_123",
template: "trial-ending",
channels: ["email"],
sendAt: "2026-09-01T09:00:00.000Z", // UTC
data: { daysLeft: 3 },
});
curl -X POST http://localhost:3000/v1/notify \
-H "Authorization: Bearer $NK_KEY" \
-H "Content-Type: application/json" \
-d '{
"user": "usr_123",
"template": "trial-ending",
"channels": ["email"],
"sendAt": "2026-09-01T09:00:00.000Z",
"data": { "daysLeft": 3 }
}'
# → { "notificationId": "..." }
sendAt is absolute UTC — it is not adjusted into the user's timezone. To send
at 9am local to each recipient, compute the instant per user and send separately.
Quiet hours use the same scheduling machinery and are timezone-aware, so a
scheduled send that lands inside a quiet window is deferred again.
Inspecting and cancelling
# What is parked and waiting
curl -s http://localhost:3000/v1/notifications/scheduled -H "Authorization: Bearer $NK_KEY"
# Cancel one, by task id
curl -X DELETE http://localhost:3000/v1/notifications/{taskId} -H "Authorization: Bearer $NK_KEY"
Cancellation deletes the stored payload and emits notification:canceled. It only
works while the task is still parked — once the scheduler has released it to the outbound
stream, the answer is 404 Task not found or already processed. There is no
recall after dispatch.
Not sending the same thing twice
Pass your own key and NotifKit uses it as the notification id, so a retried HTTP request is collapsed rather than duplicated.
curl -X POST http://localhost:3000/v1/notify \
-H "Authorization: Bearer $NK_KEY" \
-H "x-idempotency-key: order-4471-shipped" \
-H "Content-Type: application/json" \
-d '{"user":"usr_123","template":"order-shipped","channels":["email"]}'
Guards downstream deduplicate on that key with a 24-hour window — extended for scheduled sends to cover the wait. Use something derived from your domain (an order id, an invoice number), not a random value, or the guard has nothing to match on.
Naming a send so you can report on it
A fan-out produces thousands of independent tasks with nothing linking them together. Pass
campaign and every message the call produces carries that label — as does every
open, click, bounce, and unsubscribe that arrives back afterwards.
await notifkit.notify({
segment: "beta-testers",
template: "changelog",
campaign: "changelog-2026-03", // ← the reporting handle
channels: ["email"],
});
curl -X POST http://localhost:3000/v1/notify -H "Authorization: Bearer $NK_KEY" \
-H "Content-Type: application/json" \
-d '{"segment":"beta-testers","template":"changelog",
"campaign":"changelog-2026-03","channels":["email"]}'
Then, whenever you want:
GET /v1/campaigns/changelog-2026-03/stats returns sent, delivered, failed,
opened, clicked, bounced, complained, and unsubscribed with rates and a per-channel split.
Full shape in the reference.
The label is what ties the messages together, so it can only be set on the way out. There
is no way to attribute a send afterwards — a broadcast made without
campaign can only ever be inspected message by message through the delivery
log. Reusing one label across two calls merges them into a single set of statistics, which
is right for a send you deliberately split into batches and wrong for everything else.
Checking on a send
# Latest status plus every attempt for one task
curl -s http://localhost:3000/v1/notifications/{taskId} -H "Authorization: Bearer $NK_KEY"
# Filtered history
curl -s "http://localhost:3000/v1/notifications/logs?templateId=changelog&status=failed&limit=100" \
-H "Authorization: Bearer $NK_KEY"
Or subscribe to the live stream over server-sent events, which is what a dashboard would use:
const events = new EventSource(
`http://localhost:3000/v1/events/stream?token=${apiKey}`,
);
events.addEventListener("delivery:delivered", (e) => console.log(JSON.parse(e.data)));
events.addEventListener("delivery:failed", (e) => console.warn(JSON.parse(e.data)));
# Server-sent events — any SSE client works; -N disables buffering.
curl -N "http://localhost:3000/v1/events/stream?token=$NK_KEY"
# event: delivery:delivered
# data: {"taskId":"0f8c…","providerMessageId":"…","channel":"email"}
#
# event: delivery:failed
# data: {"taskId":"1b2c…","error":"token expired","channel":"push"}