Star
Build

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"] }
Note

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.

notify() segment: "beta" channels: [email, push] Enricher expands to a user list capped at 10 000 usr_1 2 emails · 1 token usr_2 1 email, no token 4 998 more loaded 500 at a time 3 tasks 2 email · 1 push 1 task email only … one per address one request one message per user one task per address
tasks ≈ users × channels × addresses per channel. Except push: one task per user, with tokens resolved at send time. Each task is gated separately, so opt-outs and quiet hours apply per person, not per campaign.

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
Watch out

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.

PriorityLaneBehaviour
criticalcriticalBypasses quiet hours and the per-user throttle.
highnormalFully gated. Shares the normal lane.
normal (default)normalFully gated.
lowlowFully 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": "..." }
Note

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.

Label it at send time or not at all

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"}