Node.js SDK
The norq npm package provides a TypeScript SDK for sending and linting notification templates from Node.js applications.
Install
Coming soon. The
norq.shpackage is not yet published to npm. See norq.sh for updates.
The SDK runs entirely in-process — no norq binary or daemon is invoked at runtime. The CLI is a separate install for local development; SDK users only need this package.
Quick start
import { Norq } from "norq.sh";
const notif = new Norq({ projectDir: "./my-project" });
// Send a notification
const result = await notif.send("transactional/order-shipped", {
to: { email: "gaurav@example.com", phone: "+1234567890" },
data: { user: { first_name: "Gaurav" }, order: { id: "ORD-123" } },
});Constructor options
const notif = new Norq({
projectDir: "./my-project", // project root (required)
strict: true, // runtime template validation (default: true)
logger: myLogger, // structured logger
recipientResolver: myResolver, // user ID -> Recipient
});| Option | Type | Description |
|---|---|---|
projectDir |
string |
Path to norq project root. Required — templates are read from this directory and compiled in-process. |
strict |
boolean |
Reject sends that reference missing data or unknown pipes. Default true. |
logger |
Logger |
Structured logger for send/compile events |
recipientResolver |
RecipientResolver |
Resolves user ID strings to Recipient objects |
API
send(id, opts)
Compile and deliver a notification.
const result = await notif.send("transactional/welcome", {
to: { email: "user@example.com" }, // Recipient object
// or: to: "user-123", // user ID (requires recipientResolver)
data: { user: { first_name: "Gaurav" } },
channels: ["email"], // optional: send only these channels
dryRun: false, // optional: compile without sending
idempotencyKey: "order-shipped-ORD-123", // optional: providers honour as Idempotency-Key
});
// result.results -> SendResult[] (one per channel)
// result.skipped -> SkippedChannel[] (channels that were skipped and why)| Option | Description |
|---|---|
to |
Recipient object or string (user ID, requires recipientResolver). |
data |
Template data — keys must satisfy data.schema.yaml when strict mode is on. |
sample |
Use a named entry from data.samples.yaml instead of data. |
channels |
Restrict the send to these channels only. |
dryRun |
Compile without sending. Returns { results: [], skipped: [] }. |
strict |
Per-call override for the client-level strict setting. |
idempotencyKey |
Forwarded to providers that honour it (Resend, SuprSend) as the Idempotency-Key: header. When omitted, the SDK generates a UUID v4 per send and applies the same value to every channel — so an unsupplied call still de-duplicates against an in-flight retry. Pass a stable user-derived key (order-shipped-ORD-123) to dedup across processes too. |
attachments |
Email-channel attachments. Each entry is { filename, content: Buffer | string, contentType?, contentId?, disposition? }. The SDK base64-encodes Buffer payloads transparently; string content is passed through unchanged. contentType falls back to filename-extension inference for a curated whitelist (pdf/png/jpg/csv/…). Use disposition: "inline" + contentId: "logo" to embed images, then reference them as <img src="cid:logo">. Provider caps: Resend 40MB total, SendGrid 30MB — validated locally before HTTP. See Attachments. |
batch(id, opts)
Create a batch for sending a single channel template to many recipients. The template is compiled once, then rendered per-recipient. Requests auto-flush at the provider’s max batch size.
const batch = await notif.batch("transactional/welcome", { channel: "email" });
// Add recipients (auto-flushes at provider's max batch size)
for (const user of users) {
await batch.add({
to: user.email,
data: { user: { first_name: user.name } },
});
}
// Flush remaining and get all results
const results = await batch.flush();lint(id?)
Lint notification templates.
const results = await notif.lint(); // all notifications
const results = await notif.lint("transactional/welcome"); // one notification
for (const result of results) {
for (const diag of result.diagnostics) {
console.log(`${diag.severity}: ${diag.message} [${diag.rule}]`);
}
}Recipient
const recipient: Recipient = {
email: "user@example.com",
cc: ["audit@example.com"], // email-channel only — providers that support it (Resend) include in body
bcc: ["compliance@example.com"],
phone: "+1234567890",
slack: { channel_id: "C01234567" },
push: {
tokens: [
{ token: "fcm-token-here", platform: "android" },
{ token: "apns-token-here", platform: "ios" },
],
},
whatsapp: { phone: "+1234567890" },
msteams: { webhook_url: "https://..." },
};The SDK intersects available channels (notification templates vs. recipient contact info) and silently skips unreachable channels, reporting skip reasons in result.skipped. cc and bcc apply to the email channel only — providers that support them (currently Resend) include them in the request body; others ignore.
RecipientResolver
To send by user ID instead of passing a full Recipient object, implement a RecipientResolver:
const notif = new Norq({
projectDir: "./my-project",
recipientResolver: {
resolve: async (userId: string) => {
const user = await db.getUser(userId);
return {
email: user.email,
phone: user.phone,
slack: user.slackId ? { channel_id: user.slackId } : undefined,
};
},
},
});
// Now send by user ID
await notif.send("transactional/welcome", {
to: "user-123", // resolved via recipientResolver
data: { user: { first_name: "Alice" } },
});Logger
const notif = new Norq({
logger: {
info(msg, meta) { console.log(msg, meta); },
warn(msg, meta) { console.warn(msg, meta); },
error(msg, meta) { console.error(msg, meta); },
},
});The SDK logs compile and send events with timing, channel, and provider metadata.
Payload types
interface EmailPayload {
subject: string;
html: string;
text: string;
}
interface SmsPayload {
body: string;
segments: number;
encoding: string; // "gsm7" or "ucs2"
}
interface SlackPayload {
blocks: unknown[];
text: string;
}
interface PushPayload {
title: string;
body: string;
image: string | null;
action_url: string | null;
platforms: {
ios?: Record<string, unknown>;
android?: Record<string, unknown>;
web?: Record<string, unknown>;
};
}
interface WhatsAppPayload {
type: string;
payload: unknown;
}
interface TeamsPayload {
card: unknown;
}Sending push notifications
Push delivery is per-token. A recipient’s push.tokens array lists every device the user has registered, possibly across ios, android, and web platforms. The SDK fans the send out one HTTP call per token and returns one SendResult per token in result.results — even when multiple tokens share the same channel.
Token shape
interface PushToken {
token: string; // FCM/APNs/Expo token, or Web Push endpoint URL
platform: "ios" | "android" | "web"; // required — Norq routes per platform
provider?: string; // optional — overrides routing.push / routing.push.<platform>
keys?: { p256dh: string; auth: string }; // required iff platform === "web"
environment?: "sandbox" | "production"; // APNs only; overrides provider production flag
}Mixed-platform recipient
const result = await notif.send("transactional/order-shipped", {
to: {
push: {
tokens: [
{ token: "fcm-android-token", platform: "android" },
{ token: "apns-ios-token", platform: "ios", environment: "production" },
{
token: "https://updates.push.services.mozilla.com/wpush/v2/...",
platform: "web",
keys: { p256dh: "BNc...", auth: "tBHI..." },
},
{ token: "ExponentPushToken[xxx]", platform: "ios", provider: "expo" },
],
},
},
data: { user: { first_name: "Gaurav" }, order: { id: "ORD-123" } },
channels: ["push"],
});Per-token result fields
Each push SendResult carries two extra fields beyond the standard success/provider/channel/message_id:
| Field | Meaning |
|---|---|
token_index |
Index into the original recipient.push.tokens array — match results back to the token that produced them. |
token_status |
Normalised cross-provider status: "invalid" (FCM UNREGISTERED, APNs BadDeviceToken, Web Push 404/410) or "transient" (5xx, rate limits). Absent on success. |
Reaction guide
Use token_status to decide what your application does next:
token_status |
What it means | What to do |
|---|---|---|
"invalid" |
Token is permanently dead — user uninstalled the app, revoked notifications, or the subscription expired. | Drop the token from your DB. Do not retry. |
"transient" |
Provider had a temporary failure — 5xx, rate limit, network error. | Retry later with exponential backoff. The token is still good. |
| (absent) on success | Delivery accepted by the provider. | No action. |
for (const r of result.results) {
if (r.channel !== "push") continue;
const token = recipient.push!.tokens[r.token_index!];
if (r.token_status === "invalid") {
await db.deletePushToken(userId, token.token);
} else if (r.token_status === "transient") {
await retryQueue.enqueue({ userId, token, retryAt: Date.now() + 60_000 });
}
}See Push provider errors for the full set of per-token failure modes.
Error handling
import { NorqError } from "norq.sh";
try {
await notif.send("transactional/welcome", { to: "user-123", data: { ... } });
} catch (err) {
if (err instanceof NorqError) {
console.error("Norq error:", err.message);
}
}Codegen
Generate typed bindings for compile-time type safety:
norq codegen --lang typescript --out src/generated/norq.tsThis generates typed interfaces for each notification’s data shape and channel availability.
Agent Toolkit
The Node SDK ships an Agent Toolkit module that exposes notifications as typed tools to LLM agents (Vercel AI SDK v6 and any framework that consumes JSON Schema). Locked recipient, allowlist scoping, deterministic idempotency keys, and minimal-by-default result shape — see the Agent Toolkit page for the full API.