@norialabs/sendstack
v0.1.12
Published
Official JavaScript SDK for the SendStack email SaaS API.
Maintainers
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/sendstackQuick 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 appliedThe 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 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
textormediareply (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 anEmailAttachmentInput(base64).optionsacceptsfilename(defaults to the basename),contentType,inline, andcontentId.pathmay be a string or afile:URL.attachmentFromBuffer(data, options)— encode aBuffer/Uint8Array;filenameis 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 implementationheaders: extra headersquery: default or per-request query paramstimeoutMs: request timeout, default30000signal: per-requestAbortSignalauthenticated: setfalseto strip auth headers for a requestauth: bearer or custom header auth strategyretry: retry config, retry count, orfalsemiddleware: request/response middlewareparseResponse: custom response parsertransformResponse: custom response transformerunwrapData: unwrap{ ok: true, data }envelopes, defaulttrue
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:
statusCodecodedetailsresponseBody
Exports
Runtime exports:
SendstackSendstackClientSendstackErrorDEFAULT_BASE_URL- default export:
Sendstack
Important type exports:
SendstackClientOptionsEmailDefaultsSmsDefaultsWhatsAppDefaultsSendstackRequestOptionsSendstackMutationOptionsSendstackRawRequestOptionsSendstackAuthStrategySendstackRetryOptionsSendstackMiddlewareSendEmailRequestSendEmailResultSendEmailBatchRequestSendEmailBatchResultEmailMessageEmailEventSendSmsRequestSendSmsResultSendSmsBatchRequestSendSmsBatchResultSmsMessageSmsEventSendWhatsAppRequestSendWhatsAppResultSendWhatsAppBatchRequestSendWhatsAppBatchResultWhatsAppMessageWhatsAppEventWhatsAppTemplateRefWhatsAppMediaRefCreateWhatsAppSenderRequestWhatsAppSenderWhatsAppSenderRefUploadAttachmentRequestUploadedAttachmentCreateDomainRequestDomainCreateTemplateRequestUpdateTemplateRequestEmailTemplatePreviewTemplateRequestTemplatePreviewTemplateVariableCreateWebhookEndpointRequestUpdateWebhookEndpointRequestWebhookEndpointRetryWebhookEventResultCreateSuppressionRequestCreateSuppressionResultSuppressionCursorPageSendstackListCreateSenderIdRequestUploadSenderKycRequestPaySenderIdRequestPaySenderIdResultSenderIdRequestSenderIdRequestRefSenderIdOptionsSenderIdNetworkSenderEntityTypeCreditBalanceCreditChannelBillingProductCheckoutRequestCheckoutResultPaymentPurchase
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.
