Norq

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.sh package 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.ts

This 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.