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
5xxresponses, 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/nodeQuick 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)| Option | Type | Default |
|---|---|---|
secretKey | string | process.env.NOTIFICATIONSTREAM_SECRET_KEY (if present) |
apiUrl | string | https://api.notificationstream.com (or NOTIFICATIONSTREAM_API_URL) |
fetch | typeof fetch | globalThis.fetch |
maxRetries | number | 2 |
timeout | number (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>| Option | Default | Notes |
|---|---|---|
keyId | none | The signing key id (sig_…), sent as the JWT kid. Without it, the API tries each of the project's keys. |
expiresIn | 3600 | Seconds 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:
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);
});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" } });
}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 memoryNo 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 | |
|---|---|
code | An API error code, or "network_error" / "timeout" |
status | HTTP status (0 if no response arrived) |
message | Human-readable message from the API |
details | error.details from the API. For validation errors: { path, message, code }[] |
requestId | The 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.
5xxresponses, 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.updateTagsandsegments.preview.segments.createisn't retried on these, so it never creates a second segment.4xxerrors are never retried, except429.
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.