Node SDK

@notificationstream/node: send notifications, manage project users and segments, and sign user tokens from your server.

The server SDK wraps the REST API with types, retries and pagination helpers.

  • Zero dependencies, fetch-based. Runs on Node 20+, Bun, Deno and Cloudflare Workers.
  • Fully typed requests and responses.
  • Retries 5xx responses, network errors and timeouts with exponential backoff.
  • Notification creation is idempotent by default.
  • Paginated lists can be awaited for one page or iterated with for await.

Caution

Use this package only on your server. The secret key (sk_…) grants full access to your project. In browsers and apps, use the JavaScript, React or React Native SDK.

Install

pnpm add @notificationstream/node

Quick start

import { NotificationStream } from "@notificationstream/node";

const ns = new NotificationStream({ secretKey: process.env.NOTIFICATIONSTREAM_SECRET_KEY });

await ns.projectUsers.upsert("user_123", {
  email: "ada@example.com",
  name: "Ada",
  locale: "en",
  attributes: { plan: "pro" },
  tags: ["beta"],
});

await ns.notifications.create({
  audience: { type: "users", userIds: ["user_123"] },
  contents: {
    en: { title: "Hi {{user.name}}, your invoice is ready", actionLabel: "View invoice" },
    de: { title: "Hallo {{user.name}}, deine Rechnung ist da", actionLabel: "Rechnung ansehen" },
  },
  category: "billing",
  actionUrl: "/billing/invoices/42",
});

The second locale and the {{user.name}} template need the Growth plan or higher. On Starter, send one locale without templates. See Plan features.

Configuration

new NotificationStream(options?: NotificationStreamOptions)
OptionTypeDefault
secretKeystringprocess.env.NOTIFICATIONSTREAM_SECRET_KEY (if present)
apiUrlstringhttps://api.notificationstream.com (or NOTIFICATIONSTREAM_API_URL)
fetchtypeof fetchglobalThis.fetch
maxRetriesnumber2
timeoutnumber (ms)30000 per attempt (0 turns it off)

The constructor throws a TypeError if there is no secret key.

Signing user tokens

The client SDKs prove which user they act for with a user token: an ES256 JWT whose sub is the user id, signed with one of the project's signing keys. Sign it on your server:

import { createUserToken } from "@notificationstream/node";

const userToken = await createUserToken(process.env.NOTIFICATIONSTREAM_SIGNING_KEY!, user.id);
createUserToken(
  signingKey: string, // the PKCS#8 PEM from the dashboard
  userId: string,
  options?: { keyId?: string; expiresIn?: number },
): Promise<string>
OptionDefaultNotes
keyIdnoneThe signing key id (sig_…), sent as the JWT kid. Without it, the API tries each of the project's keys.
expiresIn3600Seconds until the token expires. At most 604800 (7 days).

It uses WebCrypto (crypto.subtle), so it works in any modern runtime. NotificationStream.createUserToken(signingKey, userId, options) is the same function.

Serve tokens from an endpoint and pass the client SDKs a function as token, such as () => fetch("/api/notifications/token").then((r) => r.text()). They call it on start, shortly before the token expires and after a 401:

Express
app.get("/api/notifications/token", requireAuth, async (req, res) => {
  const token = await createUserToken(process.env.NOTIFICATIONSTREAM_SIGNING_KEY!, req.user.id);
  res.set("Cache-Control", "no-store").type("text/plain").send(token);
});
Next.js App Router (route handler)
import { createUserToken } from "@notificationstream/node";

export async function GET() {
  const user = await getCurrentUser();
  if (!user) return new Response("Unauthorized", { status: 401 });
  const token = await createUserToken(process.env.NOTIFICATIONSTREAM_SIGNING_KEY!, user.id);
  return new Response(token, { headers: { "Cache-Control": "no-store" } });
}
Cloudflare Workers
import { createUserToken } from "@notificationstream/node";

export default {
  async fetch(request: Request, env: Env) {
    const userId = await authenticate(request);
    const token = await createUserToken(env.NOTIFICATIONSTREAM_SIGNING_KEY, userId);
    return new Response(token, { headers: { "Cache-Control": "no-store" } });
  },
};

Notifications

ns.notifications.create(input: CreateNotificationInput, options?: { idempotencyKey?: string }): Promise<NotificationView>
ns.notifications.list(options?: { limit?: number; cursor?: string; status?: NotificationDisplayStatus }): PagePromise<NotificationView>
ns.notifications.get(id: string): Promise<NotificationWithStats>
ns.notifications.update(id: string, input: UpdateNotificationInput): Promise<NotificationView>
ns.notifications.cancel(id: string): Promise<NotificationView>
ns.notifications.delete(id: string): Promise<void>

CreateNotificationInput:

  • audience (required): { type: "all" }, { type: "users", userIds } (max 1000), { type: "tags", tags, match?: "any" | "all" } or { type: "segment", segmentId }. See Targeting.
  • contents (required): { [locale]: { title, body?, actionLabel? } }, with templates.
  • defaultLocale, category, actionUrl, imageUrl (http(s) URL or a /path), data (any JSON), status ("draft" or "published", default "published"), publishAt (a future value schedules it), expiresAt, idempotencyKey.

get returns the notification plus stats: { read, clicked, archived }, counted from per-user state (a bulk "mark all as read" is not counted per notification).

On Starter, tags and segment audiences, more than one locale, {{…}} templates and a publishAt more than a minute ahead reject with forbidden (403). See Plan features.

update only works for drafts and scheduled notifications; published, expired and canceled ones reject with conflict (409). It accepts any subset of the create fields except idempotencyKey; omitted fields stay unchanged, and null clears category, actionUrl, imageUrl, data or expiresAt. Plan features are checked only on the fields you pass. Pass status: "published" to publish a draft. cancel removes a notification from every feed and keeps it with status canceled; delete removes it and all per-user state. NotificationDisplayStatus is "draft" | "scheduled" | "published" | "expired" | "canceled".

Broadcasts (all, tags and segment audiences) can take up to about 30 seconds to show up in feeds; a users audience shows up on the user's next fetch. See How quickly do notifications appear?

Idempotency

Every create call sends an Idempotency-Key header and an idempotencyKey body field with the same value, and the API returns the original notification for a repeated key. If you pass neither options.idempotencyKey nor input.idempotencyKey, the SDK generates a random key for that call, so its own retries never send twice. To deduplicate across your own retries (for example in a job queue), pass a stable key:

await ns.notifications.create(
  {
    audience: { type: "users", userIds: [invoice.userId] },
    contents: { en: { title: "Invoice {{data.number}} is ready" } },
    data: { number: invoice.number },
  },
  { idempotencyKey: `invoice-${invoice.id}-ready` },
);

Project users

ns.projectUsers.upsert(userId: string, input?: UpsertProjectUserInput): Promise<ProjectUserView>
ns.projectUsers.get(userId: string): Promise<ProjectUserView>
ns.projectUsers.list(options?: { limit?: number; cursor?: string; search?: string; tag?: string }): PagePromise<ProjectUserView>
ns.projectUsers.delete(userId: string): Promise<void>
ns.projectUsers.updateTags(userId: string, input: { add?: string[]; remove?: string[] }): Promise<ProjectUserView>
ns.projectUsers.feed(userId: string, options?: { limit?: number; cursor?: string; status?: "all" | "unread" | "archived"; category?: string; locale?: string }): Promise<FeedResponse>

UpsertProjectUserInput has email, name, avatarUrl, locale, timezone (each nullable), attributes (merged into the existing attributes; a null value deletes that key) and tags (replaces all tags). updateTags adds or removes individual tags. feed returns exactly what the client SDKs would show this user. get, updateTags, delete and feed reject with not_found for an unknown userId. delete erases the project user and their notification state (for example for GDPR requests).

Segments

ns.segments.list(): Promise<SegmentView[]>
ns.segments.create(input: SegmentInput): Promise<SegmentView>
ns.segments.get(id: string): Promise<SegmentView>
ns.segments.update(id: string, input: UpdateSegmentInput /* Partial<SegmentInput> */): Promise<SegmentView>
ns.segments.delete(id: string): Promise<void>
ns.segments.preview(conditions: ConditionGroup): Promise<SegmentPreview>
await ns.segments.create({
  name: "Pro users in Germany",
  conditions: {
    combinator: "and",
    conditions: [
      { field: "attributes.plan", operator: "eq", value: "pro" },
      { field: "locale", operator: "starts_with", value: "de" },
    ],
  },
});

Fields, operators and limits are in Segments. preview estimates matches from up to 1,000 recently active project users and returns { sampled, matched, examples } (at most 5 examples). list returns every segment sorted by name as an array (segments aren't paginated). Updating a segment affects every notification that targets it, including published ones. create and update need the Growth plan or higher (forbidden on Starter); list, get, delete and preview work on every plan.

Project

ns.project.get(): Promise<ProjectView> // { id, name, defaultLocale, publishableKey }

Handy for checking a secret key and looking up the publishable key.

Pagination

list() returns a PagePromise<T>. Awaiting it fetches a single page:

const { items, nextCursor } = await ns.projectUsers.list({ limit: 100 });
const next = await ns.projectUsers.list({ limit: 100, cursor: nextCursor ?? undefined });

Iterating it fetches pages lazily and follows the cursors:

for await (const projectUser of ns.projectUsers.list({ tag: "beta" })) {
  console.log(projectUser.userId);
}

for await (const page of ns.notifications.list({ status: "scheduled" }).pages()) {
  console.log(page.items.length);
}

const everything = await ns.projectUsers.list().toArray(); // every page, in memory

No request is made until you await or iterate the PagePromise.

Errors

Every failed call rejects with a NotificationStreamError:

import { NotificationStreamError } from "@notificationstream/node";

try {
  await ns.projectUsers.get("missing");
} catch (error) {
  if (error instanceof NotificationStreamError && error.code === "not_found") {
    // …
  }
}
Property
codeAn API error code, or "network_error" / "timeout"
statusHTTP status (0 if no response arrived)
messageHuman-readable message from the API
detailserror.details from the API. For validation errors: { path, message, code }[]
requestIdThe API's X-Request-Id for the request. Include it when you contact support.

Retries

The SDK retries up to maxRetries times (default 2), with exponential backoff and jitter: 250 to 500 ms before the first retry, 500 to 1000 ms before the second, doubling up to at most 8 s. A Retry-After header on the response (capped at 60 s) replaces the computed delay.

  • 5xx responses, network errors and timeouts are retried for idempotent requests only: GET, PUT, PATCH, DELETE, notifications.create (which always has an idempotency key), notifications.cancel, projectUsers.updateTags and segments.preview. segments.create isn't retried on these, so it never creates a second segment.
  • 4xx errors are never retried, except 429.

Server endpoints aren't rate-limited, and calls made with the secret key don't count towards your plan's monthly requests.

Other exports: DEFAULT_API_URL, SDK_VERSION, the resource classes and all request and response types.