Segments & scheduling
One notify() call can reach one person or fifty thousand, now or next Tuesday: 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; none or
several 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: "[email protected]", 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: "[email protected]" }],
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": "[email protected]", "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": "[email protected]" }],
"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. No query language, no rule builder: you decide membership in your own system and write the tags across.
await notifkit.addUser("usr_123", [
{ channel: "email", target: "[email protected]" }
], {
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",
"contacts": [{ "channel": "email", "target": "[email protected]" }],
"segments": ["beta", "enterprise", "eu"]
}'
Updates replace the whole tag list, so send the full set each time. To list a project's tags:
curl -s http://localhost:3000/v1/segments -H "Authorization: Bearer $NK_KEY"
# { "segments": ["beta", "enterprise", "eu"] }
Membership lives on the user record, so it's resolved at send time: a user tagged
beta a second before notify() is included.
What a fan-out actually produces
The unit of delivery is the task: one message to one address on one channel. You send a notification; tasks come out of it, and one segment send can produce a great many.
The fan-out ceiling
A segment with more users than SEGMENT_MAX_USERS (100,000 by default; 10,000 up to 0.1.16) is refused
whole: the enricher logs an error, emits notification.failed, and sends nothing.
It guards against an accidental "everyone" tag; it isn't a rate limit.
For 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 each recipient of a bulk send, and the project API rate limit (600 requests per minute by default) applies to your paging requests. Batch users into fewer, larger calls instead of 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 at 1 000 per publish, so a 50 000-id array is one HTTP call and fifty stream batches.
Priority lanes
Every stream has three lanes, chosen by priority. A backlog in one lane doesn't delay
another, which is why a marketing blast can't 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 rendered task to Postgres,
parks a pointer in a sharded Redis sorted set, and the scheduler, polling every five seconds,
releases it when due.
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, not adjusted to the user's timezone. To send at 9am
local time, compute the instant per user and send separately. Quiet hours use the same
machinery and are timezone-aware, so a scheduled send landing in 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"
Cancelling deletes the stored payload and emits notification:canceled, but only
while the task is parked. Once released to the outbound stream, you get
404 Task not found or already processed. There's 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 request collapses instead of duplicating.
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"]}'
Downstream guards deduplicate on that key over 24 hours, extended for scheduled sends to cover the wait. Derive it from your domain (an order id, an invoice number), not a random value, or the guard has nothing to match.
Naming a send so you can report on it
A fan-out produces thousands of independent tasks with nothing linking them. Pass
campaign and every message carries that label, as does every open, click, bounce
and unsubscribe that comes back later.
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 like, 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 ties messages together, so it can only be set on the way out. A broadcast sent without
campaign can only be inspected message by message in the delivery log. Reusing a
label across two calls merges their statistics: right for a send deliberately split into
batches, wrong otherwise.
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 server-sent events stream, as a dashboard would:
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"}