Unified TypeScript SDK for Airtel Money and TNM Mpamba — Malawi's two major mobile money providers. One interface, both providers, full TypeScript types.
Built by Prince Thawani · [email protected]
- Single
createAirtimePay()factory — swap providers with one config change - Full TypeScript types — same shape for both providers
- Automatic OAuth2 token refresh (Airtel)
- Malawian phone number normalization — accepts
0888...,265888...,+265888... - MKW money utilities — safe tambala arithmetic,
MK 1,000.00formatting - Built-in retry with exponential backoff
MockProviderfor unit tests — no network, full scenario control- Zero runtime dependencies
npm install airtime-payimport { createAirtimePay } from "airtime-pay";
// ── Airtel Money ───────────────────────────────────────────────────────────────
const airtel = createAirtimePay({
provider: "airtel",
clientId: process.env.AIRTEL_CLIENT_ID!,
clientSecret: process.env.AIRTEL_CLIENT_SECRET!,
});
// ── TNM Mpamba ────────────────────────────────────────────────────────────────
const mpamba = createAirtimePay({
provider: "mpamba",
apiKey: process.env.MPAMBA_API_KEY!,
});
// ── Same API for both ─────────────────────────────────────────────────────────
const result = await airtel.pay({
amount: 100_000, // MK 1,000 in tambala (1 tambala = MK 0.01)
currency: "MWK",
phone: "0888123456", // any Malawian format accepted
reference: "ORDER-001",
description: "Payment for order #001",
});
console.log(result.status); // "successful" | "pending" | "failed"
console.log(result.transactionId); // provider transaction ID
console.log(result.phone); // "+265888123456" (normalized)// Initiate a payment
const payment = await provider.pay({ amount, phone, reference, description? });
// Check wallet balance
const balance = await provider.balance();
console.log(balance.balance); // in tambala
// Check transaction status
const status = await provider.status(transactionId);
// Refund a transaction
const refund = await provider.refund({ transactionId, amount? }); // amount omit = full refundAll amounts are in tambala (the smallest unit of MKW), the same way Stripe uses cents.
| You want | You pass |
|---|---|
| MK 1,000 | 100_000 |
| MK 500 | 50_000 |
| MK 50 | 5_000 |
import { toTambala, toKwacha, formatMKW } from "airtime-pay";
toTambala(1000) // → 100000
toKwacha(100000) // → 1000
formatMKW(100000) // → "MK 1,000.00"Any Malawian format is accepted — the SDK normalizes to E.164 internally.
import { normalizePhone, detectNetwork } from "airtime-pay";
normalizePhone("0888123456") // → "+265888123456"
normalizePhone("265888123456") // → "+265888123456"
normalizePhone("+265888123456") // → "+265888123456"
detectNetwork("0888123456") // → "airtel"
detectNetwork("0999456789") // → "tnm"Use MockProvider in your tests — no network calls, no credentials needed.
import { MockProvider } from "airtime-pay";
import { describe, it, expect, beforeEach } from "vitest";
const mock = new MockProvider({ provider: "mock" });
beforeEach(() => mock.reset());
it("charges a customer", async () => {
const result = await mock.pay({
amount: 50_000,
phone: "0888123456",
reference: "ORDER-001",
});
expect(result.status).toBe("successful");
});
it("handles declined payment", async () => {
mock.use("insufficientBalance");
await expect(
mock.pay({ amount: 50_000, phone: "0888123456", reference: "ORDER-002" })
).rejects.toMatchObject({ code: "INSUFFICIENT_BALANCE" });
});| Scenario | Behaviour |
|---|---|
success |
Payment succeeds (default) |
pending |
Payment stays pending |
failed |
Payment fails |
insufficientBalance |
Throws INSUFFICIENT_BALANCE |
invalidPhone |
Throws INVALID_PHONE |
timeout |
Throws TIMEOUT |
networkError |
Throws NETWORK_ERROR |
duplicate |
Throws DUPLICATE_TRANSACTION |
mock.use("pending"); // named scenario
mock.useCustom({ payment: { status: "processing" } }); // custom
mock.setBalance(500_000); // set wallet balanceconst handler = vi.fn();
mock.on("payment.successful", handler);
await mock.pay({ amount: 10_000, phone: "0888123456", reference: "R1" });
expect(handler).toHaveBeenCalledOnce();
expect(handler.mock.calls[0][0].type).toBe("payment.successful");
// Inspect full history
const events = mock.getEventHistory();import { AirtimePayError } from "airtime-pay";
try {
await provider.pay({ amount: 10_000, phone: "0888123456", reference: "R1" });
} catch (err) {
if (err instanceof AirtimePayError) {
console.log(err.code); // "INSUFFICIENT_BALANCE"
console.log(err.provider); // "airtel" | "mpamba" | "mock"
console.log(err.statusCode); // 402
console.log(err.message); // human-readable message
}
}| Code | Meaning |
|---|---|
INVALID_CREDENTIALS |
Wrong API key or client secret |
INSUFFICIENT_BALANCE |
Customer wallet has insufficient funds |
INVALID_PHONE |
Phone number could not be normalized |
TRANSACTION_NOT_FOUND |
No transaction with that ID |
DUPLICATE_TRANSACTION |
Reference already used |
REFUND_NOT_ALLOWED |
Transaction not in refundable state |
INVALID_AMOUNT |
Amount is zero, negative, or exceeds original |
PROVIDER_ERROR |
Provider returned an unexpected error |
NETWORK_ERROR |
Could not reach the provider API |
TIMEOUT |
Request exceeded timeout |
# Run tests
docker compose up test
# Watch mode for development
docker compose up dev
# Build production image
docker compose up prodairtime-pay/
├── config/ # Config types and defaults
├── middlewares/ # Errors, retry, logger
├── src/
│ ├── payments/interface/types.ts # All shared domain types
│ └── providers/
│ ├── airtel/AirtelProvider.ts
│ ├── mpamba/MpambaProvider.ts
│ └── mock/MockProvider.ts
├── utils/ # phone, money, ID helpers
├── tests/
├── .github/workflows/ci.yml # GitHub Actions CI
├── .env.example
├── docker-compose.yml
├── Dockerfile
└── index.ts # Public API
See CONTRIBUTING.md — adding a new provider takes about 30 minutes.
MIT © Prince Thawani