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

@ffembi-labs/tuma

v1.0.0

Published

Unified SMS and Email API for Node/Bun apps. One interface, many providers.

Readme

tuma

Tumasend in Luganda.


Unified SMS & Email API for Node/Bun apps. One interface, many providers — bring your own keys.

This package does one thing and one thing well: sending messages (SMS & Email) through multiple Ugandan/East African providers with a single, normalized API. It also exposes balance checking where the provider supports it, so you can monitor spend without leaving your codebase.

Part of the Ffembi Labs OSS ecosystem.


Table of Contents


Why tuma?

Every messaging provider has a different API shape, authentication scheme, error format, and bulk strategy. Instead of learning a new SDK for every provider:

  • Consistent tuma.sms() and tuma.email() API — works identically across all providers.
  • Normalized results — every provider returns the same SmsResult or EmailResult shape.
  • Automatic bulk handling — if an SMS provider supports native bulk, tuma uses it. If not, it falls back to parallel individual sends.
  • Balance checking — monitor credit without switching dashboards.
  • Swap providers without touching app code — change one constructor argument, everything else stays the same.

Install

# Bun (recommended)
bun add tuma

# npm
npm install tuma

# pnpm
pnpm add tuma

Requires fetch (global). Works out of the box in Bun, Node 18+, Deno, and modern edge runtimes.


Quick Start

import { Tuma, providers } from "@ffembi-labs/tuma";

const tuma = new Tuma({
  smsProvider: providers.africastalking({
    apiKey: process.env.AT_API_KEY!,
    username: process.env.AT_USERNAME!,
    senderId: "YourApp",
    sandbox: process.env.NODE_ENV !== "production",
  }),
  emailProvider: providers.resend({
    apiKey: process.env.RESEND_API_KEY!,
    defaultFrom: "Acme <[email protected]>",
  }),
});

// Send an SMS
const smsResult = await tuma.sms({
  to: "+256700000000",
  message: "Your login code is 1234",
});

console.log(smsResult.success); // true
console.log(smsResult.provider); // "africastalking"
console.log(smsResult.recipients[0].status); // "sent"

// Send an Email
const emailResult = await tuma.email({
  to: "[email protected]",
  subject: "Welcome to Acme!",
  html: "<p>Thank you for joining Acme.</p>",
});

console.log(emailResult.success); // true
console.log(emailResult.provider); // "resend"

Providers

Africa's Talking (SMS)

Africa's Talking

import { providers } from "@ffembi-labs/tuma";

const provider = providers.africastalking({
  apiKey: "your-at-api-key",
  username: "your-at-username",
  senderId: "YourApp", // optional — registered sender ID
  sandbox: false, // true for sandbox (free test credits)
  enqueue: false, // true to queue large bulk sends server-side
});

Key features:

  • ✅ Native bulk (supportsBulk: true) — sends up to 100 numbers in a single HTTP call.
  • ✅ Balance checking via GET /version1/user.
  • enqueue: true for campaigns > 100 recipients (queues on Africa's Talking side).
  • ✅ Sandbox mode for integration testing.
  • ⚠️ senderId must be pre-registered with Africa's Talking.

Cironet Messaging (SMS)

Cironet Messaging

import { providers } from "@ffembi-labs/tuma";

const provider = providers.cironet({
  apiKey: "your-cironet-api-key",
  sender: "YourApp", // required — sender ID
});

Key features:

  • ❌ No native bulk (supportsBulk: false). Tuma falls back to parallel individual send() calls.
  • ❌ No balance check endpoint exposed in public docs.
  • ✅ Simple form-urlencoded API.

EgoSMS (SMS)

EgoSMS

import { providers } from "@ffembi-labs/tuma";

const provider = providers.egosms({
  username: "your-egosms-username",
  password: "your-egosms-password", // API key
  senderId: "YourApp", // optional — defaults to "EgoSMS"
  priority: "0", // "0" = normal, "1" = high
});

Key features:

  • ✅ Native bulk (supportsBulk: true) — sends multiple recipients in one msgdata array.
  • ✅ Balance checking via GetBalance method.
  • ✅ Returns per-send Cost and MsgFollowUpUniqueCode (message ID).

MarzSMS (SMS)

import { providers } from "@ffembi-labs/tuma";

const provider = providers.marz({
  apiKey: "your-marz-api-key",
  apiSecret: "your-marz-api-secret",
});

// Check wallet balance
const balance = await provider.checkBalance();

// Initiate Mobile Money top-up / account reload
const reload = await provider.reloadAccount({
  amount: 20000,
  phoneNumber: "+256700000000",
  description: "Wallet top-up via API",
});

Key features:

  • ✅ Native bulk (supportsBulk: true) — accepts comma-separated recipient numbers in one request.
  • ✅ Balance checking via GET /api/v1/account/balance.
  • ✅ Account reload / Mobile Money top-up via reloadAccount() (POST /api/v1/account/topup).
  • ✅ Returns per-recipient status, cost, and transaction/message tracking IDs.

Resend (Email)

Resend

import { providers } from "@ffembi-labs/tuma";

const provider = providers.resend({
  apiKey: "re_123456789", // Resend API key
  defaultFrom: "Acme <[email protected]>", // optional default sender address
});

Key features:

  • ✅ Supports HTML, plain text, CC, BCC, Reply-To, custom headers, and attachments.
  • ✅ Supports tag-based tracking and scheduling (scheduledAt).
  • ✅ Returns unique Resend email id message tracking ID.

Sending SMS

Single recipient

const result = await tuma.sms({
  to: "+256700000000",
  message: "Hello world",
  from: "YourApp", // optional — overrides provider default senderId
});

Multiple recipients (bulk)

const result = await tuma.sms({
  to: ["+256700000001", "+256700000002", "+256700000003"],
  message: "Hello everyone",
});

When to is an array, tuma decides the strategy:

| Provider | Strategy | HTTP calls | | ---------------- | ------------------------------ | --------------------- | | Africa's Talking | Native bulk (sendBulk) | 1 per 100 recipients | | EgoSMS | Native bulk (sendBulk) | 1 | | Cironet | Parallel fallback (send × N) | N (one per recipient) | | MarzSMS | Native bulk (sendBulk) | 1 |

Batching large lists:

Africa's Talking limits bulk to ~100 numbers per request. If you pass more than 100, the provider itself may reject the request. For very large campaigns, consider chunking in your app or using enqueue: true:

const provider = providers.africastalking({
  apiKey: process.env.AT_API_KEY!,
  username: process.env.AT_USERNAME!,
  enqueue: true, // AT queues the campaign internally
});

Rate limiting:

Tuma does not implement client-side rate limiting. If you are sending thousands of messages through Cironet (fallback bulk), you may hit provider rate limits. Consider adding a p-limit or bottleneck wrapper in your app:

import pLimit from "p-limit";

const limit = pLimit(10); // max 10 concurrent sends
const results = await Promise.all(
  recipients.map((to) => limit(() => tuma.sms({ to, message }))),
);

The from / senderId field

The from field in tuma.sms() is optional and overrides the provider's default sender ID:

// Uses provider default (e.g. "YourApp" from config)
await tuma.sms({ to: "+256700000000", message: "Hello" });

// Overrides for this message only
await tuma.sms({
  to: "+256700000000",
  message: "Hello",
  from: "SupportTeam",
});

Not all providers support per-message sender ID overrides. Africa's Talking and Cironet respect it. EgoSMS uses the config senderId as the default but the from parameter is passed through the msgdata array.


Sending Email

Basic usage (tuma.email())

Pass an emailProvider to the Tuma constructor and call tuma.email():

import { Tuma, providers } from "@ffembi-labs/tuma";

const tuma = new Tuma({
  emailProvider: providers.resend({
    apiKey: process.env.RESEND_API_KEY!,
    defaultFrom: "Acme <[email protected]>",
  }),
});

const result = await tuma.email({
  to: "[email protected]",
  subject: "Welcome to Acme",
  html: "<h1>Welcome!</h1><p>We are glad to have you.</p>",
});

console.log(result.success); // true
console.log(result.messageId); // "4ef9a417-02e9-4d39-ad75-9611e0fcc33c"

Advanced email options

tuma.email() supports full transactional options:

const result = await tuma.email({
  from: "Support <[email protected]>",
  to: ["[email protected]", "[email protected]"],
  subject: "Quarterly Report",
  text: "Attached is the quarterly report.",
  html: "<p>Attached is the <strong>quarterly report</strong>.</p>",
  cc: ["[email protected]"],
  bcc: ["[email protected]"],
  replyTo: "[email protected]",
  headers: { "X-Entity-Ref-ID": "12345" },
  tags: [{ name: "category", value: "financial_report" }],
  attachments: [
    {
      filename: "report.pdf",
      content: "base64content...",
      contentType: "application/pdf",
    },
  ],
});

Checking Balance & Account Reload

Check your provider account balance without leaving your code:

const balance = await tuma.checkBalance();

if (balance.success) {
  console.log(`${balance.currency} ${balance.balance}`);
  // → "UGX 12345.67"
} else {
  console.error("Could not fetch balance");
}

Provider support:

| Provider | Balance check | Mobile Money Reload | Notes | | ---------------- | ------------- | -------------------- | ----------------------------------------------------------------- | | Africa's Talking | ✅ | ❌ | Returns UGX 12345.67 format; parsed into currency + balance | | EgoSMS | ✅ | ❌ | Uses GetBalance JSON method | | MarzSMS | ✅ | ✅ (reloadAccount) | Uses GET /account/balance & POST /account/topup | | Cironet | ❌ | ❌ | No public balance endpoint documented |

Mobile Money Account Reload (MarzSMS)

Providers like MarzSMS support direct account reloads via Mobile Money API:

const marzProvider = providers.marz({
  apiKey: process.env.MARZ_API_KEY!,
  apiSecret: process.env.MARZ_API_SECRET!,
});

// Initiate a Mobile Money top-up request
const reload = await marzProvider.reloadAccount({
  amount: 20000,
  phoneNumber: "+256700000000",
  description: "Wallet reload for SMS campaigns",
});

console.log(reload.success); // true
console.log(reload.transactionId); // "550e8400-e29b-41d4-a716-446655440000"

If you call checkBalance() on a provider that does not support it, tuma throws:

Error: Provider "cironet" does not support balance checks.

Result Shape

Every provider returns the same shape, so your app code never branches on provider:

interface SmsResult {
  success: boolean; // true ONLY if ALL recipients have status "sent"
  provider: string; // e.g. "africastalking", "cironet", "egosms"
  recipients: {
    number: string; // the phone number as passed in
    status: "sent" | "failed" | "unknown";
    providerStatus?: string; // raw status string from the provider (e.g. "Success", "InsufficientBalance")
    cost?: string; // per-recipient cost, if provider returns it
    messageId?: string; // provider tracking ID
    raw?: unknown; // untouched provider response for this recipient
  }[];
  raw?: unknown; // full untouched provider response
}

Important: success: true means every recipient was successfully sent. If 99 out of 100 succeed, success is false and you can inspect recipients[i].status to find the failure.

const result = await tuma.sms({ to: ["a", "b"], message: "Hi" });

if (!result.success) {
  const failures = result.recipients.filter((r) => r.status === "failed");
  console.error(
    "Failed numbers:",
    failures.map((f) => f.number),
  );
}

Error Handling

Tuma distinguishes between provider errors (returned in SmsResult or EmailResult) and runtime errors (thrown as exceptions):

Thrown errors (catch with try/catch)

| Scenario | Error message | | ------------------------------------- | ---------------------------------------------------------------------------- | | No SMS provider configured | No SMS provider configured — pass smsProvider to the Tuma constructor. | | No Email provider configured | No Email provider configured — pass emailProvider to the Tuma constructor. | | Empty recipients | No recipients provided. | | Empty message | Message body cannot be empty. | | Empty email subject | Email subject cannot be empty. | | Empty email content | Email content must include either text or html. | | Balance check on unsupported provider | Provider "X" does not support balance checks. |

Provider errors (returned in result)

These do not throw. They appear in result.recipients[i].status === "failed":

  • HTTP 4xx/5xx responses
  • Authentication failures
  • Insufficient balance
  • Invalid phone numbers or email addresses
  • Malformed provider responses

Always check result.success before assuming delivery:

try {
  const result = await tuma.sms({ to: "+256700000000", message: "Hello" });
  if (!result.success) {
    // Handle provider-level failure
    console.error(result.recipients[0].providerStatus);
  }
} catch (err) {
  // Handle runtime / configuration errors
  console.error("Send failed:", err.message);
}

TypeScript

Tuma is written in TypeScript and exports all types:

import type {
  SmsProvider,
  SmsMessage,
  SmsResult,
  SmsRecipientResult,
  EmailProvider,
  EmailMessage,
  EmailResult,
  EmailRecipientResult,
  BalanceResult,
  TumaConfig,
  AfricasTalkingConfig,
  CironetConfig,
  EgoSmsConfig,
  ResendConfig,
} from "@ffembi-labs/tuma";

You can also build your own provider by implementing SmsProvider or EmailProvider:

import type { SmsProvider } from "@ffembi-labs/tuma";

const myProvider: SmsProvider = {
  name: "custom",
  supportsBulk: false,
  async send(to, message, from) {
    // Your custom logic
    return { number: to, status: "sent" };
  },
  async checkBalance() {
    return { success: true, provider: "custom", balance: "999" };
  },
};

const tuma = new Tuma({ smsProvider: myProvider });

Testing

Run the test suite with Bun:

bun test

The test suite covers:

  • Single & Bulk SMS sending (tuma.sms())
  • Email sending (tuma.email()) via Resend
  • Native bulk send vs fallback bulk behavior
  • Balance checking
  • Non-JSON / HTML error responses
  • Phone number normalization (+ stripping)
  • Input validation (empty recipients, messages, subject, content)
  • Provider capability detection (supportsBulk, checkBalance)

Mocking in your own tests

If you want to test your app without hitting real APIs, mock the provider:

import { Tuma } from "@ffembi-labs/tuma";
import type { SmsProvider } from "@ffembi-labs/tuma";

const mockProvider: SmsProvider = {
  name: "mock",
  supportsBulk: true,
  async send(to, message, from) {
    return { number: to, status: "sent", providerStatus: "mock-ok" };
  },
  async sendBulk(to, message, from) {
    return to.map((n) => ({
      number: n,
      status: "sent",
      providerStatus: "mock-bulk",
    }));
  },
};

const tuma = new Tuma({ smsProvider: mockProvider });

Environment Variables

Recommended .env structure:

# Africa's Talking
AT_API_KEY=your-api-key
AT_USERNAME=your-username
AT_SENDER_ID=YourApp

# Cironet
CIRONET_API_KEY=your-api-key
CIRONET_SENDER=YourApp

# EgoSMS
EGOSMS_USERNAME=your-username
EGOSMS_PASSWORD=your-password
EGOSMS_SENDER_ID=YourApp

# MarzSMS
MARZ_API_KEY=sk_your_api_key
MARZ_API_SECRET=sec_your_api_secret

# Resend (Email)
RESEND_API_KEY=re_123456789
RESEND_DEFAULT_FROM="Tuma <[email protected]>"

Provider Comparison

| Provider | Type | Native bulk | Balance check | Mobile Money Reload | Auth | | ---------------- | ----- | -------------------- | ------------- | -------------------- | ---------------------------------- | | Africa's Talking | SMS | ✅ (up to ~100/req) | ✅ | ❌ | API key + username | | Cironet | SMS | ❌ | ❌ | ❌ | API key | | EgoSMS | SMS | ✅ | ✅ | ❌ | Username + password | | MarzSMS | SMS | ✅ (comma-separated) | ✅ | ✅ (reloadAccount) | HTTP Basic Auth (API Key + Secret) | | Resend | Email | ✅ (Batch API) | ❌ | ❌ | Bearer API Key |


Roadmap

  • [x] Email provider support (Resend)
  • [ ] Delivery receipt webhooks
  • [ ] Retry with exponential backoff
  • [ ] SMS scheduling / delayed send
  • [ ] Message template support

License

MIT © Ffembi