SendAfrica TypeScript SDK
Official TypeScript/JavaScript client for the SendAfrica SMS Infrastructure-as-a-Service API. Designed to feel like Stripe's Node library: simple for a first integration, enough control for production use.
What You Get
- Fail-fast validation -- phone numbers are normalized to E.164 and validated locally before any network call. Bad input never hits the wire.
- Automatic retries -- exponential backoff on 429/5xx/connection errors. No retry loops in your code.
- Phone number normalization -- pass
0712345678,+255 712 345 678, or255712345678and it all works. The SDK handles the conversion. - SMS cost estimation --
getSmsPartInfo()tells you encoding (GSM-7 vs UCS-2), segment count, and credit cost with zero network calls. - Typed errors -- every error is an
instanceof-discriminable class with.code,.httpStatus, and.requestId. Quick discrimination via.isInsufficientCredits,.isRateLimited,.isUnauthorizedgetters. - Idempotent sends -- pass an
idempotencyKeyto safely retry network failures without duplicating messages. - Zero runtime dependencies -- only
fetch(Node 18+, or inject your own). - Dual CJS + ESM +
.d.ts-- works withrequire,import, and full TypeScript type inference.
Install
npm install sendafrica
yarn add sendafrica
pnpm add sendafrica
Requirements: Node 18+ (uses global fetch). For Node < 16, inject a
custom fetch implementation.
Quickstart
import { SendAfricaClient } from 'sendafrica';
const client = new SendAfricaClient({
apiKey: process.env.SENDAFRICA_API_KEY!, // starts with "SA-"
});
const result = await client.sendSms({
to: '0712345678',
message: 'Your order has been confirmed.',
});
console.log(result.messageId, result.creditsUsed);
CommonJS:
const { SendAfricaClient } = require('sendafrica');
Authentication
The SDK requires an API key. Pass it directly to the constructor:
const client = new SendAfricaClient({ apiKey: 'SA-xxxxx' });
Every request includes an X-API-Key: <key> header. The SDK also sends a
User-Agent: sendafrica-node/1.0 header and a unique X-Request-Id UUID
per request for tracing.
Note: Unlike the Python SDK, the TypeScript SDK does not auto-resolve from
SENDAFRICA_API_KEY. You must passapiKeyexplicitly or read the env var yourself.
Configuration
const client = new SendAfricaClient({
apiKey: 'SA-xxxxx',
baseUrl: 'https://api.sendafrica.online', // default
timeoutMs: 15_000, // per-request timeout, default 15s
maxRetries: 2, // retries on 429/5xx/network errors
fetch: myCustomFetch, // inject a custom fetch implementation
});
| Parameter | Default | Description |
|---|---|---|
apiKey |
-- (required) | Your SendAfrica API key |
baseUrl |
"https://api.sendafrica.online" |
Override for testing |
timeoutMs |
15000 |
Per-request timeout in milliseconds |
maxRetries |
2 |
Retries on 429/5xx/network errors. Set 0 to disable |
fetch |
globalThis.fetch |
Inject a custom fetch (e.g. node-fetch on Node < 18) |
Resources
| Resource | Methods |
|---|---|
client.sendSms() |
Send a single SMS |
client.getBalance() |
Check credit balance |
client.getVoucherRate() |
Fetch pricing tiers |
client.createVoucher() |
Top up via mobile money |
client.getCreditHistory() |
List credit transactions |
client.getMessageLogs() |
List SMS delivery logs |
SMS
client.sendSms(params, options?)
Send a single SMS. Phone numbers are normalized locally to E.164 before any network call.
const result = await client.sendSms(
{ to: '0712345678', message: 'Your OTP is 123456', from: 'MyBrand' },
{ idempotencyKey: 'order-4821-confirmation' }, // optional, reuse on retry
);
console.log(result.messageId); // "SA-abc123..."
console.log(result.status); // "sent"
console.log(result.creditsUsed); // 1
| Parameter | Type | Required | Description |
|---|---|---|---|
to |
string |
yes | Phone number (any format -- normalized locally) |
message |
string |
yes | SMS body text |
from |
string |
no | Custom sender ID (must be pre-approved by SendAfrica) |
| Option | Type | Description |
|---|---|---|
idempotencyKey |
string |
Unique key for safe retries (e.g. order ID) |
skipPhoneNormalization |
boolean |
Send to as-is without validation |
Returns: SendSmsResult
| Field | Type | Description |
|---|---|---|
messageId |
string |
Server-assigned unique ID |
status |
"sent" |
Delivery status |
cost |
string |
Cost string (e.g. "KES 1.00") |
creditsUsed |
number |
Credits consumed |
requestId |
string |
Request ID for tracing |
timestamp |
string |
Server timestamp |
Throws: InvalidPhoneNumberError, SendAfricaError
Note:
fromonly appears on the recipient's phone once SendAfrica has approved that sender ID; an unapproved one silently falls back to the defaultSendAfrikasender and the message still sends -- the SDK can't detect this for you.
Credits
client.getBalance()
const { balance, accountId } = await client.getBalance();
console.log(accountId); // "acc_abc123"
console.log(balance); // 4820
Returns: BalanceResult
| Field | Type | Description |
|---|---|---|
accountId |
string |
Account identifier |
balance |
number |
Current credit balance |
client.getCreditHistory(query?)
List credit transactions with page-based pagination.
const history = await client.getCreditHistory({ page: 1, perPage: 25 });
console.log(history);
Parameters:
| Parameter | Default | Description |
|---|---|---|
page |
1 |
Page number (1-indexed) |
perPage |
25 |
Items per page |
Returns: unknown (raw API response -- type definition is pending
the API's response schema stabilizing)
Vouchers / Top-up
Credit top-ups are pay-as-you-go: you specify any TZS amount (above the minimum) and the API converts it to credits at the current tiered rate.
client.getVoucherRate()
Fetch the current pricing schedule: minimum top-up amount and the tiered TZS-per-credit rate table.
const rate = await client.getVoucherRate();
console.log(`Minimum top-up: ${rate.minAmountTzs} TZS`);
for (const tier of rate.tiers) {
console.log(` Up to ${tier.maxAmountTzs} TZS: ${tier.rateTzsPerCredit} TZS/credit`);
}
Returns: VoucherRateResult
| Field | Type | Description |
|---|---|---|
minAmountTzs |
number |
Minimum top-up amount in TZS |
tiers |
VoucherRateTier[] |
Pricing tiers |
Where each VoucherRateTier is:
| Field | Type | Description |
|---|---|---|
maxAmountTzs |
number |
Upper bound (0 = unbounded/top tier) |
rateTzsPerCredit |
number |
Price per credit in TZS |
Use this to validate an amount client-side before calling createVoucher():
const rate = await client.getVoucherRate();
const amountTzs = 30000;
if (amountTzs < rate.minAmountTzs) {
console.log(`Minimum is ${rate.minAmountTzs} TZS`);
}
client.createVoucher(params, options?)
const voucher = await client.createVoucher({ provider: 'snippe', amount: 50000 });
console.log(voucher.id, voucher.status, voucher.creditAmount);
// voucher.status === 'pending' -- mobile-money top-ups always charge YOUR
// account's own verified phone number, never one you supply.
// There's no status-poll endpoint: poll getBalance() or watch for a
// confirmation notification instead.
| Parameter | Type | Required | Description |
|---|---|---|---|
provider |
"snippe" |
yes | Payment provider |
amount |
number |
yes | Top-up amount in TZS (must be positive) |
Returns: VoucherResult
| Field | Type | Description |
|---|---|---|
id |
string |
Voucher/order ID |
provider |
string |
Payment provider used |
phone |
string |
Account's own verified phone |
amount |
number |
Amount in TZS |
creditAmount |
number |
Credits to be credited |
currency |
string |
"TZS" |
status |
VoucherStatus |
"pending" / "confirmed" / "failed" |
packageId |
string | null |
Package ID if applicable |
createdAt |
string |
Timestamp |
Message Logs
client.getMessageLogs(jwtToken, query?)
Message logs require a JWT (dashboard login token) -- not the API
key. Pass it explicitly; it's never mixed with X-API-Key.
const logs = await client.getMessageLogs(jwtToken, { status: 'failed', page: 1 });
for (const entry of logs.items) {
console.log(entry.id, entry.toPhone, entry.status);
}
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
jwtToken |
string |
yes | Dashboard JWT (Bearer auth) |
page |
number |
no | Page number (default: 1) |
perPage |
number |
no | Items per page (default: 25) |
status |
MessageStatus |
no | Filter by "sent" / "delivered" / "failed" |
Returns: MessageLogsResult
| Field | Type | Description |
|---|---|---|
items |
MessageLogEntry[] |
Log entries |
total |
number |
Total matching entries |
page |
number |
Current page |
perPage |
number |
Items per page |
totalPages |
number |
Total pages |
Where each MessageLogEntry is:
| Field | Type | Description |
|---|---|---|
id |
string |
Message ID |
toPhone |
string |
Recipient phone |
fromId |
string | null |
Sender ID |
message |
string |
SMS body |
status |
MessageStatus |
"sent" / "delivered" / "failed" |
smsParts |
number |
Number of SMS segments |
creditsUsed |
number |
Credits consumed |
sentAt |
string |
When the message was sent |
deliveredAt |
string | null |
When delivery was confirmed |
createdAt |
string |
When the message was created |
Phone Number Handling
The SDK normalizes phone numbers to E.164 format locally, before any network call. These formats all work:
| Input | Output |
|---|---|
0712345678 |
+255712345678 |
712345678 |
+255712345678 |
255712345678 |
+255712345678 |
+255712345678 |
+255712345678 |
+255 712 345 678 |
+255712345678 |
The SDK validates that the number uses a valid Tanzania mobile prefix
(071 through 078). Numbers that cannot be normalized raise
InvalidPhoneNumberError without hitting the API.
You can also use the helpers directly:
import { normalizeTzPhone, isValidTzPhone } from 'sendafrica';
normalizeTzPhone('0712345678'); // '+255712345678'
isValidTzPhone('+254712345678'); // false -- not a Tanzania number
SMS Part / Credit Calculator
Estimate cost and encoding before you send. These are pure local computations -- zero network calls:
import { getSmsPartInfo, detectEncoding, countSmsParts } from 'sendafrica';
const info = getSmsPartInfo('Hello, your order is ready.');
// { encoding: 'GSM-7', length: 28, parts: 1, creditsRequired: 1 }
const emojiInfo = getSmsPartInfo('Habari 😊 Bei yako ni 5000 TZS');
// { encoding: 'UCS-2', length: 31, parts: 1, creditsRequired: 1 }
detectEncoding('Hello'); // 'GSM-7'
detectEncoding('Hello 😊'); // 'UCS-2'
countSmsParts('Hello'); // 1
Returns: SmsPartInfo
| Field | Type | Description |
|---|---|---|
encoding |
SmsEncoding |
"GSM-7" or "UCS-2" |
length |
number |
Character count |
parts |
number |
Number of SMS segments |
creditsRequired |
number |
Estimated credits (1 per segment) |
Segmentation rules:
- GSM-7 (basic Latin + limited symbols): 160 chars/single, 153 when concatenated
- UCS-2 (emoji, accented characters outside GSM-7): 70 chars/single, 67 when concatenated
Note:
creditsRequiredis an estimate for UI display. The authoritative number iscreditsUsedon theSendSmsResultfromclient.sendSms().
Error Handling
Every failed request throws a typed error. Catch the base class for generic handling, or use the convenience getters for quick discrimination:
import { SendAfricaError } from 'sendafrica';
try {
await client.sendSms({ to: '0712345678', message: 'Hi' });
} catch (err) {
if (err instanceof SendAfricaError) {
if (err.isInsufficientCredits) {
// top up first
} else if (err.isRateLimited) {
// back off -- though the client already retries this automatically
} else if (err.isUnauthorized) {
// check your API key
} else {
console.error(err.code, err.message, err.requestId);
}
} else {
throw err; // SendAfricaNetworkError, InvalidPhoneNumberError, etc.
}
}
Exception hierarchy
SendAfricaError (API responded with success:false)
├── .isInsufficientCredits (code === 'insufficient_credits')
├── .isRateLimited (code === 'rate_limit_exceeded')
└── .isUnauthorized (code === 'unauthorized')
SendAfricaNetworkError (request failed after retries, or non-JSON response)
InvalidPhoneNumberError (to isn't a valid Tanzania mobile number)
Attributes on every SendAfricaError
| Attribute | Type | Description |
|---|---|---|
.code |
string |
API error code (e.g. "insufficient_credits") |
.message |
string |
Human-readable error message |
.httpStatus |
number | undefined |
HTTP status code |
.requestId |
string | undefined |
Request ID from response headers |
Retry behavior
The SDK automatically retries on:
| Status | Behavior |
|---|---|
429 |
Exponential backoff |
500, 502, 503, 504 |
Exponential backoff |
| Network errors | Exponential backoff |
Backoff formula: min(1000 * 2^(attempt-1), 8000) + random(0..250) ms.
Default max retries: 2. Total max wait per request: ~16 seconds.
Bulk Sending
For a handful of numbers, space out calls yourself:
for (const to of numbers) {
try {
await client.sendSms({ to, message });
} catch (err) {
console.error(to, err);
}
await new Promise((r) => setTimeout(r, 100)); // ~600/min, safe for Pro plan
}
For campaigns of 500+ contacts, use the Campaigns feature in the SendAfrica dashboard instead -- it handles throttling, retries, and delivery reporting for you. This SDK intentionally doesn't wrap that endpoint.
Lessons: Using the SDK Effectively
These are practical patterns for getting the most out of the SDK.
Lesson 1: Check balance before sending
Avoid SendAfricaError with code insufficient_credits by checking first:
const { balance } = await client.getBalance();
if (balance < 10) {
console.log(`Low balance: ${balance} credits remaining`);
// prompt user to top up
}
Lesson 2: Preview cost with getSmsPartInfo()
getSmsPartInfo() makes zero network calls -- it's pure local computation.
Use it to show users the cost before they confirm:
import { getSmsPartInfo } from 'sendafrica';
const info = getSmsPartInfo(message);
const cost = info.creditsRequired;
// Show "This message will cost {cost} credit(s)" in your UI
Lesson 3: Use idempotency keys for safe retries
If a network error occurs after the server processed the request, the SDK retries automatically. Use an idempotency key to prevent duplicate messages:
await client.sendSms(
{ to: '0712345678', message: 'Order confirmed' },
{ idempotencyKey: `order-${orderId}-confirmation` },
);
Lesson 4: Phone numbers just work
Don't preprocess phone numbers in your code. Pass whatever format you have:
// All of these work:
await client.sendSms({ to: '0712345678', message: 'Hello' });
await client.sendSms({ to: '+255712345678', message: 'Hello' });
await client.sendSms({ to: '255712345678', message: 'Hello' });
await client.sendSms({ to: '+255 712 345 678', message: 'Hello' });
Lesson 5: Catch specific errors
Don't catch SendAfricaError for everything -- use the convenience
getters for better UX:
try {
await client.sendSms({ to: '0712345678', message: 'Hello' });
} catch (err) {
if (err instanceof InvalidPhoneNumberError) {
// Show "Please check the phone number"
} else if (err instanceof SendAfricaError && err.isInsufficientCredits) {
// Show "Please top up your credits"
} else if (err instanceof SendAfricaError && err.isRateLimited) {
// Show "Please wait a moment and try again"
}
}
Lesson 6: Retry is built in
Don't implement your own retry logic. The SDK handles 429 and 5xx automatically with exponential backoff:
// This is safe -- the SDK retries transient failures internally
const result = await client.sendSms({ to: '0712345678', message: 'Hello' });
Lesson 7: Use injectable fetch for testing
The SDK accepts a custom fetch implementation, making it trivial to
mock in tests:
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ success: true, data: { message_id: 'test-123', status: 'sent', credits_used: 1 } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const client = new SendAfricaClient({ apiKey: 'SA-test', fetch: fetchMock });
const result = await client.sendSms({ to: '0712345678', message: 'Test' });
// result.messageId === 'test-123'
Lesson 8: Message logs use a separate auth mechanism
Message logs require a JWT from the dashboard login -- not the API key. This is by design (the API uses two separate auth systems):
const jwtToken = '...'; // from dashboard login
const logs = await client.getMessageLogs(jwtToken, { status: 'failed' });
Project Layout
src/
├── index.ts # Public API exports
├── client.ts # SendAfricaClient (HTTP, retry, auth)
├── types.ts # All request/response type definitions
├── errors.ts # SendAfricaError, SendAfricaNetworkError, InvalidPhoneNumberError
├── phone.ts # TZ mobile number normalization
└── sms-parts.ts # GSM-7/UCS-2 encoding + segment analysis
test/
├── client.test.ts # Client HTTP, retry, error handling tests
├── phone.test.ts # Phone normalization tests
└── sms-parts.test.ts # SMS encoding + part calculation tests
Roadmap
- Phase 1 (done): Client, auth, SMS send, credits balance/history, vouchers top-up/rate, message logs, error hierarchy, response types, phone normalization, SMS part calculator, retry/backoff, idempotency
- Phase 2: Bulk SMS via server-side endpoint, webhook signature verification, credit history typing
- Phase 3: Campaigns, contacts, templates, scheduling
Contributing
Run tests:
npm install
npm test
Type-check without emitting:
npm run typecheck
Build:
npm run build
License
MIT