npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@norialabs/sendstack

v0.1.12

Published

Official JavaScript SDK for the SendStack email SaaS API.

Readme

@norialabs/sendstack

Official JavaScript SDK for the SendStack messaging API.

Use it for:

  • transactional and scheduled email, SMS, and WhatsApp
  • batch email, SMS, and WhatsApp
  • reusable attachment uploads
  • sending domains
  • WhatsApp Business senders and SMS sender-ID provisioning
  • email, SMS, and WhatsApp templates (with rendered previews)
  • webhook endpoints and webhook event retries
  • suppression lists
  • billing: credits, checkout, and payment/purchase history

Node >=20 is required.

Install

npm install @norialabs/sendstack

Quick Start

import { Sendstack } from "@norialabs/sendstack";

const token = process.env.SENDSTACK_TOKEN;

if (!token) {
  throw new Error("SENDSTACK_TOKEN is required.");
}

const sendstack = new Sendstack(token);

const message = await sendstack.emails.send(
  {
    from: "Noria <[email protected]>",
    to: "[email protected]",
    subject: "Hello from SendStack",
    html: "<p>Your email pipeline is working.</p>",
    text: "Your email pipeline is working.",
  },
  {
    idempotencyKey: "welcome-email-1",
  },
);

console.log(message.id, message.status);

The SDK defaults to https://sendstack.norialabs.com/api/v1 (the versioned API base). Override baseUrl to point at another environment — include the /api/v1 version segment, since the SDK sends resource paths (e.g. /emails) relative to whatever base you provide:

const sendstack = new Sendstack({
  authToken,
  baseUrl: "https://staging.norialabs.com/api/v1",
});

Documentation

This package guide covers install, initialization, SDK methods, TypeScript names, request options, errors, and examples.

Complete SendStack SaaS docs: https://sendstack.norialabs.com/docs.

Use the SaaS docs as the canonical source for product/API behavior: account setup, API tokens, domain verification, DNS records, webhook event catalogs, deliverability concepts, provider behavior, dashboard workflows, and raw HTTP API reference.

Auth

The current API uses bearer auth:

Authorization: Bearer <token>

Passing a token as the first constructor argument configures that header automatically.

const sendstack = new Sendstack("mlr_live_...");

You can also pass custom auth:

const sendstack = new Sendstack({
  auth: {
    type: "bearer",
    token: async () => await getFreshToken(),
  },
});

Method Reference

| SDK method | HTTP route | Returns | | --- | --- | --- | | attachments.upload(payload, options?) | POST /attachments | UploadedAttachment | | emails.send(payload, options?) | POST /emails | SendEmailResult | | emails.sendBatch(payload, options?) | POST /emails/batch | SendEmailBatchResult | | emails.list(options?) | GET /emails | CursorPage<EmailMessage> | | emails.get(messageId, options?) | GET /emails/{id} | EmailMessage | | emails.events(messageId, options?) | GET /emails/{id}/events | CursorPage<EmailEvent> | | emails.cancel(messageId, options?) | POST /emails/{id}/cancel | EmailMessage | | emails.requeue(messageId, options?) | POST /emails/{id}/requeue | EmailMessage | | sms.send(payload, options?) | POST /sms | SendSmsResult | | sms.sendBatch(payload, options?) | POST /sms/batch | SendSmsBatchResult | | sms.list(options?) | GET /sms | CursorPage<SmsMessage> | | sms.get(messageId, options?) | GET /sms/{id} | SmsMessage | | sms.events(messageId, options?) | GET /sms/{id}/events | CursorPage<SmsEvent> | | sms.cancel(messageId, options?) | POST /sms/{id}/cancel | SmsMessage | | sms.requeue(messageId, options?) | POST /sms/{id}/requeue | SmsMessage | | whatsapp.send(payload, options?) | POST /whatsapp | SendWhatsAppResult | | whatsapp.sendBatch(payload, options?) | POST /whatsapp/batch | SendWhatsAppBatchResult | | whatsapp.list(options?) | GET /whatsapp | CursorPage<WhatsAppMessage> | | whatsapp.get(messageId, options?) | GET /whatsapp/{id} | WhatsAppMessage | | whatsapp.events(messageId, options?) | GET /whatsapp/{id}/events | CursorPage<WhatsAppEvent> | | whatsapp.cancel(messageId, options?) | POST /whatsapp/{id}/cancel | WhatsAppMessage | | whatsapp.requeue(messageId, options?) | POST /whatsapp/{id}/requeue | WhatsAppMessage | | whatsappSenders.list(options?) | GET /whatsapp/senders | CursorPage<WhatsAppSender> | | whatsappSenders.create(payload, options?) | POST /whatsapp/senders | WhatsAppSenderRef | | whatsappSenders.get(senderId, options?) | GET /whatsapp/senders/{id} | WhatsAppSender | | whatsappSenders.setDefault(senderId, options?) | POST /whatsapp/senders/{id}/default | WhatsAppSenderRef | | whatsappSenders.remove(senderId, options?) | DELETE /whatsapp/senders/{id} | void | | domains.create(payload, options?) | POST /domains | Domain | | domains.list(options?) | GET /domains | CursorPage<Domain> | | domains.get(domainId, options?) | GET /domains/{id} | Domain | | domains.verify(domainId, options?) | POST /domains/{id}/verify | Domain | | templates.create(payload, options?) | POST /templates | EmailTemplate | | templates.list(options?) | GET /templates | CursorPage<EmailTemplate> | | templates.get(templateId, options?) | GET /templates/{id} | EmailTemplate | | templates.update(templateId, payload, options?) | PATCH /templates/{id} | EmailTemplate | | templates.remove(templateId, options?) | DELETE /templates/{id} | void | | templates.preview(payload, options?) | POST /templates/preview | TemplatePreview | | webhooks.create(payload, options?) | POST /webhook-endpoints | WebhookEndpoint | | webhooks.list(options?) | GET /webhook-endpoints | CursorPage<WebhookEndpoint> | | webhooks.update(webhookId, payload, options?) | PATCH /webhook-endpoints/{id} | WebhookEndpoint | | webhooks.remove(webhookId, options?) | DELETE /webhook-endpoints/{id} | void | | webhookEvents.retry(eventId, options?) | POST /events/{id}/retry | RetryWebhookEventResult | | suppressions.add(payload, options?) | POST /suppressions | CreateSuppressionResult | | suppressions.list(options?) | GET /suppressions | CursorPage<Suppression> | | suppressions.remove(recipient, options?) | DELETE /suppressions/{recipient} | void | | senders.options(options?) | GET /sms/senders/options | SenderIdOptions | | senders.list(options?) | GET /sms/senders | SendstackList<SenderIdRequest> | | senders.create(payload, options?) | POST /sms/senders | SenderIdRequestRef | | senders.get(senderId, options?) | GET /sms/senders/{id} | SenderIdRequest | | senders.uploadKyc(senderId, payload, options?) | POST /sms/senders/{id}/kyc | SenderIdRequestRef | | senders.pay(senderId, payload, options?) | POST /sms/senders/{id}/pay | PaySenderIdResult | | senders.authorizationLetter(options?) | GET /sms/authorization-letter | unknown (file) | | billing.credits(options?) | GET /billing/credits | CreditBalance | | billing.products(options?) | GET /billing/products | SendstackList<BillingProduct> | | billing.checkout(payload, options?) | POST /billing/checkout | CheckoutResult | | billing.payments(options?) | GET /billing/payments | SendstackList<Payment> | | billing.payment(paymentId, options?) | GET /billing/payments/{id} | Payment | | billing.purchases(options?) | GET /billing/purchases | SendstackList<Purchase> |

Emails

await sendstack.emails.send({
  from: "[email protected]",
  to: ["[email protected]", "[email protected]"],
  replyTo: "[email protected]",
  subject: "Welcome",
  html: "<p>Hello</p>",
  text: "Hello",
  tags: [{ name: "campaign", value: "welcome" }],
  metadata: { account: "acct_123" },
  trackOpens: true,
  trackClicks: true,
});

Batch sends accept either an array or { emails: [...] }:

await sendstack.emails.sendBatch([
  {
    from: "[email protected]",
    to: "[email protected]",
    subject: "One",
    text: "First email",
  },
  {
    from: "[email protected]",
    to: "[email protected]",
    subject: "Two",
    text: "Second email",
  },
]);

The SDK accepts TypeScript-friendly aliases like replyTo, trackOpens, trackClicks, providerId, templateId, templateData, and scheduledAt, then sends the snake-case API fields.

Per-channel defaults

from (email), from (SMS) and from (WhatsApp) are usually constant, so set them once on the client. Each send fills the default in when the call omits it, and any per-send value overrides it:

const sendstack = new Sendstack({
  authToken: "mlr_live_…",
  emails: { from: "Noria <[email protected]>" },
  sms: { from: "NORIA" },
  whatsapp: { from: "+254711000000" },
});

await sendstack.emails.send({ to: "[email protected]", subject: "Welcome", html: "<p>Hi</p>" }); // from applied
await sendstack.sms.send({ to: "+254700000000", body: "Your code is 4821" });                       // from applied

The channel namespaces are bound methods, so you can destructure them for a terser call-site:

const { emails, sms } = new Sendstack({
  authToken: "mlr_live_…",
  emails: { from: "Noria <[email protected]>" },
  sms: { from: "NORIA" },
});

await emails.send({ to: "[email protected]", subject: "Welcome", html: "<p>Hi</p>" });
await sms.send({ to: "+254700000000", body: "Your code is 4821" });

SMS

With sms: { from } set on the client (above), a send only needs to and body; pass from on the call to override for one message:

// Uses the client default sender. Render a saved template with template_data.
await sendstack.sms.send({
  to: "+254700000000",
  templateId: "otp",
  templateData: { code: "1234" },
});

// Overrides the default for this one message.
await sendstack.sms.send({
  to: "+254700000001",
  body: "Reminder: your appointment is tomorrow.",
  from: "CLINIC",
});

Batch sends accept either an array or { messages: [...] }, and the default sender is applied per message:

await sendstack.sms.sendBatch([
  { to: "+254700000002", body: "First" },
  { to: "+254700000003", body: "Second", from: "ALERTS" },
]);

sms.list, sms.get, sms.events, sms.cancel, and sms.requeue mirror their emails.* counterparts. SMS responses include a segments count — billing is one credit per segment. The SMS request accepts the same TypeScript-friendly aliases (providerId, templateId, templateData, scheduledAt).

WhatsApp

WhatsApp is sent over the official Meta Cloud API. A send is exactly one content mode:

  • a saved templateId + templateData (business-initiated — the recommended, cross-channel-consistent way, identical to email and SMS);
  • a free-form text or media reply (deliverable only inside the 24-hour customer service window);
  • an inline template — the advanced escape hatch for a Meta-approved template you manage directly in Meta rather than mirroring in SendStack.
// Recommended: render a saved WhatsApp template — same shape as emails.send / sms.send.
// SendStack maps your named data onto the ordered parameters Meta expects.
await sendstack.whatsapp.send({
  to: "+254700000000",
  templateId: "order_update",
  templateData: { name: "A. Doe", ref: "#1042" },
});

// Free-form reply inside the 24h window (uses the client default sender).
await sendstack.whatsapp.send({ to: "+254700000000", text: "Thanks — your order is on the way." });

// Media reply.
await sendstack.whatsapp.send({
  to: "+254700000000",
  media: { type: "image", link: "https://cdn.example.com/receipt.png", caption: "Your receipt" },
});

// Advanced: reference a Meta-approved template directly (positional params, WhatsApp-only).
await sendstack.whatsapp.send({
  to: "+254700000000",
  template: { name: "order_update", language: "en_US", variables: ["A. Doe", "#1042"] },
});

Batch sends accept either an array or { messages: [...] }. whatsapp.list, whatsapp.get, whatsapp.events, whatsapp.cancel, and whatsapp.requeue mirror their emails.*/sms.* counterparts, and the request accepts the same aliases (providerId, templateId, templateData, scheduledAt). Only template (business-initiated) messages consume a credit; session replies inside the 24-hour window are free.

Register and manage the WhatsApp Business numbers you send from with whatsappSenders. The Cloud API access token is stored encrypted and never returned on reads:

const sender = await sendstack.whatsappSenders.create({
  phoneNumberId: "109876543210",
  wabaId: "220011223344",
  accessToken: "EAAG…",       // stored encrypted, never echoed back
  displayName: "Acme Support",
  isDefault: true,
});

await sendstack.whatsappSenders.list();
await sendstack.whatsappSenders.setDefault(sender.id);
await sendstack.whatsappSenders.remove(sender.id);

WhatsApp templates are created through the same templates.* methods with channel: "whatsapp", using templateName, language, and bodyVariables. Give bodyVariables named {{ placeholder }} values in Meta's parameter order (e.g. ["{{name}}", "{{ref}}"]) — those names are what templateData fills at send time, so a WhatsApp template is authored just like an email or SMS one.

Attachments

const attachment = await sendstack.attachments.upload({
  filename: "invoice.pdf",
  contentBase64: invoicePdfBase64,
  contentType: "application/pdf",
});

await sendstack.emails.send({
  from: "[email protected]",
  to: "[email protected]",
  subject: "Invoice",
  text: "Attached.",
  attachments: [
    {
      filename: "invoice.pdf",
      attachmentId: attachment.attachment_id,
    },
  ],
});

Reading from files (Node)

The core SDK is isomorphic and never touches the filesystem — html/text are plain strings and attachments are base64. For Node apps, the optional @norialabs/sendstack/node entrypoint does the read-and-encode step for you. It imports node:fs, so it lives in a separate subpath to keep the core browser/edge-safe.

import { Sendstack } from "@norialabs/sendstack";
import {
  htmlFromFile,
  textFromFile,
  attachmentFromFile,
  attachmentFromBuffer,
} from "@norialabs/sendstack/node";

await sendstack.emails.send({
  from: "[email protected]",
  to: "[email protected]",
  subject: "Your invoice",
  html: await htmlFromFile("./templates/invoice.html"),
  text: await textFromFile("./templates/invoice.txt"),
  attachments: [
    // From a path — filename defaults to the basename, content is base64-encoded.
    await attachmentFromFile("./invoices/2026-06.pdf", { contentType: "application/pdf" }),
    // From in-memory bytes (e.g. a generated PDF) — filename is required.
    attachmentFromBuffer(generatedPdf, { filename: "summary.pdf", contentType: "application/pdf" }),
  ],
});
  • htmlFromFile(path) / textFromFile(path) — read a UTF-8 file into a string.
  • attachmentFromFile(path, options?) — read a file into an EmailAttachmentInput (base64). options accepts filename (defaults to the basename), contentType, inline, and contentId. path may be a string or a file: URL.
  • attachmentFromBuffer(data, options) — encode a Buffer/Uint8Array; filename is required.

Domains

const domain = await sendstack.domains.create({
  domain: "example.com",
  region: "af-south-1",
  tls: "enforced",
  capabilities: { sending: "enabled" },
});

await sendstack.domains.verify(domain.id);

Templates

Templates are channel-aware: pass channel: "email" (the default) or channel: "sms". Email templates use subject/html/text; SMS templates use body. Filter the list with templates.list({ channel, status, limit, cursor }).

Templates start as drafts and must be published before they can send. Sends render the published snapshot, so editing a live template never changes in-flight mail until you publish again. Declared variables are typed (string/number/boolean) and may carry a fallback_value; a missing required variable with no fallback fails the send with 422.

const template = await sendstack.templates.create({
  name: "Welcome",
  slug: "welcome",
  subject: "Welcome, {{firstName}}",
  html: "<p>Hello {{firstName}}</p>",
  variables: [{ name: "firstName", type: "string", required: true }],
  publish: true, // create and publish in one call; omit to keep it a draft
});

await sendstack.emails.send({
  from: "[email protected]",
  to: "[email protected]",
  templateId: template.id,
  templateData: { firstName: "Amina" },
});

// Edit safely, then publish to go live; or clone into a new draft.
await sendstack.templates.update(template.id, { html: "<p>Welcome, {{firstName}}!</p>" });
await sendstack.templates.publish(template.id);
const copy = await sendstack.templates.duplicate(template.id, { name: "Welcome v2" });

templates.create(...) is awaitable and chainable — .publish() creates then publishes in one expression (an alternative to the publish: true flag above):

const published = await sendstack.templates
  .create({ name: "order-confirmation", subject: "Order", html: "<p>Thanks</p>" })
  .publish();

Render any template against sample data with templates.preview before sending — for SMS the preview returns the segments count so you can check cost up front:

const otp = await sendstack.templates.create({
  channel: "sms",
  name: "otp",
  body: "Your code is {{ code }}",
  sampleData: { code: "1234" },
});

const preview = await sendstack.templates.preview({
  templateId: otp.id,
  templateData: { code: "4821" },
});
// { channel: "sms", body: "Your code is 4821", segments: 1, variables: ["code"], ... }

Webhooks

const endpoint = await sendstack.webhooks.create({
  url: "https://example.com/webhooks/sendstack",
  eventTypes: ["email.sent", "email.failed"],
});

await sendstack.webhookEvents.retry("event_123");
await sendstack.webhooks.update(endpoint.id, { enabled: false });

Suppressions

await sendstack.suppressions.add({
  recipient: "[email protected]",
  reason: "manual",
});

const suppressions = await sendstack.suppressions.list();
await sendstack.suppressions.remove("[email protected]");

SMS sender IDs

senders.* drives the alphanumeric SMS sender-ID provisioning flow: read the fee, networks, and KYC requirements, file a request, upload the signed authorization letter and KYC documents, then pay the one-time fee (an M-Pesa STK push).

const opts = await sendstack.senders.options(); // fee, networks, required KYC docs per entity type

const request = await sendstack.senders.create({
  requestedId: "ACME",
  entityType: "limited_company",
  networks: ["safaricom", "airtel"],
});

await sendstack.senders.uploadKyc(request.id, {
  documents: [{ slug: "cert_of_incorporation", filename: "cert.pdf", contentBase64: certPdfBase64 }],
  authLetter: { filename: "auth.pdf", contentBase64: authPdfBase64 },
});

await sendstack.senders.pay(request.id, { phone: "+254700000000" });

await sendstack.senders.list();
await sendstack.senders.get(request.id);

senders.authorizationLetter() downloads the blank authorization-letter template (a binary body, returned as-is).

Billing

billing.* covers the credit/wallet catalog, checkout, and payment/purchase history.

const email = await sendstack.billing.credits();                 // default channel: email
const sms = await sendstack.billing.credits({ channel: "sms" }); // { remaining, unlimited, active_packs }

const products = await sendstack.billing.products();
const checkout = await sendstack.billing.checkout({ productCode: "starter_10k", phone: "+254700000000" });
// method defaults to "mpesa"; pass method: "wallet" to settle from the prepaid wallet.

const payments = await sendstack.billing.payments({ limit: 20 });
const payment = await sendstack.billing.payment("pay_123"); // polls the provider if a pending payment is stale
const purchases = await sendstack.billing.purchases();

Request Options

All methods accept request options. Mutating methods also accept idempotencyKey.

await sendstack.emails.send(
  {
    from: "[email protected]",
    to: "[email protected]",
    subject: "Hello",
    text: "Hello",
  },
  {
    idempotencyKey: "email-123",
    timeoutMs: 10_000,
    query: { debug: true },
  },
);

Supported client/request options:

  • fetch: custom Fetch implementation
  • headers: extra headers
  • query: default or per-request query params
  • timeoutMs: request timeout, default 30000
  • signal: per-request AbortSignal
  • authenticated: set false to strip auth headers for a request
  • auth: bearer or custom header auth strategy
  • retry: retry config, retry count, or false
  • middleware: request/response middleware
  • parseResponse: custom response parser
  • transformResponse: custom response transformer
  • unwrapData: unwrap { ok: true, data } envelopes, default true

Lower-Level Request

Every resource method uses request(...) internally. Use it directly for new API routes before the SDK grows a typed wrapper.

const result = await sendstack.request("GET", "/emails", {
  query: {
    limit: 25,
    status: "queued",
  },
});

Errors

Failed responses throw SendstackError.

import { SendstackError } from "@norialabs/sendstack";

try {
  await sendstack.emails.send({
    from: "[email protected]",
    to: "bad",
    subject: "Hello",
    text: "Hello",
  });
} catch (error) {
  if (error instanceof SendstackError) {
    console.error(error.statusCode, error.code, error.message, error.details);
  }
}

SendstackError includes:

  • statusCode
  • code
  • details
  • responseBody

Exports

Runtime exports:

  • Sendstack
  • SendstackClient
  • SendstackError
  • DEFAULT_BASE_URL
  • default export: Sendstack

Important type exports:

  • SendstackClientOptions
  • EmailDefaults
  • SmsDefaults
  • WhatsAppDefaults
  • SendstackRequestOptions
  • SendstackMutationOptions
  • SendstackRawRequestOptions
  • SendstackAuthStrategy
  • SendstackRetryOptions
  • SendstackMiddleware
  • SendEmailRequest
  • SendEmailResult
  • SendEmailBatchRequest
  • SendEmailBatchResult
  • EmailMessage
  • EmailEvent
  • SendSmsRequest
  • SendSmsResult
  • SendSmsBatchRequest
  • SendSmsBatchResult
  • SmsMessage
  • SmsEvent
  • SendWhatsAppRequest
  • SendWhatsAppResult
  • SendWhatsAppBatchRequest
  • SendWhatsAppBatchResult
  • WhatsAppMessage
  • WhatsAppEvent
  • WhatsAppTemplateRef
  • WhatsAppMediaRef
  • CreateWhatsAppSenderRequest
  • WhatsAppSender
  • WhatsAppSenderRef
  • UploadAttachmentRequest
  • UploadedAttachment
  • CreateDomainRequest
  • Domain
  • CreateTemplateRequest
  • UpdateTemplateRequest
  • EmailTemplate
  • PreviewTemplateRequest
  • TemplatePreview
  • TemplateVariable
  • CreateWebhookEndpointRequest
  • UpdateWebhookEndpointRequest
  • WebhookEndpoint
  • RetryWebhookEventResult
  • CreateSuppressionRequest
  • CreateSuppressionResult
  • Suppression
  • CursorPage
  • SendstackList
  • CreateSenderIdRequest
  • UploadSenderKycRequest
  • PaySenderIdRequest
  • PaySenderIdResult
  • SenderIdRequest
  • SenderIdRequestRef
  • SenderIdOptions
  • SenderIdNetwork
  • SenderEntityType
  • CreditBalance
  • CreditChannel
  • BillingProduct
  • CheckoutRequest
  • CheckoutResult
  • Payment
  • Purchase

Relationship To @norialabs/sendkit

@norialabs/sendstack is the client for the managed SendStack messaging SaaS — email, SMS, and WhatsApp all sent, tracked, and billed through the SendStack API.

Use @norialabs/sendkit when you instead want thin, direct wrappers around the underlying providers (Meta WhatsApp Cloud API, bulk SMS gateways) without the SendStack platform in between.