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

sendafrica

v1.0.0

Published

Unofficial TypeScript/JavaScript SDK for the SendAfrica Tanzania SMS API — send SMS, check credits, and top up with full type safety.

Readme

SendAfrica TypeScript SDK

npm License: MIT

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, or 255712345678 and 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, .isUnauthorized getters.
  • Idempotent sends -- pass an idempotencyKey to safely retry network failures without duplicating messages.
  • Zero runtime dependencies -- only fetch (Node 18+, or inject your own).
  • Dual CJS + ESM + .d.ts -- works with require, 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 pass apiKey explicitly 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: from only appears on the recipient's phone once SendAfrica has approved that sender ID; an unapproved one silently falls back to the default SendAfrika sender 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: creditsRequired is an estimate for UI display. The authoritative number is creditsUsed on the SendSmsResult from client.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