Node.js
The @wajub/node server SDK, its resources, and the TypeScript it ships with.
@wajub/node is the server side of a Wajub integration. It holds your secret key, creates
payments, reads their real status, and verifies webhook signatures. It never runs in a browser.
@wajub/node
Stable · GAnpm
- Version
- 1.1.1
- Runtime
- Node.js 18+, Bun, Deno, any runtime with fetch
Covers
- Payments
- Billing
- Transfers
- Sync
- Shield
- Tax
The package is ESM and CommonJS at once, ships its own types, and has no runtime dependency. It
calls fetch, which is why Node 18 is the floor and why the same build runs on Bun, on Deno and
on an edge runtime.
Install
npm install @wajub/nodeCreate the client once
Build it in a module and import that module everywhere. A second instance is not harmful, it is just a second set of connections for nothing.
import { Wajub } from '@wajub/node';
if (!process.env.WAJUB_SECRET_KEY) {
throw new Error('WAJUB_SECRET_KEY is missing');
}
export const wajub = new Wajub({
secretKey: process.env.WAJUB_SECRET_KEY,
webhookSecret: process.env.WAJUB_WEBHOOK_SECRET,
});This is the one SDK with no environment fallback
Python, PHP, Go, Ruby, Java and C# all read WAJUB_API_KEY from the environment when you pass
nothing. @wajub/node does not. It throws Wajub: secretKey (or apiKey) is required, so read
the variable yourself, as above.
| Option | What it does |
|---|---|
secretKey | Your sk. or sk_test. key. apiKey is the accepted alias |
webhookSecret | The whsec_ secret, needed only for webhooks.constructEvent() |
fetchOptions | A RequestInit merged into every call, for a proxy dispatcher or a dev certificate |
idempotencyKeyPrefix | Prefix of the generated Idempotency-Key. Defaults to wajub |
timeout | Per request milliseconds. Defaults to 30000, overridable per call |
There is no baseUrl option. The SDK targets https://api.wajub.com and reads WAJUB_API_URL
to let you point a local stack at something else. It is the only Wajub SDK that honours that
variable, the other six hold the URL as a constant.
The first call
import { wajub } from '@/lib/wajub';
const payment = await wajub.payments.create({
amount: 25000,
currency: 'XAF',
email: 'amina@example.com',
description: 'Order 4172',
reference: 'order-4172',
callback: 'https://shop.example.com/complete',
});
// payment.authorization_url is where the customer pays.
// payment.authorization_token is what the browser SDK mounts.The response fields keep the API's snake_case, so authorization_url and created_at read the
same here as they do in the API reference. Only method names are
camelCase.
Amounts are in the major unit
25000 with XAF is twenty-five thousand francs. A decimal currency takes a decimal:
amount: 12.5 with GHS.
Every resource on the client
| Getter | Methods |
|---|---|
| wajub.global | ping, channels, countries, currencies |
| wajub.payments | create, initialize, retrieve, list, cancel, process, processSplit, listRefunds |
| wajub.customers | create, retrieve, update, delete, list, block, unblock, activate, deactivate, listTaxIds, createTaxId, deleteTaxId |
| wajub.refunds | create, retrieve, list |
| wajub.transfers | create, retrieve, list |
| wajub.beneficiaries | create, retrieve, update, delete, list |
| wajub.links | create, retrieve, update, delete, list |
| wajub.invoices | create, retrieve, update, delete, list, send, markPaid, cancel |
| wajub.accounts | create, retrieve, update, delete, list, regenerateToken |
| wajub.webhookEndpoints | create, retrieve, update, delete, list, rotateSecret |
| wajub.balance | retrieve |
| wajub.events | list, retrieve, resend |
| wajub.disputes | list, retrieve, submitEvidence, accept, close, sendMessage |
| wajub.identity | resolve, validate |
| wajub.tax | getSettings, updateSettings, rates, calculate, reports, listCodes, retrieveCode, listRegistrations, createRegistration, retrieveRegistration, updateRegistration, deleteRegistration, jurisdictions, thresholds, thresholdAlerts |
| wajub.shield | getSettings, updateSettings, stats, listBlocklist, addToBlocklist, removeFromBlocklist |
| wajub.listen | config, auth |
| wajub.webhooks | constructEvent |
webhookssignature verification runs locally, no HTTP call. All other resources call the merchant REST API.links,invoices,taxandshieldare live only. A sandbox key gets403 This feature is only available in live mode.on every one of their methods.refundsandtransfersare create, retrieve and list only. The shared CRUD base also exposesupdateanddeleteon them, but the API serves no such route.
Paging through a list
list() returns a page, not an array. It carries the rows, the metadata, and a way to get the
next one.
const page = await wajub.payments.list({ status: 'success', per_page: 50 });
// One page at a time, when you control the loop.
for (const payment of page.data) {
console.log(payment.id, payment.amount);
}
if (page.has_more) {
const next = await page.getNextPage();
}
// Or let it fetch the following pages for you.
for await (const payment of page) {
await reconcile(payment);
}page.meta carries total, per_page, current_page and last_page when the endpoint returns
them.
Idempotency and retries
Every POST and PUT leaves with an Idempotency-Key header, generated for you when you do not
supply one. That is what makes the automatic retries safe.
await wajub.payments.create(params, { idempotencyKey: `order-${orderId}` });A generated key protects a retry inside one call. Your own key protects a retry across process restarts, so use the order number whenever you have one.
| What | Value |
|---|---|
| Retried statuses | 429, 500, 502, 503, 504, and any network failure |
| Attempts | 4 in total, one original plus three retries |
| Backoff | 500 ms, doubling, plus up to 30 percent jitter |
Retry-After | Honoured when the API sends it on a 429 |
GET and DELETE | Always retried, they are idempotent by definition |
POST and PUT | Retried only because they carry an idempotency key |
Node.js retries more than the others
The other six SDKs stop after two retries and let you change that with
maxNetworkRetries. @wajub/node does three and exposes no option, so a request that keeps
failing takes longer here than in PHP or Go.
Acting for a connected account
Marketplaces pass a connected account per call rather than holding a second client.
await wajub.payments.create(
{ amount: 25000, currency: 'XAF', email: buyer.email },
{ sync: seller.wajubAccountId },
);The option becomes the X-Sync header. Setup and capabilities are on Sync.
Webhooks
constructEvent verifies the signature and gives you the parsed event. It needs the body exactly
as it arrived, byte for byte.
import express from 'express';
import { wajub } from './lib/wajub';
const app = express();
app.post(
'/webhooks/wajub',
express.raw({ type: 'application/json' }),
(req, res) => {
try {
const event = wajub.webhooks.constructEvent(
req.body,
req.header('x-wajub-signature')!,
req.header('x-wajub-timestamp')!,
);
if (event.event === 'payment.succeeded') {
void fulfil(event.data);
}
res.sendStatus(200);
} catch {
res.sendStatus(400);
}
},
);express.json() destroys the signature
The signature covers {timestamp}.{raw body}. Re-serialising a parsed object changes key order
and whitespace, so the hash no longer matches. Mount express.raw() on the webhook route only,
before any global JSON parser, or read req.text() in a route handler as the Next.js tab does.
The event name is in event, and the type says type
The delivered body carries the event name in a field called event, which is what the code
above reads. The exported WebhookEvent type declares { type, data } instead, so TypeScript
rejects event.event and happily accepts event.type, which is undefined at runtime. Until
the type is corrected, declare your own:
type WajubEvent = { id: string; event: string; data: Record<string, unknown> };
const event = wajub.webhooks.constructEvent(body, sig, ts) as unknown as WajubEvent;The tolerance is 300 seconds by default, and constructEvent takes a fourth argument if your
clocks drift further than that. Details on
Signature verification.
Errors
import {
WajubError,
WajubInvalidRequestError,
WajubRateLimitError,
} from '@wajub/node';
try {
await wajub.payments.create(params);
} catch (error) {
if (error instanceof WajubInvalidRequestError) {
return res.status(422).json({ fields: error.errors });
}
if (error instanceof WajubRateLimitError) {
return res.status(503).set('Retry-After', String(error.retryAfter ?? 5)).end();
}
if (error instanceof WajubError) {
logger.error({ code: error.code, status: error.httpStatus, raw: error.raw });
}
throw error;
}| Class | Raised on |
|---|---|
WajubAuthenticationError | 401 and 403 |
WajubInvalidRequestError | 400, 404 and 422 |
WajubPaymentError | 402 |
WajubRateLimitError | 429, with retryAfter in seconds |
WajubApiError | Every other status |
WajubConnectionError | No response at all, network or timeout |
WebhookSignatureVerificationError | A webhook that did not verify |
All of them extend WajubError and carry message, code, httpStatus, errors and raw. The
property is httpStatus, not status.
These names are specific to Node.js
The other six SDKs drop the Wajub prefix and split 403 and 404 into PermissionError and
NotFoundError instead. Catching the base class works everywhere.
TypeScript
The types ship inside the package, so there is no @types/wajub to install and nothing to
configure. Everything is exported.
import type {
CreatePaymentParams,
PaymentObject,
PaymentListParams,
CustomerObject,
RefundObject,
TransferObject,
InvoiceObject,
DisputeObject,
EventObject,
WebhookEvent,
PagedResult,
RequestOptions,
WajubConfig,
} from '@wajub/node';Objects carry an index signature, so a field the API adds tomorrow is readable today without a type error, at the cost of no completion on it.
Two fields in CreatePaymentParams do not reach the API
expires_in is typed but the endpoint reads expires.in, a nested object, so the flat field is
ignored and your session keeps the 24 hour default. items[].unit_price is typed and the schema
has no such column. Pass expires: { in: 60 } as an untyped extra field until the types catch
up.
Charging without the hosted page
When you collect the payer's details yourself, create the payment first and then process it on a channel.
const payment = await wajub.payments.create({
amount: 25000,
currency: 'XAF',
phone: '+237670000000',
});
const result = await wajub.payments.process(payment.id, {
channel: 'cm.mtn',
phone: '+237670000000',
});A channel is country.operator, so cm.mtn is MTN Mobile Money in Cameroon. The full list is on
Payment methods. The customer still has to approve on their handset, so the
outcome arrives on the webhook, not in result.
Paying out
const transfer = await wajub.transfers.create(
{
amount: 100000,
currency: 'XAF',
beneficiary: {
name: 'Amina Diallo',
channel: 'cm.mtn',
phone: '+237670000000',
},
reference: 'payout-892',
},
{ idempotencyKey: 'payout-892' },
);beneficiary also accepts the ben_… id of a saved beneficiary, which is the better shape once
you pay the same person twice.
Local development
The SDK always talks to the real API, so a sandbox key is what makes a call harmless. To receive webhooks on your machine, forward them with the CLI rather than exposing a tunnel.
wajub listen --forward-to localhost:3000/webhooks/wajub
wajub trigger payment.succeededMore on the CLI.
Related pages
- Wajub.jsThe browser half, mounted with the token this SDK returns.
- ReactProvider and components for a Next.js front end.
- WebhooksEvery event, and the delivery guarantees behind them.
- IdempotencyWhat a key protects and for how long.
- Error handlingWhich failures to retry and which to surface.
- API referenceThe endpoints behind every method above.