Take payments across M-Pesa, card, mobile money and bank transfer from a single integration. This package is the official, fully-typed Node.js client for the NowPesa REST API.
npm install @nowpesa/sdk
# or
pnpm add @nowpesa/sdkRequires Node.js 18+ (uses the built-in fetch). ESM and CJS both
ship in the same package; import and require both work.
import { Nowpesa } from "@nowpesa/sdk";
const nowpesa = new Nowpesa({
apiKey: {
id: process.env.NOWPESA_KEY_ID!,
secret: process.env.NOWPESA_KEY_SECRET!,
},
});
const payment = await nowpesa.payments.create({
reference: "order-1234",
amount: 100_00, // minor units — 100.00 KES
currency: "KES",
channel: "mpesa_stk_push",
payer_phone: "+254712345678",
});
console.log(payment.id, payment.status);The SDK returns plain JavaScript objects that mirror the REST shapes documented at https://nowpesa.com/docs/api.
NowPesa uses HTTP Basic auth with an API key id + secret pair. Get keys from the dashboard, or bootstrap them programmatically:
const bootstrap = new Nowpesa({ apiKey: { id: "_", secret: "_" } });
const auth = await bootstrap.auth.register({
email: "founder@acme.example",
password: "...",
merchant_name: "Acme",
country: "KE",
});
const nowpesa = new Nowpesa({
apiKey: { id: auth.api_key_id, secret: auth.api_key_secret },
});// M-Pesa STK Push — async, completes via webhook + status update.
const stk = await nowpesa.payments.create({
reference: "order-1",
amount: 1500,
currency: "KES",
channel: "mpesa_stk_push",
payer_phone: "+254712345678",
});
// Card — synchronous; resolves to `succeeded` (or `failed`) before
// returning.
const card = await nowpesa.payments.create({
reference: "order-2",
amount: 2500,
currency: "USD",
channel: "card",
card_token: "tok_visa_test",
});
// Read one.
const fetched = await nowpesa.payments.get(stk.id);
// List with filters + cursor pagination.
const page = await nowpesa.payments.list({ status: "succeeded", limit: 50 });
if (page.next_before) {
const next = await nowpesa.payments.list({
status: "succeeded",
limit: 50,
before: page.next_before,
});
}
// Refund (full or partial).
const refund = await nowpesa.payments.refund(card.id, { amount: 2500 });Mutating requests (POST) auto-attach an Idempotency-Key (UUIDv4) so
network retries don't double-charge. Set your own when you need
explicit deduplication across processes:
await nowpesa.payments.create({
reference: "order-3",
amount: 1000,
currency: "KES",
channel: "mpesa_stk_push",
payer_phone: "+254712345678",
idempotencyKey: "order-3-v1",
});Pass idempotencyKey: null to suppress (used for /v1/auth/*).
const payout = await nowpesa.payouts.create({
amount: 10_000_00,
currency: "KES",
destination_phone: "+254712345678",
remarks: "weekly settlement",
});Register an endpoint and verify signatures server-side:
const ep = await nowpesa.webhooks.register({
url: "https://api.example.com/webhooks/nowpesa",
event_types: ["payment.succeeded", "payment.refunded"],
});
console.log("Store this — shown once:", ep.signing_secret);import { verifyWebhook, parseEvent, type WebhookEvent } from "@nowpesa/sdk";
app.post("/webhooks/nowpesa", express.raw({ type: "application/json" }), (req, res) => {
const sig = req.header("X-Nowpesa-Signature");
const ok = verifyWebhook(req.body, sig, process.env.NOWPESA_WEBHOOK_SECRET!);
if (!ok) return res.status(400).end();
const event = parseEvent<WebhookEvent>(req.body);
switch (event.type) {
case "payment.succeeded":
// event.data is the payment payload
break;
case "payment.refunded":
break;
}
res.status(200).end();
});The two-step rotate → promote flow lets you cut over to a new secret
with zero dropped events. Until you call promoteSecret, deliveries
carry both X-Nowpesa-Signature (current secret) and
X-Nowpesa-Signature-Next (new secret). verifyWebhook accepts either:
const rotated = await nowpesa.webhooks.rotateSecret(ep.id);
// Store rotated.signing_secret_next.
// During cutover, verify either signature:
const ok = verifyWebhook(req.body, req.header("X-Nowpesa-Signature"), CURRENT, {
nextSecret: NEW,
nextHeader: req.header("X-Nowpesa-Signature-Next"),
});
// Once your fleet uses the new secret:
await nowpesa.webhooks.promoteSecret(ep.id);const st = await nowpesa.statement.get({ since: "2026-01-01", currency: "KES" });
console.log(st.inbound_succeeded, st.payouts_succeeded);
// CSV download as a streamable Response:
const resp = await nowpesa.statement.downloadCsv({});
import { Writable } from "node:stream";
import { createWriteStream } from "node:fs";
await resp.body!.pipeTo(Writable.toWeb(createWriteStream("statement.csv")));Every method throws a typed NowpesaError subclass on non-2xx
responses. Branch with instanceof:
import {
NowpesaError,
NowpesaValidationError,
NowpesaRateLimitError,
NowpesaServerError,
} from "@nowpesa/sdk";
try {
await nowpesa.payments.create({ ... });
} catch (err) {
if (err instanceof NowpesaValidationError) {
// 400 — surface err.message to the user
} else if (err instanceof NowpesaRateLimitError) {
// 429 — err.retryAfter (seconds) honors the server's hint
} else if (err instanceof NowpesaServerError) {
// 5xx — already retried up to maxRetries times
} else if (err instanceof NowpesaError) {
console.error(err.status, err.type, err.message, err.requestId);
}
}The client retries on 429, 5xx, and network errors:
- 3 retries by default (override with
maxRetries). - 429 honors the
Retry-Afterheader. - 5xx + network: exponential backoff (1s, 2s, 4s) + 30% jitter.
- 4xx (other than 429) are NOT retried — they're caller bugs.
new Nowpesa({
apiKey: { id, secret },
baseUrl: "https://api.nowpesa.com", // override for staging/dev
timeoutMs: 30_000, // per-request timeout
maxRetries: 3,
defaultHeaders: { "X-My-App": "v1" }, // sent on every request
fetch: customFetch, // inject for testing
});pnpm install
pnpm test # unit tests + integration tests (auto-skipped if no edge-api)
pnpm typecheck
pnpm buildIntegration tests hit a local edge-api at http://localhost:8080. They
auto-skip if it's unreachable. Override:
NOWPESA_BASE_URL=http://localhost:8080 pnpm test
NOWPESA_INTEG=0 pnpm test # force-skip integration suite
NOWPESA_SINK_HOST=127.0.0.1 pnpm test # if edge-api runs on the hostMIT.