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

airtime-pay

v1.0.0

Published

Unified TypeScript SDK for Airtel Money and TNM Mpamba — payments, balance, transaction status and webhooks via one interface.

Readme

airtime-pay

CI npm version License: MIT

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]


Features

  • 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.00 formatting
  • Built-in retry with exponential backoff
  • MockProvider for unit tests — no network, full scenario control
  • Zero runtime dependencies

Installation

npm install airtime-pay

Quickstart

import { 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)

All methods

// 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 refund

Amounts — tambala

All 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"

Phone numbers

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"

Testing with MockProvider

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" });
});

Available scenarios

| 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 balance

Webhook events in tests

const 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();

Error handling

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
  }
}

Error codes

| 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 |

Docker

# Run tests
docker compose up test

# Watch mode for development
docker compose up dev

# Build production image
docker compose up prod

Project structure

airtime-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

Contributing

See CONTRIBUTING.md — adding a new provider takes about 30 minutes.

License

MIT © Prince Thawani