easypay-tz
v0.4.3
Published
Official Node.js SDK for the EasyPay payment platform collections, payouts, signed webhooks and test events.
Downloads
916
Maintainers
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:cryptoandAbortSignalare all it needs. - ESM and CommonJS, with type definitions for both. Node 18.17+.
- Status is webhook/callback-only.
payments.create()andpayouts.create()resolve when the platform accepts the request; terminal outcomes arrive as signed webhooks. There is deliberately nopayments.retrieve()/list()to poll. Payouts have noretrieve()either: the per-payoutcallbackUrlis 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-tzQuick 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
429and5xx, 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-Afterheader 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:coverageThe 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 releaseBump 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