Skip to content

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.

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/node

Create 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.

lib/wajub.ts
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,
});
OptionWhat it does
secretKeyYour sk. or sk_test. key. apiKey is the accepted alias
webhookSecretThe whsec_ secret, needed only for webhooks.constructEvent()
fetchOptionsA RequestInit merged into every call, for a proxy dispatcher or a dev certificate
idempotencyKeyPrefixPrefix of the generated Idempotency-Key. Defaults to wajub
timeoutPer 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

Create a payment
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

GetterMethods
wajub.globalping, channels, countries, currencies
wajub.paymentscreate, initialize, retrieve, list, cancel, process, processSplit, listRefunds
wajub.customerscreate, retrieve, update, delete, list, block, unblock, activate, deactivate, listTaxIds, createTaxId, deleteTaxId
wajub.refundscreate, retrieve, list
wajub.transferscreate, retrieve, list
wajub.beneficiariescreate, retrieve, update, delete, list
wajub.linkscreate, retrieve, update, delete, list
wajub.invoicescreate, retrieve, update, delete, list, send, markPaid, cancel
wajub.accountscreate, retrieve, update, delete, list, regenerateToken
wajub.webhookEndpointscreate, retrieve, update, delete, list, rotateSecret
wajub.balanceretrieve
wajub.eventslist, retrieve, resend
wajub.disputeslist, retrieve, submitEvidence, accept, close, sendMessage
wajub.identityresolve, validate
wajub.taxgetSettings, updateSettings, rates, calculate, reports, listCodes, retrieveCode, listRegistrations, createRegistration, retrieveRegistration, updateRegistration, deleteRegistration, jurisdictions, thresholds, thresholdAlerts
wajub.shieldgetSettings, updateSettings, stats, listBlocklist, addToBlocklist, removeFromBlocklist
wajub.listenconfig, auth
wajub.webhooksconstructEvent
  • webhooks signature verification runs locally, no HTTP call. All other resources call the merchant REST API.
  • links, invoices, tax and shield are live only. A sandbox key gets 403 This feature is only available in live mode. on every one of their methods.
  • refunds and transfers are create, retrieve and list only. The shared CRUD base also exposes update and delete on 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.

Two ways to walk a list
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.

Your own key beats the generated one
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.

WhatValue
Retried statuses429, 500, 502, 503, 504, and any network failure
Attempts4 in total, one original plus three retries
Backoff500 ms, doubling, plus up to 30 percent jitter
Retry-AfterHonoured when the API sends it on a 429
GET and DELETEAlways retried, they are idempotent by definition
POST and PUTRetried only because they carry an idempotency key

Acting for a connected account

Marketplaces pass a connected account per call rather than holding a second client.

One call, one seller
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);
    }
  },
);

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

Narrow before you read
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;
}
ClassRaised on
WajubAuthenticationError401 and 403
WajubInvalidRequestError400, 404 and 422
WajubPaymentError402
WajubRateLimitError429, with retryAfter in seconds
WajubApiErrorEvery other status
WajubConnectionErrorNo response at all, network or timeout
WebhookSignatureVerificationErrorA 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.

What you will actually import
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.

Charging without the hosted page

When you collect the payer's details yourself, create the payment first and then process it on a channel.

Mobile Money push
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

A transfer to a phone number
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.

Two terminals
wajub listen --forward-to localhost:3000/webhooks/wajub
wajub trigger payment.succeeded

More on the CLI.

What did you think of this content?