← website
Build

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

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.

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. Push is the exception — one task per user, with tokens resolved at send time. Every task is gated independently, so opt-outs and quiet hours are applied per person, not per campaign.

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

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.

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 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": "..." }
note

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.

label it at send time or not at all

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