Official Node.js SDK for the Pesepay payment gateway.
- Zero runtime dependencies. Nothing but
node:builtins. - TypeScript-first, with CommonJS and ESM from one compiled module — so
require('pesepay')andimport 'pesepay'give you the same classes, andinstanceofkeeps working across the boundary. - All 17 transaction statuses, not a
paidboolean.PENDING,DECLINEDandREVERSEDare three different things to do next. - Typed errors that tell you which credential is wrong.
Requires Node.js 22.12 or newer.
Warning
Server-side only. This package holds your integration key and your encryption key. There is no browser build and there should not be one: a key that reaches a browser is a key that has been published. Keep both in environment variables, never in source control, and never ship them to a client.
Upgrading from 1.x? See MIGRATION.md — the zero-effort step is one line.
npm install pesepayTwo calls: create the transaction, send the customer to redirectUrl.
import { Pesepay } from 'pesepay';
const { PESEPAY_INTEGRATION_KEY, PESEPAY_ENCRYPTION_KEY } = process.env;
if (!PESEPAY_INTEGRATION_KEY || !PESEPAY_ENCRYPTION_KEY) {
throw new Error('Pesepay keys are missing from the environment.');
}
const pesepay = new Pesepay({
integrationKey: PESEPAY_INTEGRATION_KEY,
encryptionKey: PESEPAY_ENCRYPTION_KEY,
resultUrl: 'https://example.com/pesepay/webhook',
returnUrl: 'https://example.com/checkout/done',
});
const { referenceNumber, pollUrl, redirectUrl } = await pesepay.initiateTransaction({
amount: 10.5,
currencyCode: 'USD',
reasonForPayment: 'Order #1024',
});CommonJS works the same way:
const { Pesepay } = require('pesepay');resultUrl and returnUrl are both required by the gateway, and neither is
validated by it: a blank or missing one is silently replaced server-side with
the literal string "NONE", which produces a transaction whose outcome you are
never told about. This package rejects them up front instead.
The hosted flow. Pesepay presents the payment page; you get a redirectUrl.
const transaction = await pesepay.initiateTransaction({
amount: 10.5,
currencyCode: 'USD',
reasonForPayment: 'Order #1024',
merchantReference: 'order-1024',
paymentMetadata: { orderId: '1024', customerId: '77' },
});
await saveOrder({
referenceNumber: transaction.referenceNumber,
pollUrl: transaction.pollUrl,
redirectUrl: transaction.redirectUrl,
});Important
redirectUrl exists only here. The gateway declares one on transaction
results but has it commented out, so no later poll, check or webhook hands it
back. Store it with the reference number if you need to re-send a customer to
the payment page.
merchantReference is echoed back on every result for that transaction, and
paymentMetadata comes back as transactionMetadata — both are how you find
your own order again without a lookup table.
Pass paymentMethodCode to skip Pesepay's method picker and land the customer
straight on, say, EcoCash.
The customer never leaves your site: you collect the details and the gateway charges directly. There is no redirect and no hosted page.
const result = await pesepay.makeSeamlessPayment({
amount: 5,
currencyCode: 'USD',
paymentMethodCode: 'PZW211',
reasonForPayment: 'Order #1024',
customer: { email: 'customer@example.com', phoneNumber: '0771111111' },
requiredFields: { customerPhoneNumber: '0771111111' },
});
log.info(result.transactionStatus, result.paid, result.isTerminal);Three things to get right:
customeris mandatory. At least one ofemailorphoneNumbermust be set. The gateway dereferences the customer without a null check, so omitting it answers500with aNullPointerExceptionrather than a useful message — this package refuses the call before it leaves your process.requiredFieldsis keyed by each field's wirename, which you read from the payment method'srequiredFields— not by itsdisplayName.- The result is usually not terminal. A mobile-money charge comes back
PENDINGwhile the customer's handset is still showing the prompt. Keep polling.
Not every method can be charged this way: a method whose redirectRequired is
true must go through the hosted flow.
checkPayment(referenceNumber) and pollTransaction(pollUrl) both return the
same PaymentResult. Use whichever handle you kept.
import { isPaid, TransactionStatus } from 'pesepay';
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
let result = await pesepay.checkPayment(referenceNumber);
for (let attempt = 0; attempt < 20 && !result.isTerminal; attempt += 1) {
await sleep(3_000);
result = await pesepay.checkPayment(referenceNumber);
}
if (result.paid) {
log.info('paid', result.amountDetails?.merchantAmount);
} else if (result.isTerminal) {
log.warn('not paid', result.transactionStatus, result.transactionStatusDescription);
} else {
log.warn('still pending after 20 attempts', result.referenceNumber);
}
// The same two questions, answered from a raw status string — a queue message,
// say, or a row read back out of your database.
log.info(isPaid(TransactionStatus.SUCCESS));Every result carries paid and isTerminal as plain data, so a result survives
JSON.stringify, structuredClone and a trip through a queue with both answers
intact.
paidistrueonly forSUCCESS. Not forPARTIALLY_PAID(money arrived, but not the amount you asked for) and not forREVERSED(you were paid, and then you were not).isTerminalisfalsefor exactly four statuses —INITIATED,PROCESSING,PENDING,PARTIALLY_PAID. A status Pesepay adds in future is treated as terminal, so a poll loop stops rather than spinning forever.
Reconcile against amountDetails.merchantAmount, which is what settles to you.
The gateway computes a fee split rather than echoing what you sent, so
amountDetails.amount and customerPayableAmount are different numbers.
| status | code | terminal | paid |
|---|---|---|---|
INITIATED |
301 | ||
PROCESSING |
302 | ||
PENDING |
303 | ||
PARTIALLY_PAID |
315 | ||
SUCCESS |
304 | ✅ | ✅ |
FAILED |
300 | ✅ | |
TERMINATED |
305 | ✅ | |
TIME_OUT |
306 | ✅ | |
CLOSED |
307 | ✅ | |
INSUFFICIENT_FUNDS |
308 | ✅ | |
CANCELLED |
309 | ✅ | |
ERROR |
310 | ✅ | |
DECLINED |
311 | ✅ | |
AUTHORIZATION_FAILED |
312 | ✅ | |
SERVICE_UNAVAILABLE |
313 | ✅ | |
REVERSED |
314 | ✅ | |
CLOSED_PERIOD_ELAPSED |
307 | ✅ |
CLOSED and CLOSED_PERIOD_ELAPSED share code 307, so a status code cannot
be mapped back to a status. Branch on transactionStatus; treat
transactionStatusCode as something to log.
TRANSACTION_STATUS_CODES and TRANSACTION_STATUS_DESCRIPTIONS hold the
gateway's own numbers and wording if you need to display them.
Pesepay POSTs the transaction result to your resultUrl whenever the status
reaches a terminal value. parseCallback decodes that body and tells you
whether the request carried your integration key.
app.post('/pesepay/webhook', express.json(), async (req, res) => {
// 1. Answer immediately. There are no retries — a slow or failing
// response loses the notification permanently.
res.sendStatus(200);
const { result, keyVerified } = pesepay.parseCallback(req.body, req.headers);
if (!keyVerified) log.warn('unverified pesepay callback', result.referenceNumber);
// 2. Re-verify over the authenticated, encrypted API before acting.
const confirmed = await pesepay.checkPayment(result.referenceNumber);
// 3. Be idempotent on referenceNumber + transactionStatus.
await creditOnce(confirmed.referenceNumber, confirmed.transactionStatus, confirmed);
});Each step is forced by something the gateway does. This is not defensive boilerplate; drop any one of the three and you have a specific, reproducible bug.
The callback is not authenticated. There is no HMAC and no signature — not a
weak one, none. The only credential is an Authorization header holding your
integration key verbatim. So:
| step | the property that forces it |
|---|---|
| Respond first, before any work | The gateway posts once. It catches every exception, logs it, and moves on — no retries, no backoff, no dead-letter. A 500 from your handler, a timeout, a deploy mid-post, and the notification is gone for good. |
Re-verify through checkPayment |
The header is absent entirely when the gateway's own key lookup fails, and it posts the body anyway. So an unverified callback is a body anyone could have sent. And keyVerified: true proves only that the sender knew a key you also put in an outbound header on every API call — evidence, not proof, and it says nothing about the contents being untampered. checkPayment is encrypted, authenticated by your key, and answered by the gateway; it is the only channel here that establishes what happened. |
Be idempotent on referenceNumber + transactionStatus |
A reversal arrives as a second callback. The gateway posts on every terminal status change, so one transaction delivers SUCCESS and then, later, REVERSED. A handler keyed on the reference alone either ignores the reversal or double-credits the success. |
parseCallback returns keyStatus alongside keyVerified, separating the two
ways verification fails — they are different incidents and deserve different
alerts.
keyStatus |
meaning | what to do |
|---|---|---|
'matched' |
the header held your integration key | proceed to step 2 |
'absent' |
no Authorization header at all |
your own account is misconfigured — the gateway could not find an integration key for the application and posted anyway |
'mismatched' |
a header was present and held something else | a key you rotated and did not finish rolling out, or a request that did not come from Pesepay |
A missing header never throws: the gateway genuinely sends none, and a webhook endpoint that crashes on that is worse than one that records the fact.
The comparison is constant-time, and verbatim — no Bearer prefix is stripped
and nothing is trimmed, because the gateway sets the header to the raw key and
leniency would only widen what counts as a match.
The body as a parsed object (express.json()), a JSON string, or the raw
Buffer. Unlike every other response from this gateway the callback is plain,
unencrypted JSON — not the { payload } envelope — so nothing is decrypted,
and a body that is an envelope is rejected with that explanation rather than
quietly mis-parsed. headers is optional and matched case-insensitively; Node's
req.headers fits as-is.
Pesepay emails the payer a payment link and collects on your behalf. There is no
checkout to present and no redirectUrl to send anyone to.
const invoice = await pesepay.initiateInvoice({
amount: 250,
currencyCode: 'USD',
narrative: 'Consulting retainer, September',
payer: { name: 'Tendai Moyo', email: 'tendai@example.com' },
applicationCode: 'APP-CODE',
processingDate: '2026-09-15',
dueDate: '2026-09-30',
initiatorReference: 'retainer-2026-09',
});
const payment = await pesepay.checkInvoice(invoice.invoiceNumber);
log.info(invoice.invoiceNumber, invoice.pollUrl, payment.paid);Four things are unlike every other endpoint here:
applicationCodeis required. The gateway resolves the owning application from that field and never from your integration key — every other call in this SDK identifies you by the key header, this one does not. Omit it and the gateway answers500; this package refuses first.- Dates are
MM/DD/YYYYon the wire, parsed by a hand-written formatter rather than by Jackson, so ISO-8601 does not work there. ADate, an ISO'YYYY-MM-DD'string, or the gateway's own'MM/DD/YYYY'are all accepted here and converted. ADateis read in UTC —new Date('2026-09-15')is UTC midnight, and reading local components would make the due date a day early for everyone west of Greenwich. initiatorReferencemust be unique across your invoices; the gateway rejects a repeat. That makes it a usable idempotency key.invoiceNumberis the reference number. It is a zero-padded row id such as0001042, and it is the reference of the transaction created when the payer pays — which is whycheckInvoiceanswers with aPaymentResult, withpaidandisTerminalmeaning what they always mean.
recurring: true needs a recurringFrequency; without one the gateway asserts
rather than validates, and answers 500. The invoice's pollUrl carries
?invoiceNumber=, not ?referenceNumber= — it is still a valid argument to
pollTransaction.
What your account can transact in, and what it can be charged with. Both are plain, unencrypted, and public — this package deliberately sends no credential to them, because they do not ask for one.
const currencies = await pesepay.getActiveCurrencies();
const methods = await pesepay.getPaymentMethods('USD');
for (const method of methods) {
log.info(method.code, method.name, method.redirectRequired, method.minimumAmount);
for (const field of method.requiredFields ?? []) {
log.info(field.name, field.displayName, field.fieldType, field.optional);
}
}
const everything = await pesepay.getActivePaymentMethods();
log.info(currencies.map((currency) => currency.code), everything.length);Call getActiveCurrencies before a checkout rather than hard-coding a code: a
currency your account is not configured for is rejected at initiate time,
several steps further into a checkout than here.
getPaymentMethods(currencyCode) is the one to prefer. getActivePaymentMethods()
returns every active method across all currencies, and each still has to be
filtered against its own currencies array before it can be offered.
Check minimumAmount and maximumAmount client-side, and read requiredFields
to build the form a seamless charge needs.
Every failure is a throw, and every error extends PesepayError.
PesepayError
├── PesepayApiError the gateway answered, and it was not a 2xx
│ └── PesepayAuthError 403 or 404 — your integration key
├── PesepayCryptoError encrypt/decrypt failed. Fatal by design
├── PesepayNetworkError no response at all
│ └── PesepayTimeoutError
└── PesepayConfigError bad configuration or bad arguments. Thrown before any socket
import { PesepayApiError, PesepayAuthError, PesepayConfigError, PesepayTimeoutError } from 'pesepay';
try {
await pesepay.makeSeamlessPayment({
amount: 5,
currencyCode: 'USD',
paymentMethodCode: 'PZW211',
reasonForPayment: 'Order #1024',
customer: { phoneNumber: '0771111111' },
});
} catch (error) {
if (error instanceof PesepayTimeoutError) {
// Not a failed payment. See below.
log.warn('timed out after', error.timeoutMs);
} else if (error instanceof PesepayAuthError) {
// 404 = the key is unknown, 403 = the key is disabled. Never retried.
log.error('integration key rejected', error.status);
} else if (error instanceof PesepayApiError && error.isEncryptionKeyMismatch()) {
log.error('the encryption key does not match this integration key');
} else if (error instanceof PesepayApiError && error.isRetryable()) {
log.warn('transient', error.status, error.serverMessage);
} else if (error instanceof PesepayConfigError) {
log.error('called wrongly', error.message);
} else {
throw error;
}
}Every error also carries a stable code — 'ERR_PESEPAY_AUTH' and so on — for
structured logs, worker boundaries, and anywhere instanceof cannot reach.
No error here carries key material, in its message, its properties or its
cause. The gateway's own words are redacted before they become an error
message, because errors end up in log aggregators.
The usual rule — 4xx is your fault, 5xx is worth retrying — is wrong against this gateway. These mappings are read off the server, not guessed.
| status | what it actually means | class | isRetryable() |
|---|---|---|---|
400 |
often just an unhandled server-side exception, not a validation failure | PesepayApiError |
false |
403 |
your integration key exists but is disabled | PesepayAuthError |
false |
404 |
your integration key is unknown — not "no such endpoint" | PesepayAuthError |
false |
408, 429 |
genuinely transient | PesepayApiError |
true |
500 "Failed to decrypt your data" |
your encryption key is wrong — the integration key is fine | PesepayApiError |
false |
other 5xx |
the gateway is unwell | PesepayApiError |
true |
The two that cost the most time:
- A
404sends people hunting for a typo in a URL. It means the key in yourkeyheader is not one the gateway knows. Check the credential, and check that it belongs to the environment you are pointed at — a live key against the sandbox is a404. - The
500sends people rotating the wrong credential. It is the other key.PesepayApiError.isEncryptionKeyMismatch()picks it out, and this is also why the status is always checked before anything is decrypted: decrypting first would turn "your integration key is unknown" into a padding error.
Use isRetryable() rather than writing the table into your own code. It answers
true for 408, 429 and 5xx other than the decryption failure, and false
for everything else — a 403/404 is a key that will still be wrong in ten
minutes, and retrying a key mismatch is a loop.
Caution
A PesepayTimeoutError means you did not hear back. It does not mean the
transaction did not happen. The gateway may have taken the payment and
answered too slowly, or answered into a dropped socket.
Recover with checkPayment(referenceNumber). Never by re-initiating — that
risks charging the customer twice.
import { PesepayTimeoutError } from 'pesepay';
try {
await pesepay.makeSeamlessPayment({
amount: 5,
currencyCode: 'USD',
paymentMethodCode: 'PZW211',
reasonForPayment: 'Order #1024',
customer: { phoneNumber: '0771111111' },
merchantReference: 'order-1024',
});
} catch (error) {
if (!(error instanceof PesepayTimeoutError)) throw error;
// Ask what actually happened. Retrying the charge could take the money twice.
const actual = await pesepay.checkPayment(referenceNumber);
log.warn('recovered after timeout', actual.transactionStatus, actual.paid);
}The same applies to a timed-out initiateTransaction — except that you have no
reference number yet, which is why merchantReference is worth setting on every
call. The transport knows this too: it retries nothing on a timeout or a
refused connection, precisely because replaying a POST to /initiate risks a
double charge.
import { Pesepay, DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS, VERSION } from 'pesepay';
const sandbox = new Pesepay({
integrationKey: 'your-integration-key',
encryptionKey: '0123456789abcdef0123456789abcdef',
resultUrl: 'https://example.com/pesepay/webhook',
returnUrl: 'https://example.com/checkout/done',
baseUrl: 'https://api.test.pesepay.com/api/payments-engine',
timeoutMs: 15_000,
});
log.info(sandbox.baseUrl, sandbox.timeoutMs, DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS, VERSION);| option | default | notes |
|---|---|---|
integrationKey |
— | required. Sent as the key header |
encryptionKey |
— | required. Exactly 32 ASCII characters, validated eagerly — a bad key throws PesepayConfigError before any socket |
resultUrl |
— | where the gateway POSTs results. Required by initiateTransaction, makeSeamlessPayment and initiateInvoice |
returnUrl |
— | where the customer lands after the hosted page |
timeoutMs |
30_000 |
total budget per call |
baseUrl |
https://api.pesepay.com/api/payments-engine |
point at api.test.pesepay.com for sandbox |
transport |
httpsTransport |
the HTTP seam — see below |
resultUrl and returnUrl are also settable properties, and each call can
override them for itself. The v1 positional form still constructs the same
class:
import { Pesepay } from 'pesepay';
const pesepay = new Pesepay('INTEGRATION KEY', '0123456789abcdef0123456789abcdef');
pesepay.resultUrl = 'https://example.com/pesepay/webhook';
pesepay.returnUrl = 'https://example.com/checkout/done';JSON.stringify(pesepay) publishes neither key: both are #private fields,
which are invisible to it.
HTTP is a single injectable function, so you can route through a proxy, add instrumentation or retries, or drive the client from a test double with no socket at all.
import { createHttpsTransport, httpsTransport, Pesepay, type Transport } from 'pesepay';
const timed: Transport = async (request) => {
const started = Date.now();
const response = await httpsTransport(request);
log.info(request.method, request.url, response.status, Date.now() - started);
return response;
};
const strict = new Pesepay({
integrationKey: 'your-integration-key',
encryptionKey: '0123456789abcdef0123456789abcdef',
transport: createHttpsTransport({ allowInsecureHttpParserFallback: false }),
});
log.info(strict.baseUrl, timed);Note
Why this package does not use fetch. api.pesepay.com emits a
Strict-Transport-Security header whose value contains a literal newline, so
its header block carries a bare LF where HTTP/1.1 requires CRLF. Node's strict
parser rejects every such response with HPE_CR_EXPECTED, and no undici
option relaxes it — a fetch-based SDK cannot talk to production at all.
This package tries the strict parser first and retries once with
insecureHTTPParser on a parse error only, replaying the body. That keeps
response-smuggling protection on by default, warns once per process when the
fallback is used, and self-heals the day the header is fixed. A timeout or a
refused connection is never retried. createHttpsTransport({ allowInsecureHttpParserFallback: false })
turns the fallback off if your policy forbids the lenient parser.
The real fix is one line of nginx on the gateway:
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
The transport refuses to send your integration key over cleartext HTTP to anything but loopback.
| method | what it does |
|---|---|
initiateTransaction(options) |
creates a transaction; returns referenceNumber, pollUrl, redirectUrl |
makeSeamlessPayment(options) |
charges a method directly; returns a PaymentResult |
checkPayment(referenceNumber) |
reads a transaction's current state |
pollTransaction(pollUrl) |
the same, from the URL the gateway handed back |
initiateInvoice(options) |
creates an invoice; Pesepay emails the payer |
checkInvoice(invoiceNumber) |
that invoice's payment, as a PaymentResult |
getActiveCurrencies() |
currencies your account can transact in |
getPaymentMethods(currencyCode) |
methods for one currency |
getActivePaymentMethods() |
every active method, all currencies |
parseCallback(body, headers?) |
decodes a resultUrl POST and verifies the key |
Also exported: TransactionStatus, isPaid, isTerminal,
isTransactionStatus, TERMINAL_TRANSACTION_STATUSES,
NON_TERMINAL_TRANSACTION_STATUSES, TRANSACTION_STATUS_CODES,
TRANSACTION_STATUS_DESCRIPTIONS, the seven error classes, DEFAULT_BASE_URL,
DEFAULT_TIMEOUT_MS, VERSION, createHttpsTransport, httpsTransport, and
the types for all of it.
The published type declarations keep their documentation, so your editor's hover is the full reference for every option and every field.
const { Pesepay } = require('pesepay');becomes
const { Pesepay } = require('pesepay/v1-compat');and nothing else changes. That compatibility layer reports only paid, though,
so it cannot tell PENDING from DECLINED from REVERSED — it is a migration
ramp, not a destination. MIGRATION.md has the per-method
before and after, and what the layer deliberately does not reproduce.
npm install
npm run verify # lint, typecheck, build, test, publint, are-the-types-wrongEvery code block in this file is extracted and compiled against the built
package by test/docs/snippets.test.mts, so a snippet that does not typecheck
fails the build.
MIT. See LICENSE.