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

easypay-tz

v0.4.3

Published

Official Node.js SDK for the EasyPay payment platform collections, payouts, signed webhooks and test events.

Downloads

916

Readme

easypay-tz

Official Node.js SDK for the EasyPay payment platform: collect money with mobile money, send reviewed payouts to mobile wallets and banks, verify signed webhooks, and test your webhook handler over the public /v1 HTTP API.

  • Zero runtime dependencies. Node's global fetch, node:crypto and AbortSignal are all it needs.
  • ESM and CommonJS, with type definitions for both. Node 18.17+.
  • Status is webhook/callback-only. payments.create() and payouts.create() resolve when the platform accepts the request; terminal outcomes arrive as signed webhooks. There is deliberately no payments.retrieve() / list() to poll. Payouts have no retrieve() either: the per-payout callbackUrl is the source of truth.
  • Typed errors. One class per failure mode, branch on the class or on error.code, never on the message text.

Install

npm install easypay-tz

Quick start

import { EasyPay } from 'easypay-tz';

const easypay = new EasyPay({
  apiKey: process.env.EASYPAY_API_KEY!,       // epk_…  (public)
  secretKey: process.env.EASYPAY_SECRET_KEY!, // ep_sk_… (secret)
});

const payment = await easypay.payments.create({
  amount: 5000,                 // 5000 TZS major units, NOT cents
  payerPhone: '255712XXXXXX',   // international MSISDN
  payerName: 'Asha Mwinyi',
  merchantReference: 'ORD-1001',
  idempotencyKey: 'ord-1001-attempt-1',
});

console.log(payment.reference); // COL20260101ABC123 store it with the order
console.log(payment.state);     // 'INITIATED'

CommonJS is the same package with a different import:

const { EasyPay } = require('easypay-tz');

The payer is prompted on their phone. When they finish, the platform POSTs a signed event to your webhook URL see Webhooks.

Configuration

const easypay = new EasyPay({
  apiKey: 'epk_…',
  secretKey: 'ep_sk_…',
  timeoutMs: 30_000,
  maxRetries: 2,
});

| Option | Type | Default | Notes | | ----------- | ------------------------- | ---------------------------- | ----- | | apiKey | string | (required) | Must start with epk_. Safe to log. | | secretKey | string | (required) | Must start with ep_sk_. Server-side only. | | baseUrl | string | https://api.easypay.co.tz | Absolute http(s) URL. | | timeoutMs | number | 30_000 | Bounds one attempt, so a retried call may take timeoutMs × (1 + maxRetries). | | maxRetries| number | 2 | Capped at 5. See Retries. | | headers | Record<string, string> | {} | Extra headers on every request; a per-call header of the same name wins. | | fetch | FetchLike | the global fetch | Inject a proxy or instrumentation; the tests inject a fake. |

Credentials are trimmed and validated at construction, so a swapped pair (a secret key in apiKey, a trailing newline from process.env) fails immediately with EasyPayConfigurationError instead of costing you a 401.

There is no environment: 'live' | 'not_live' option. An application's environment is a property of its credentials and is resolved server-side: the same URL and the same code serve both.

Per-call options go in the second argument of every resource method:

| Option | Notes | | ---------------- | ----- | | idempotencyKey | 10–100 characters, sent as X-Idempotency-Key. | | timeoutMs | Overrides the client timeout for this call. | | maxRetries | Overrides the client budget for this call. | | signal | Caller cancellation; rejects with code ABORTED. | | headers | Extra headers for this call only. |

Collecting a payment

easypay.payments.create(request, options?) → POST /v1/payments

| Field | Type | Required | Notes | | ------------------- | ------------------------- | -------- | ----- | | amount | number \| string | yes | Major units of currency: 5000 is TZS 5 000. At most 2 decimals 0.005 is rejected rather than silently rounded. | | mode | PaymentMode | no | instant (default) · link · installment. Validated locally against the platform's enum, the dashboard-only link_installment alias is not accepted here. See Pay links and installments. | | expiresAt | string | no | ISO-8601 deadline for a link / installment payment, e.g. 2026-11-01T18:00:00.000Z. Must be in the future. Omit for a link that never expires; rejected for instant. | | payerPhone | string | instant | International MSISDN, e.g. 255712XXXXXX (max 20). Routes the payment and decides the rail. Required for instant; optional for link / installment. | | payerName | string | instant | Shown on the prompt and the receipt (max 255). Required for instant; optional for link / installment. | | currency | Currency | no | TZS (default) · KES · UGX · USD · EUR · GBP. Validated locally against the platform's enum. | | payerEmail | string | no | For receipts (max 255). | | description | string | no | What the payer is paying for (max 500). | | merchantReference | string | no | Your order id (max 100). Send it: it doubles as the field that ties a webhook back to your record, it is echoed as data.merchantReference. | | metadata | object | no | Arbitrary JSON, echoed on the payment and delivered as data.metadata on webhooks. | | idempotencyKey | string | no | Convenience alias for the per-call option; always sent as a header. |

Unknown fields are dropped rather than forwarded the SDK builds the body field by field, so a typo never reaches the API silently.

The response is exactly the fifteen keys the platform publishes, with money as strings exactly as stored:

| Field | Notes | | ----- | ----- | | reference | Platform reference, e.g. COL20260101ABC123. Pass it to cancel(). | | state | INITIATED right after create(); the outcome arrives by webhook. | | amount, currency | Strings ("5000", "TZS"), as stored by the platform. | | type, paymentMethod | COLLECTION, and the rail chosen for the payer (MOBILE_MONEY). | | merchantReference | Your order id, echoed back, or null. | | metadata | Your metadata, or null. | | qrCode | Legacy QR payload; may be a provider payload or the platform's checkout URL. | | tipsQrCode | Provider-issued TIPS payload, or null until a payment order exists. | | internalQrCode | EasyPay wallet payload, present on every payment. | | mode | The mode the payment was created with (instant when you sent none). | | link | Shareable checkout URL for a link / installment payment, else null. | | expiresAt | The link deadline as ISO-8601, or null when it never expires (and always for instant). | | createdAt | ISO-8601. |

There is no id: a payment is identified by reference. The payload is an allow-list (not the transaction row with keys removed), so internal columns (walletId, providerName, providerFeeAmount, …) are never returned and a new column on the platform cannot leak into your code.

Pay links and installments

mode: 'link' and mode: 'installment' are for collecting from a payer you do not know yet. The platform creates an unpaid payment, contacts nobody, and answers with the checkout URL on the response, send it to the payer yourself (SMS, email, WhatsApp, in-app). Because nobody is pinged, neither payerPhone nor payerName is required; whatever you do send is carried along for the receipt.

// A single-payment link.
const bill = await easypay.payments.create({
  amount: 5000,
  mode: 'link',
  description: 'Invoice INV-1001',
  merchantReference: 'INV-1001',
  expiresAt: '2026-12-31T23:59:59.000Z', // optional
  idempotencyKey: 'inv-1001',
});

console.log(bill.mode, bill.link);
// 'link' https://dashboard.easypay.co.tz/checkout/COL20260101ABC123

// The same container, but the payer may settle it in parts.
const plan = await easypay.payments.create({
  amount: 120_000,
  mode: 'installment',
  merchantReference: 'INV-1002',
});

A link never expires unless you send expiresAt, which must be a future ISO-8601 timestamp with an offset. Once the deadline passes the link is no longer payable and the platform marks the payment FAILED (delivering the usual webhook); expiresAt is null on the response when no deadline was set. It is rejected for instant, which has no shareable link.

Both still report their outcome by webhook, and payments.cancel(reference) works while the bill is open. Cancelling returns the same projection, so mode, link and expiresAt are present there too (a paid or already-cancelled bill rejects with EasyPayConflictError, as for any settled payment).

easypay.payments.cancel(reference, options?) → POST /v1/payments/{reference}/cancel

Cancels a payment that has not settled yet and resolves with it in the CANCELLED state. A payment that already reached a terminal state rejects with EasyPayConflictError (HTTP 409); an unknown reference rejects with EasyPayNotFoundError.

await easypay.payments.cancel(payment.reference);

Sending a payout

easypay.payouts.create(request, options?) → POST /v1/payouts

Payouts send money out of the merchant wallet to mobile-money numbers and bank accounts. A payout is funded by the merchant account, not by one application, so it uses a dedicated account-level payout credential instead of an application key. Generate it in Dashboard → Settings → Security → Payout API credentials; payout keys start with epk_payout_ and secrets with ep_sk_payout_.

Use a second SDK client configured with those credentials:

const payoutClient = new EasyPay({
  apiKey: process.env.EASYPAY_PAYOUT_KEY!,       // epk_payout_…
  secretKey: process.env.EASYPAY_PAYOUT_SECRET!, // ep_sk_payout_…
});

Payout credentials cannot create collections, and application keys cannot call the Payouts API. Rotating the pair invalidates the old credentials immediately.

Every developer payout is reviewed. create() resolves with the payout in PENDING, which means the platform accepted the request and queued it for moderator approval. It does not mean money moved. Approval requires the moderator's password step-up, so a leaked payout credential cannot release funds on its own.

const payout = await payoutClient.payouts.create({
  amount: 50_000,
  type: 'MOBILE_MONEY',
  channel: 'MPESA',
  payeeName: 'Asha Mwinyi',
  payeePhone: '0712345678',
  callbackUrl: 'https://merchant.example.com/webhooks/easypay',
  merchantReference: 'PAYROLL-2026-10-001',
  idempotencyKey: 'payroll-2026-10-001',
});

console.log(payout.reference); // DSB20261018ABC123
console.log(payout.state);     // 'PENDING'

| Field | Type | Required | Notes | | ----- | ---- | -------- | ----- | | amount | number \| string | yes | Major units; two decimals max. | | type | 'MOBILE_MONEY' \| 'BANK_TRANSFER' | yes | Destination rail. | | channel | string | bank only | Payout channel code (e.g. MPESA, CRDB); must support type. Required for BANK_TRANSFER. Optional for MOBILE_MONEY, where the engine resolves it from payeePhone; an explicit code always wins. Codes are shown live in the dashboard API documentation. | | callbackUrl | string | yes | Absolute http(s) outcome URL. The SDK base64-encodes it for the wire; the platform stores the decoded URL with the payout. | | payeeName | string | yes | Recipient name (max 255). | | payeePhone | string | mobile only | Recipient mobile-money number. | | payeeAccountNumber | string | bank only | Recipient bank account number. | | payeeBankCode | string | bank only | Receiving bank code, e.g. CRDB. | | payeeEmail | string | no | For receipts. | | currency | Currency | no | Defaults to the tenant currency (TZS). | | description | string | no | Max 500. | | merchantReference | string | no | Your payroll/invoice id; echoed on the response and webhook. | | metadata | object | no | Arbitrary JSON echoed on the payout and webhook. | | idempotencyKey | string | no | Alias for the per-call option; sent as a header. |

The response is an allow-list with no internal transaction, tenant or application UUID: identify a payout by reference. It carries amount, currency, type (DISBURSEMENT), paymentMethod, channel (the selected channel's code and name), payee, the feeAmount and totalAmount (amount + fee), requiresApproval, state, createdAt, metadata, merchantReference and failureReason.

easypay.payouts.quote(request, options?) → GET /v1/payouts/quote

Previews the platform fee and the merchant account's available balance before a payout is created. The quote applies the same fee bands and payout pause as create(); create() additionally enforces the platform's minimum payout amount.

const quote = await payoutClient.payouts.quote({
  amount: 50_000,
  type: 'MOBILE_MONEY',
  payeePhone: '0712345678', // resolves the channel; or pass channel: 'MPESA'
});

console.log(quote.feeAmount, quote.totalAmount, quote.sufficient);

Pass channel (the channel code) to price a specific channel. For MOBILE_MONEY you can omit it and pass payeePhone, and the engine resolves the channel from the recipient's network; BANK_TRANSFER requires channel. The resolved channel is echoed as quote.channel. An unknown, inactive or rail-incompatible code - or a mobile number with no matching active channel - is rejected with a field-level EasyPayValidationError (HTTP 400); the current codes are listed in the dashboard API documentation, which reads them live from your active payout channels.

Test events

easypay.testEvents.send() makes the platform deliver a real, correctly signed event to your own webhook URL through the production delivery pipeline (signature header, attempts, retries, delivery audit) without creating a transaction or contacting a payer. The event carries test: true.

const delivery = await easypay.testEvents.send({
  state: 'COMPLETED',                  // or 'FAILED' | 'CANCELLED'
  data: { amount: '5000' },            // optional overrides for the payload
});

console.log(delivery.deliveryId, delivery.eventId, delivery.url);

Provide state or eventType (TRANSACTION_COMPLETED, TRANSACTION_FAILED); state wins if you send both. The response returns the deliveryId, the eventId, the url, the exact signed payload and its signature.

Webhooks

The platform signs the exact bytes it sends: HMAC-SHA256 over the raw request body, hex encoded, in X-Webhook-Signature. Verification is therefore only possible with the raw body JSON.stringify(req.body) changes whitespace and key order, and the signature will no longer match.

Payment webhooks are signed with the application's webhook secret (shown in the application's Security dialog). Payout callbacks are delivered to the callbackUrl supplied on payouts.create() and signed with the account's payout secret key; use the same easypay.webhooks.constructEvent() helper with that secret. Payout events are DISBURSEMENT_COMPLETED and DISBURSEMENT_FAILED (the latter also covers a moderator REJECTED), and event.data.reference matches the payout reference.

import express from 'express';
import { EasyPay, isWebhookSignatureError } from 'easypay-tz';

const easypay = new EasyPay({
  apiKey: process.env.EASYPAY_API_KEY!,
  secretKey: process.env.EASYPAY_SECRET_KEY!,
});

const app = express();

// `verify` keeps the bytes the platform signed, before any parser touches them.
app.post(
  '/webhooks/easypay',
  express.json({
    verify: (req, _res, buf) => {
      (req as typeof req & { rawBody?: Buffer }).rawBody = buf;
    },
  }),
  (req, res) => {
    try {
      const event = easypay.webhooks.constructEvent(
        (req as typeof req & { rawBody: Buffer }).rawBody,
        req.headers, // IncomingHttpHeaders, Headers, or a signature string
        process.env.EASYPAY_WEBHOOK_SECRET!,
      );

      if (event.eventType === 'TRANSACTION_COMPLETED') {
        // `merchantReference` is the order id you sent at creation (null if you
        // sent none); `reference` is the platform's payment id.
        void fulfil(event.data.merchantReference, event.data.reference);
      }

      res.sendStatus(200);
    } catch (error) {
      if (isWebhookSignatureError(error)) return res.sendStatus(400);
      res.sendStatus(500);
    }
  },
);

Raw bodies elsewhere: Fastify's addContentTypeParser('application/json', { parseAs: 'buffer' }, …), or const chunks = []; for await (const chunk of req) chunks.push(chunk) with node:http. Serverless platforms hand you the string body as-is.

constructEvent(rawBody, signatureOrHeaders, secret) returns the parsed WebhookEvent:

{
  "eventType": "TRANSACTION_COMPLETED",   // platform-owned; switch on this
  "eventId": "evt_00000000-0000-4000-8000-000000000001",
  "timestamp": "2026-01-01T00:00:00.000Z",
  "test": false,                           // true only for test events
  "data": {
    "id": "00000000-0000-4000-8000-0000000000aa",
    "transactionId": "00000000-0000-4000-8000-0000000000aa",
    "reference": "COL20260101ABC123",
    "merchantReference": "ORD-1001",        // your order id, echoed back, null if you sent none
    "type": "COLLECTION",
    "state": "COMPLETED",
    "amount": "5000",
    "currency": "TZS",
    "metadata": {                           // your metadata, nested, always present
      "cartId": "c_1",
      "providerReference": "MPESA-REF-1"    // …plus the platform's annotations
    }
  }
}

data is a flat, whitelisted object: those nine keys (plus test on simulated deliveries) are the entire contract, so nothing internal can appear. merchantReference is your own reference echoed back, null when you sent none, so a handler can join the outcome to its record directly. metadata is always present too, null when there is nothing to report, and your other keys live inside it, not alongside it: read event.data.metadata?.cartId rather than event.data.cartId. On failures the platform adds failureCode / failureReason there too.

It throws WebhookSignatureError when the signature does not match and EasyPayValidationError when the body is not a JSON object, so a handler can never process an unverified payload by accident. easypay.webhooks.verify() returns a boolean instead, for callers that prefer to branch. The header names are exported as WEBHOOK_HEADERS, so nothing hardcodes them.

Deliveries retry with backoff, and a retry reuses the same X-Webhook-Event-Id with an incremented X-Webhook-Attempt: de-duplicate on eventId, and return 2xx as soon as the event is stored before doing slow work.

Errors

import { EasyPayError, EasyPayRateLimitError } from 'easypay-tz';

try {
  await easypay.payments.create({ /* … */ });
} catch (error) {
  if (error instanceof EasyPayRateLimitError) {
    await backOff(error.retryAfter); // seconds, from the platform's header
  }
  if (error instanceof EasyPayError) {
    console.error(error.code, error.status, error.details);
  }
}

Every error carries code (stable, machine-readable), status (when the platform answered), details (per-field messages for validation failures), origin ('sdk' or 'api') and, when there is one, cause.

| Class | Code | Meaning | | ----- | ---- | ------- | | EasyPayConfigurationError | CONFIGURATION_ERROR | Unusable client options thrown by the constructor, before any request. | | EasyPayValidationError | VALIDATION_ERROR | The request was rejected locally; details is keyed by field. | | EasyPayConnectionError | CONNECTION_ERROR | DNS/TLS/socket failure: the API was never reached. | | EasyPayTimeoutError | TIMEOUT | One attempt exceeded timeoutMs. The platform may still process it. | | EasyPayAuthenticationError | envelope code, UNAUTHORIZED on a bare 401 | 401 check that both keys come from the same application. | | EasyPayPermissionError | FORBIDDEN, EMAIL_NOT_VERIFIED | 403 application not ACTIVE, IP not whitelisted, limit reached. | | EasyPayNotFoundError | NOT_FOUND | 404 no such resource for this credential. | | EasyPayConflictError | e.g. INVALID_STATE | 409 the request contradicts the resource's current state. | | EasyPayIdempotencyError | IDEMPOTENCY_ERROR | 409 the key is still in flight, or was reused with a different payload. | | EasyPayRateLimitError | RATE_LIMIT_EXCEEDED | 429 retryAfter holds the Retry-After header in seconds. | | EasyPayServerError | INTERNAL_ERROR, HTTP_503 on a body-less 5xx | 5xx the platform failed to process a valid request. | | WebhookSignatureError | WEBHOOK_SIGNATURE_INVALID | The delivery did not verify; never process it. | | EasyPayError | EASYPAY_ERROR | Base class of all of the above. | | EasyPayError | ABORTED | The caller's signal aborted the call. | | EasyPayError | INVALID_RESPONSE | A 2xx that was not an EasyPay envelope, or a response whose projection was incomplete. |

Retries and idempotency

  • Retried: HTTP 429 and 5xx, connection failures, timeouts.
  • Not retried: everything else in 4xx (a deterministic answer), ABORTED, and a 2xx that is not an envelope.
  • Backoff starts around 400 ms with jitter and doubles up to 8 s; a parseable Retry-After header wins (capped at 30 s).
  • Writes are only retried when they carry an idempotency key. Without one, payments.create() is sent exactly once repeating it could ring the payer's phone twice. Send a key (10–100 characters) derived from your own record, e.g. ord-1001-attempt-1.
  • A replayed key returns the original response (2xx or 4xx); a 5xx releases the key, so the same key can be retried safely.

Development

npm install          # Node 18.17+; the lockfile pins the dev tooling exactly
npm run build        # tsc → dist/esm + dist/cjs, with .d.ts for both
npm run lint         # tsc --noEmit
npm test             # vitest run no network: every suite injects a fake fetch
npm run test:coverage

The package has no runtime dependencies and no test touches the wire: EasyPayClientOptions.fetch is the seam, and the doubles speak the real { status, success, data, pagination, errors } envelope. See tests/helpers.ts.

Publishing

prepublishOnly runs lint, test and build before every publish, so the tarball can never be produced from a failing tree. npm publish also runs that hook the scripts below just name the workflow.

# rehearse: packs the tarball exactly as it would be published, uploads nothing
npm run release:dry

# publish to the registry (lint + tests + build run first)
npm run release

Bump the version first with npm version <major|minor|patch>, which also stages the change and creates the matching git tag:

npm version patch        # 0.1.0 → 0.1.1
npm run release
git push --follow-tags