@norialabs/sendkit
Modular TypeScript/JavaScript SDK for WhatsApp and bulk SMS providers.
Node >=20 is required. The package is ESM-only and designed for Node.js services, workers, and serverless messaging flows.
Use sendkit when you want direct provider wrappers:
- Meta WhatsApp Cloud API
- Onfon bulk SMS
- Africa's Talking SMS
For SendStack email SaaS, use @norialabs/sendstack instead.
Install
npm install @norialabs/sendkit
Import Paths
Import the whole package:
import {
AfricasTalkingSmsClient,
MetaWhatsAppClient,
OnfonSmsClient,
} from "@norialabs/sendkit";
Or import only what you need:
import { MetaWhatsAppClient } from "@norialabs/sendkit/whatsapp";
import { OnfonSmsClient } from "@norialabs/sendkit/sms/onfon";
import { AfricasTalkingSmsClient } from "@norialabs/sendkit/sms/africastalking";
import {
requireValidMetaSignature,
resolveMetaSubscriptionChallenge,
} from "@norialabs/sendkit/webhooks";
Available subpaths:
@norialabs/sendkit@norialabs/sendkit/whatsapp@norialabs/sendkit/sms@norialabs/sendkit/sms/onfon@norialabs/sendkit/sms/africastalking@norialabs/sendkit/webhooks
Quick Start
import { MetaWhatsAppClient } from "@norialabs/sendkit/whatsapp";
const whatsapp = new MetaWhatsAppClient({
accessToken: process.env.META_WHATSAPP_ACCESS_TOKEN!,
phoneNumberId: process.env.META_WHATSAPP_PHONE_NUMBER_ID!,
});
await whatsapp.sendText({
recipient: "254700123456",
text: "Hello from Noria",
});
Onfon SMS
import { OnfonSmsClient } from "@norialabs/sendkit/sms/onfon";
const sms = new OnfonSmsClient({
accessKey: process.env.ONFON_ACCESS_KEY!,
apiKey: process.env.ONFON_API_KEY!,
clientId: process.env.ONFON_CLIENT_ID!,
defaultSenderId: "NORIALABS",
});
await sms.send({
messages: [
{ recipient: "254700123456", text: "Your OTP is 123456", reference: "otp-1" },
],
});
Africa's Talking SMS
import { AfricasTalkingSmsClient } from "@norialabs/sendkit/sms/africastalking";
const sms = new AfricasTalkingSmsClient({
apiKey: process.env.AFRICASTALKING_API_KEY!,
username: process.env.AFRICASTALKING_USERNAME!,
defaultSenderId: "NORIALABS",
});
await sms.send({
messages: [
{ recipient: "+254700123456", text: "Your OTP is 123456", reference: "otp-1" },
],
});
Provider Coverage
| Provider | Capabilities |
|---|---|
| Meta WhatsApp | Text, templates, media by ID or URL, media upload/get/delete, location, contacts, reactions, interactive buttons/lists, catalog, single product, product list, flows, mark-read, typing indicator, template management, delivery parsing, inbound parsing |
| Onfon SMS | Bulk SMS send, scheduled SMS, Unicode/flash flags, balance, groups, templates, delivery report parsing |
| Africa's Talking SMS | Bulk SMS send, premium SMS reply, incoming message fetch, subscription create/delete, balance, delivery report parsing |
Non-SMS Africa's Talking products such as Airtime, Voice, USSD, Payments, and Data Bundles are intentionally outside the current sendkit SMS scope.
Shared Transport
Every provider client supports:
fetch: custom Fetch implementationtimeoutMs: request timeoutdefaultHeaders: extra default headersretry: retry policy orfalsehooks:beforeRequest,afterResponse,onError
Per-request options:
headerssignaltimeoutMsretry
Example:
const sms = new OnfonSmsClient({
accessKey: "access-key",
apiKey: "api-key",
clientId: "client-id",
timeoutMs: 15_000,
retry: {
maxAttempts: 3,
retryMethods: ["GET", "POST"],
retryOnStatuses: [429, 500, 502, 503, 504],
retryOnNetworkError: true,
baseDelayMs: 250,
},
hooks: {
beforeRequest(context) {
context.headers.set("x-trace-id", "trace-123");
},
},
});
WhatsApp: Meta Cloud API
Construction
const whatsapp = new MetaWhatsAppClient({
accessToken: process.env.META_WHATSAPP_ACCESS_TOKEN!,
phoneNumberId: process.env.META_WHATSAPP_PHONE_NUMBER_ID!,
whatsappBusinessAccountId: process.env.META_WHATSAPP_WHATSAPP_BUSINESS_ACCOUNT_ID,
appSecret: process.env.META_WHATSAPP_APP_SECRET,
webhookVerifyToken: process.env.META_WHATSAPP_WEBHOOK_VERIFY_TOKEN,
});
whatsappBusinessAccountId is required only for template management methods.
From Env
const whatsapp = MetaWhatsAppClient.fromEnv();
Supported env vars:
META_WHATSAPP_ACCESS_TOKENMETA_WHATSAPP_PHONE_NUMBER_IDMETA_WHATSAPP_WHATSAPP_BUSINESS_ACCOUNT_IDMETA_WHATSAPP_APP_SECRETMETA_WHATSAPP_WEBHOOK_VERIFY_TOKENMETA_WHATSAPP_API_VERSIONMETA_WHATSAPP_BASE_URLMETA_WHATSAPP_TIMEOUT_SECONDS
WhatsApp Method Reference
| Method | Purpose |
|---|---|
sendText(request, options?) |
Send text messages with optional URL preview |
sendTemplate(request, options?) |
Send approved template messages, including text/media/button parameters |
sendMedia(request, options?) |
Send image, audio, document, sticker, or video by uploaded media ID or public URL |
sendLocation(request, options?) |
Send a location pin |
sendContacts(request, options?) |
Send one or more contacts |
sendReaction(request, options?) |
React to an existing message |
sendInteractive(request, options?) |
Send reply-button or list interactive messages |
sendCatalog(request, options?) |
Send catalog messages |
sendProduct(request, options?) |
Send a single-product message |
sendProductList(request, options?) |
Send a multi-product list |
sendFlow(request, options?) |
Send a WhatsApp Flow interactive message |
markMessageRead(request, options?) |
Mark an inbound message as read |
sendTypingIndicator(request, options?) |
Mark an inbound message as read and show a typing indicator |
uploadMedia(request, options?) |
Upload media bytes to Meta |
getMedia(mediaId, options?) |
Get media metadata and download URL |
deleteMedia(mediaId, options?) |
Delete uploaded media |
listTemplates(request?, options?) |
List templates for a WABA |
getTemplate(templateId, fields?, options?) |
Fetch one template |
createTemplate(request, options?) |
Create a template |
updateTemplate(templateId, request, options?) |
Update a template |
deleteTemplate(request, options?) |
Delete a template by name, ID, or IDs |
parseEvents(payload) |
Parse delivery/read/failed webhook statuses |
parseInboundMessages(payload) |
Parse inbound messages |
parseEvent(payload) |
Return the first parsed delivery event or null |
parseInboundMessage(payload) |
Return the first parsed inbound message or null |
Text
await whatsapp.sendText({
recipient: "254700123456",
text: "Plain text message",
previewUrl: true,
replyToMessageId: "wamid.previous",
});
Templates
Template messages support text, media, and button parameters. For media template headers, pass a parameter of type image, video, or document and either provide the provider-specific object through providerOptions, or pass value to use an uploaded media ID.
await whatsapp.sendTemplate({
recipient: "254700123456",
templateName: "order_update",
languageCode: "en",
components: [
{
type: "header",
parameters: [
{
type: "document",
providerOptions: {
document: {
id: "media-id",
filename: "invoice.pdf",
},
},
},
],
},
{
type: "body",
parameters: [
{ type: "text", value: "NORIA-123" },
{ type: "text", value: "Ready for pickup" },
],
},
{
type: "button",
subType: "quick_reply",
index: 0,
parameters: [{ type: "payload", value: "track-order" }],
},
],
});
Media And Attachments
Meta WhatsApp calls these files βmediaβ. You can send media by public URL, send media by uploaded ID, or upload bytes first and then use the returned media ID.
await whatsapp.sendMedia({
recipient: "254700123456",
mediaType: "image",
link: "https://example.com/product.jpg",
caption: "Preview",
});
const uploaded = await whatsapp.uploadMedia({
filename: "menu.pdf",
mimeType: "application/pdf",
content: Buffer.from("file-bytes"),
});
await whatsapp.sendMedia({
recipient: "254700123456",
mediaType: "document",
mediaId: uploaded.mediaId,
filename: "menu.pdf",
});
await whatsapp.getMedia(uploaded.mediaId);
await whatsapp.deleteMedia(uploaded.mediaId);
Supported media types:
imageaudiodocumentstickervideo
Location, Contacts, And Reactions
await whatsapp.sendLocation({
recipient: "254700123456",
latitude: -1.286389,
longitude: 36.817223,
name: "Nairobi Office",
address: "Nairobi, Kenya",
});
await whatsapp.sendContacts({
recipient: "254700123456",
contacts: [
{
name: { formattedName: "Noria Support", firstName: "Noria" },
phones: [{ phone: "+254700000000", type: "WORK" }],
emails: [{ email: "support@example.com", type: "WORK" }],
},
],
});
await whatsapp.sendReaction({
recipient: "254700123456",
messageId: "wamid.inbound",
emoji: "π",
});
Interactive, Catalog, Product, And Flow Messages
await whatsapp.sendInteractive({
recipient: "254700123456",
interactiveType: "button",
bodyText: "Choose one",
buttons: [
{ identifier: "yes", title: "Yes" },
{ identifier: "no", title: "No" },
],
});
await whatsapp.sendInteractive({
recipient: "254700123456",
interactiveType: "list",
bodyText: "Choose a product",
buttonText: "View options",
sections: [
{
title: "Products",
rows: [
{ identifier: "sku-1", title: "Starter" },
{ identifier: "sku-2", title: "Pro" },
],
},
],
});
await whatsapp.sendCatalog({
recipient: "254700123456",
bodyText: "Browse our catalog",
});
await whatsapp.sendProduct({
recipient: "254700123456",
catalogId: "catalog-1",
productRetailerId: "sku-1",
});
await whatsapp.sendProductList({
recipient: "254700123456",
catalogId: "catalog-1",
header: { type: "text", text: "Featured" },
sections: [
{
title: "Top Picks",
productItems: [{ productRetailerId: "sku-1" }],
},
],
});
await whatsapp.sendFlow({
recipient: "254700123456",
flowCta: "Start",
flowId: "flow-1",
flowAction: "navigate",
});
Read Receipts And Typing Indicator
await whatsapp.markMessageRead({
messageId: "wamid.inbound",
});
await whatsapp.sendTypingIndicator({
messageId: "wamid.inbound",
});
Template Management
const list = await whatsapp.listTemplates({
limit: 20,
status: ["approved"],
fields: ["name", "status", "category", "language", "components"],
});
await whatsapp.getTemplate("template-id");
await whatsapp.createTemplate({
name: "order_update",
language: "en_US",
category: "utility",
components: [
{
type: "body",
text: "Order {{1}} is ready",
example: {
body_text: [["NORIA-123"]],
},
},
],
});
await whatsapp.updateTemplate("template-id", {
category: "utility",
});
await whatsapp.deleteTemplate({ templateId: "template-id" });
WhatsApp Webhooks
const deliveryEvents = whatsapp.parseEvents(metaWebhookPayload);
const inboundMessages = whatsapp.parseInboundMessages(metaWebhookPayload);
parseInboundMessages supports inbound text, media, location, contacts, button replies, interactive replies, reactions, and unsupported message fallback metadata.
SMS: Shared Types
All SMS providers use the shared send shape:
await sms.send({
senderId: "NORIALABS",
messages: [
{
recipient: "254700123456",
text: "Hello",
reference: "internal-id",
metadata: { accountId: "acct_1" },
},
],
scheduleAt: new Date("2026-06-26T09:00:00.000Z"),
isUnicode: false,
isFlash: false,
providerOptions: {},
});
providerOptions is passed through to the underlying provider payload when you need provider-specific fields.
SMS: Onfon
Construction
const sms = new OnfonSmsClient({
accessKey: process.env.ONFON_ACCESS_KEY!,
apiKey: process.env.ONFON_API_KEY!,
clientId: process.env.ONFON_CLIENT_ID!,
defaultSenderId: "NORIALABS",
});
From Env
const sms = OnfonSmsClient.fromEnv();
Supported env vars:
ONFON_ACCESS_KEYONFON_API_KEYONFON_CLIENT_IDONFON_SENDER_IDONFON_BASE_URLONFON_TIMEOUT_SECONDS
Onfon Method Reference
| Method | Purpose |
|---|---|
send(request, options?) |
Send one or more SMS messages |
getBalance(options?) |
Read SMS balance |
listGroups(options?) |
List contact groups |
createGroup(request, options?) |
Create a contact group |
updateGroup(groupId, request, options?) |
Update a contact group |
deleteGroup(groupId, options?) |
Delete a contact group |
listTemplates(options?) |
List SMS templates |
createTemplate(request, options?) |
Create an SMS template |
updateTemplate(templateId, request, options?) |
Update an SMS template |
deleteTemplate(templateId, options?) |
Delete an SMS template |
parseDeliveryReport(payload) |
Parse delivery-report callbacks |
Onfon Examples
const result = await sms.send({
senderId: "NORIALABS",
messages: [
{ recipient: "254700123456", text: "Hello there", reference: "msg-1" },
{ recipient: "254711111111", text: "Hello again", reference: "msg-2" },
],
isUnicode: false,
});
await sms.getBalance();
const group = await sms.createGroup({ name: "VIP Customers" });
await sms.updateGroup(group.resourceId!, { name: "Priority Customers" });
await sms.deleteGroup(group.resourceId!);
const template = await sms.createTemplate({
name: "otp",
body: "Your OTP is {{1}}",
});
await sms.updateTemplate(template.resourceId!, {
name: "otp",
body: "Use code {{1}}",
});
await sms.deleteTemplate(template.resourceId!);
const report = sms.parseDeliveryReport({
messageId: "abc123",
mobile: "254700123456",
status: "Delivered",
});
SMS: Africa's Talking
Construction
const sms = new AfricasTalkingSmsClient({
apiKey: process.env.AFRICASTALKING_API_KEY!,
username: process.env.AFRICASTALKING_USERNAME!,
defaultSenderId: "NORIALABS",
});
Use AFRICASTALKING_SANDBOX_SMS_BASE_URL for sandbox clients:
const sandboxSms = new AfricasTalkingSmsClient({
apiKey: process.env.AFRICASTALKING_API_KEY!,
username: "sandbox",
baseUrl: AFRICASTALKING_SANDBOX_SMS_BASE_URL,
});
From Env
const sms = AfricasTalkingSmsClient.fromEnv();
Supported env vars:
AFRICASTALKING_API_KEYAFRICASTALKING_USERNAMEAFRICASTALKING_SENDER_IDAFRICASTALKING_BASE_URLAFRICASTALKING_TIMEOUT_SECONDS
Fallback env vars are also accepted:
AFRICAS_TALKING_API_KEYAFRICAS_TALKING_USERNAMEAFRICAS_TALKING_SENDER_IDAFRICAS_TALKING_BASE_URL
Africa's Talking Method Reference
| Method | Purpose |
|---|---|
send(request, options?) |
Send normal bulk SMS |
sendPremium(request, options?) |
Send premium SMS replies using keyword and link ID |
fetchMessages(request?, options?) |
Fetch incoming SMS messages |
createSubscription(request, options?) |
Opt a phone number into a premium SMS subscription |
deleteSubscription(request, options?) |
Remove a premium SMS subscription |
getBalance(options?) |
Read account balance |
parseDeliveryReport(payload) |
Parse delivery-report callbacks |
Normal SMS
await sms.send({
senderId: "NORIALABS",
messages: [
{ recipient: "+254700123456", text: "Hello", reference: "msg-1" },
{ recipient: "+254711111111", text: "Hello", reference: "msg-2" },
],
providerOptions: {
enqueue: "1",
},
});
The client groups messages by text because Africa's Talking accepts one message body per request and many recipients.
Premium SMS
await sms.sendPremium({
recipient: "+254700123456",
shortCode: "22384",
keyword: "NORIA",
linkId: "link-id-from-inbound-message",
text: "Thanks for subscribing",
retryDurationInHours: 2,
});
Incoming Messages
const inbox = await sms.fetchMessages({
lastReceivedId: 42,
});
for (const message of inbox.messages) {
console.log(message.providerMessageId, message.sender, message.text);
}
Premium Subscriptions
await sms.createSubscription({
phoneNumber: "+254700123456",
shortCode: "22384",
keyword: "NORIA",
});
await sms.deleteSubscription({
phoneNumber: "+254700123456",
shortCode: "22384",
keyword: "NORIA",
});
Balance And Delivery Reports
const balance = await sms.getBalance();
const event = sms.parseDeliveryReport({
id: "at-message-id",
phoneNumber: "+254700123456",
status: "Success",
networkCode: "63902",
retryCount: "0",
});
Webhooks
Meta Verification Challenge
const challenge = resolveMetaSubscriptionChallenge(
{
"hub.mode": "subscribe",
"hub.verify_token": "verify-me",
"hub.challenge": "12345",
},
"verify-me",
);
Meta Signature Verification
import { requireValidMetaSignature } from "@norialabs/sendkit/webhooks";
requireValidMetaSignature(rawBody, req.headers["x-hub-signature-256"], appSecret);
SMS Delivery Reports
import {
parseAfricasTalkingSmsDeliveryReport,
parseOnfonDeliveryReport,
} from "@norialabs/sendkit/webhooks";
const onfonEvent = parseOnfonDeliveryReport(req.query, onfonClient);
const atEvent = parseAfricasTalkingSmsDeliveryReport(req.body, africasTalkingClient);
Errors
Important exported errors:
SendKitErrorConfigurationErrorApiErrorProviderErrorNetworkErrorTimeoutErrorWebhookVerificationError
Provider errors include provider response details where available.
import { ProviderError } from "@norialabs/sendkit";
try {
await sms.send({ messages: [] });
} catch (error) {
if (error instanceof ProviderError) {
console.error(error.provider, error.errorCode, error.responseBody);
}
}
Runtime Exports
Root exports:
OnfonSmsClientAfricasTalkingSmsClientMetaWhatsAppClientONFON_BASE_URLONFON_SMS_BASE_URLAFRICASTALKING_SMS_BASE_URLAFRICASTALKING_SANDBOX_SMS_BASE_URLMETA_GRAPH_BASE_URLMETA_GRAPH_API_VERSIONparseAfricasTalkingDeliveryReportparseOnfonDeliveryReportparseAfricasTalkingSmsDeliveryReportresolveMetaSubscriptionChallengeverifyMetaSignaturerequireValidMetaSignature- all exported error classes
Provider-specific subpaths export the same provider classes and related types for that provider.
Important Type Exports
SMS:
SmsClientSmsManagementClientSmsSendRequestSmsSendResultSmsSendReceiptSmsMessageSmsBalanceSmsGroupSmsTemplateAfricasTalkingPremiumSmsRequestAfricasTalkingFetchMessagesResultAfricasTalkingIncomingMessageAfricasTalkingSubscriptionRequestAfricasTalkingSubscriptionResult
WhatsApp:
WhatsAppClientWhatsAppTemplateManagementClientWhatsAppTextRequestWhatsAppTemplateRequestWhatsAppMediaRequestWhatsAppLocationRequestWhatsAppContactsRequestWhatsAppReactionRequestWhatsAppInteractiveRequestWhatsAppCatalogMessageRequestWhatsAppProductMessageRequestWhatsAppProductListRequestWhatsAppFlowMessageRequestWhatsAppReadRequestWhatsAppStatusResultWhatsAppMediaUploadRequestWhatsAppMediaUploadResultWhatsAppManagedTemplateWhatsAppInboundMessageWhatsAppSendResult
Core:
RequestOptionsRetryPolicyHooksDeliveryEventDeliveryStateMessageChannel
Versioning And Publish Notes
The package publishes only built JavaScript, declaration files, and this package guide. The publish build uses build:dist, which does not emit sourcemaps.