@ffembi-labs/tuma
v1.0.0
Published
Unified SMS and Email API for Node/Bun apps. One interface, many providers.
Maintainers
Readme
tuma
Tuma — send 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?
- Install
- Quick Start
- Providers
- Sending SMS
- Sending Email
- Checking Balance & Account Reload
- Result Shape
- Error Handling
- TypeScript
- Testing
- Environment Variables
- Provider Comparison
- Roadmap
- License
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()andtuma.email()API — works identically across all providers. - Normalized results — every provider returns the same
SmsResultorEmailResultshape. - 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 tumaRequires 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)
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: truefor campaigns > 100 recipients (queues on Africa's Talking side). - ✅ Sandbox mode for integration testing.
- ⚠️
senderIdmust be pre-registered with Africa's Talking.
Cironet Messaging (SMS)
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 individualsend()calls. - ❌ No balance check endpoint exposed in public docs.
- ✅ Simple form-urlencoded API.
EgoSMS (SMS)
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 onemsgdataarray. - ✅ Balance checking via
GetBalancemethod. - ✅ Returns per-send
CostandMsgFollowUpUniqueCode(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)
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
idmessage 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 testThe 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
