SDKs and tools

Node.js / TypeScript SDK

Install and use @labyrinthe/labpay: dependency-free TypeScript client, native fetch, ESM and CommonJS, webhooks with Webhooks.constructEvent.

Official V2 SDK for Node.js 18+: native fetch, no runtime dependency, ESM and CommonJS builds with type declarations. The core only uses Fetch and Web Crypto: it also runs on Bun, Deno and edge runtimes — server-side only, never in a browser.

Version 0.1.0 (preview). Source code, examples, changelog and full README: node folder on GitHub.

Installation#

HTTP
npm install @labyrinthe/labpay
Coming soon

This SDK is not published on npm yet: the command above will work once it is released. Until then, install it from the SDK GitHub repository, as shown below.

From the repository: build the package (dist/ is not versioned), then install it by path.

HTTP
git clone https://github.com/Labyrinthe-Team/labyrinthe-pay-packages.git
cd labyrinthe-pay-packages/node && npm install && npm run build && cd -
npm install ../labyrinthe-pay-packages/node

Configuration#

Node.js
import { LabPay } from '@labyrinthe/labpay';

// Depuis l'environnement : LABPAY_API_KEY (et LABPAY_BASE_URL)
const labpay = new LabPay();

// Ou explicitement
const client = new LabPay({
  apiKey: 'test_xxxxxxxxxxxxxxxx',
  baseUrl: 'https://api.labyrinthe-rdc.com/api', // défaut ; "/V2" est ajouté
  timeoutMs: 30_000,
  maxRetries: 2,
  maxRetryWaitMs: 10_000,
  autoReference: true,
  fetch: customFetch, // facultatif (proxy, tests)
});

client.lastResponse; // statut et en-têtes de la dernière réponse (mode = X-Labyrinthe-Mode)
OptionDefaultRole
apiKeyLABPAY_API_KEYLive token or test_… test key.
baseUrlLABPAY_BASE_URLAPI root; /V2 is added.
timeoutMs30000Milliseconds, per attempt.
maxRetries / maxRetryWaitMs2 / 10000Safe retries and longest Retry-After wait.
autoReferencetrueGenerates sdk_<uuid> when reference is absent.
fetchglobal fetchfetch implementation to use.

Responses are mapped to camelCase objects (totalAmount, gatewayStatus…); the raw payment stays in payment.raw. Every method accepts { signal } (AbortSignal) as last argument. Helpers: isCompleted(p), isFailed(p), isFinal(p), isQueued(p).

First collection (test mode)#

With the test key and the test number 0813456789 (success), no real money moves. The payment stays pending until the customer confirms; only deliver on completed.

POST /V2/deposits
import { LabPay } from '@labyrinthe/labpay';

const labpay = new LabPay({ apiKey: 'test_xxxxxxxxxxxxxxxx' }); // ou new LabPay() (LABPAY_API_KEY)

const payment = await labpay.deposits.create({
  amount: '1000',
  currency: 'CDF',
  phone: '0813456789',
  reference: 'ORDER-42', // facultatif : sinon le SDK génère sdk_<uuid>
  callbackUrl: 'https://boutique.example/webhooks/labpay',
  customerMessage: 'Commande 42',
  metadata: { orderId: '42' },
});

console.log(payment.id, payment.status); // DEP-… pending

Without reference, the SDK generates one (sdk_<uuid>): a resend after a network failure returns the same payment.

Automatic reference
import { LabPay } from '@labyrinthe/labpay';

const labpay = new LabPay({ apiKey: 'test_xxxxxxxxxxxxxxxx' }); // ou new LabPay() (LABPAY_API_KEY)

// Sans reference : le SDK envoie sdk_<uuid> et peut relancer sans risque de double paiement.
const payment = await labpay.deposits.create({ amount: '1000', currency: 'CDF', phone: '0813456789' });
console.log(payment.reference); // sdk_…

// Désactiver : new LabPay({ apiKey: 'test_xxxxxxxxxxxxxxxx', autoReference: false })

Track and synchronise#

The final status arrives by notification. As a safety net, read the payment, or force a refresh from LabPay with sync (without a tight loop).

GET /V2/payments/{id}
import { LabPay, isCompleted } from '@labyrinthe/labpay';

const labpay = new LabPay({ apiKey: 'test_xxxxxxxxxxxxxxxx' }); // ou new LabPay() (LABPAY_API_KEY)

const payment = await labpay.payments.get('DEP-01JCX8Q3T6Y0V5W1Z2A3B4C5D6');
if (isCompleted(payment)) {
  // livrer la commande payment.reference
}
POST /V2/payments/{id}/sync
import { LabPay } from '@labyrinthe/labpay';

const labpay = new LabPay({ apiKey: 'test_xxxxxxxxxxxxxxxx' }); // ou new LabPay() (LABPAY_API_KEY)

const payment = await labpay.payments.sync('DEP-01JCX8Q3T6Y0V5W1Z2A3B4C5D6'); // relit le statut tout de suite
GET /V2/payments (list, every page, by reference)
import { LabPay } from '@labyrinthe/labpay';

const labpay = new LabPay({ apiKey: 'test_xxxxxxxxxxxxxxxx' }); // ou new LabPay() (LABPAY_API_KEY)

const page = await labpay.payments.list({ status: 'completed', operation: 'deposit', perPage: 50 });
for (const payment of page.data) console.log(payment.id, payment.amount);

// Toutes les pages, à la demande :
for await (const payment of labpay.payments.iterate({ status: 'completed' })) { /* … */ }

// Par votre référence (null si inconnue) :
const found = await labpay.payments.findByReference('ORDER-42');

Send money#

Your balance is debited with amount + fees at initiation, and credited back if the payout fails. A queued payout (ENQUEUED) can be cancelled.

POST /V2/payouts
import { InsufficientBalanceError, LabPay } from '@labyrinthe/labpay';

const labpay = new LabPay({ apiKey: 'test_xxxxxxxxxxxxxxxx' }); // ou new LabPay() (LABPAY_API_KEY)

try {
  const payout = await labpay.payouts.create({
    amount: '5000',
    currency: 'CDF',
    phone: '0813456789',
    reference: 'PAYOUT-17',
    callbackUrl: 'https://boutique.example/webhooks/labpay',
    metadata: { payoutId: '17' },
  });
  console.log(payout.id, payout.totalAmount); // montant + frais débités
} catch (error) {
  if (error instanceof InsufficientBalanceError) {
    // rien n'a été envoyé
  } else throw error;
}
POST /V2/payouts/{id}/cancel
import { LabPay, isQueued } from '@labyrinthe/labpay';

const labpay = new LabPay({ apiKey: 'test_xxxxxxxxxxxxxxxx' }); // ou new LabPay() (LABPAY_API_KEY)

let payout = await labpay.payments.get('PAY-01JCX9A1B2C3D4E5F6G7H8J9K0');
if (isQueued(payout)) { // envoi ENQUEUED : annulable
  payout = await labpay.payouts.cancel(payout.id);
}

Refund#

All or part of a completed deposit, to the wallet that paid; only one refund in progress at a time per deposit.

POST /V2/refunds
import { LabPay } from '@labyrinthe/labpay';

const labpay = new LabPay({ apiKey: 'test_xxxxxxxxxxxxxxxx' }); // ou new LabPay() (LABPAY_API_KEY)

const refund = await labpay.refunds.create({
  paymentId: 'DEP-01JCX8Q3T6Y0V5W1Z2A3B4C5D6',
  amount: '300', // facultatif : par défaut, tout le reste remboursable
  reference: 'REFUND-42-1',
});

Providers, prediction and balances#

POST /V2/predict-provider
import { LabPay } from '@labyrinthe/labpay';

const labpay = new LabPay({ apiKey: 'test_xxxxxxxxxxxxxxxx' }); // ou new LabPay() (LABPAY_API_KEY)

const prediction = await labpay.providers.predict({ phone: '0813456789', country: 'COD' });
console.log(prediction.provider); // VODACOM_MPESA_COD
GET /V2/providers
import { LabPay } from '@labyrinthe/labpay';

const labpay = new LabPay({ apiKey: 'test_xxxxxxxxxxxxxxxx' }); // ou new LabPay() (LABPAY_API_KEY)

const providers = await labpay.providers.list({ operation: 'payout', country: 'COD' });
GET /V2/balances
import { LabPay } from '@labyrinthe/labpay';

const labpay = new LabPay({ apiKey: 'test_xxxxxxxxxxxxxxxx' }); // ou new LabPay() (LABPAY_API_KEY)

const balances = await labpay.account.balances();
const cdf = await labpay.account.balance('CDF'); // null si aucun solde dans cette devise
console.log(cdf?.balance); // "125000.00"

Also: open countries (GET /V2/countries), availability (GET /V2/availability), account (GET /V2/account) and rotations of the signing secret and test key (live token). Examples on Providers and prediction and Account and balances.

Error handling#

Every error extends LabPayError; API errors extend ApiError (code: error.code, HTTP status, per-field errors, Retry-After, mode). A payment refused at initiation is not an error: it is returned with the failed status. Full list of codes: Errors.

Node.js
import { ApiError, ConnectionError, LabPay, ServiceUnavailableError, ValidationError, isFailed } from '@labyrinthe/labpay';

const labpay = new LabPay({ apiKey: 'test_xxxxxxxxxxxxxxxx' }); // ou new LabPay() (LABPAY_API_KEY)

try {
  const payment = await labpay.deposits.create({ amount: '1000', currency: 'CDF', phone: '0813456789', reference: 'ORDER-42' });
  if (isFailed(payment)) {
    // refusé dès l'initiation (422 avec le paiement) : renvoyé, pas levé
    console.log(payment.failure?.code);
  }
} catch (error) {
  if (error instanceof ValidationError) console.log(error.errors); // messages par champ
  else if (error instanceof ServiceUnavailableError) console.log(error.code, error.retryAfter); // MAINTENANCE…
  else if (error instanceof ConnectionError) { /* PAS un échec : renvoyer la même référence */ }
  else if (error instanceof ApiError) console.log(error.code); // toujours le code, jamais le message
  else throw error;
}
Code or caseClass
UNAUTHENTICATED (401)AuthenticationError
TOKEN_BLOCKED, MERCHANT_INACTIVE, KYC_REQUIRED, LIVE_TOKEN_REQUIRED (403)PermissionError
PAYMENT_NOT_FOUND, DEPOSIT_NOT_FOUND, NOTIFICATION_NOT_FOUND (404)NotFoundError
NOT_A_PAYOUT, NOT_CANCELLABLE, DEPOSIT_NOT_COMPLETED, REFUND_IN_PROGRESS, DEPOSIT_ALREADY_REFUNDED (409)ConflictError
VALIDATION_ERROR (422)ValidationError
INVALID_PHONE_NUMBERInvalidPhoneNumberError
AMOUNT_TOO_SMALL, AMOUNT_TOO_LARGE, AMOUNT_OUT_OF_BOUNDSAmountOutOfRangeError
INSUFFICIENT_BALANCEInsufficientBalanceError
INVALID_CURRENCY, UNSUPPORTED_COUNTRY, UNSUPPORTED_PROVIDER, INVALID_AMOUNTInvalidRequestError
429 (rate limit) / DAILY_LIMIT_REACHEDRateLimitError / DailyLimitReachedError
MAINTENANCE, API_DISABLED, DEPOSITS_DISABLED, WITHDRAWALS_DISABLED, PROVIDER_TEMPORARILY_UNAVAILABLE, GATEWAY_UNAVAILABLE, V2_DISABLED, TEST_MODE_DISABLED, other 5xxServiceUnavailableError
Network error or timeoutConnectionError / AbortedError
Invalid input, detected before any callInvalidArgumentError
Notification with an invalid signature or bodySignatureVerificationError / InvalidWebhookPayloadError

The API_VERSION_DISABLED and FEATURE_DISABLED codes (version or operation switched off from the console, 503) are newer than SDK 0.1.0: they arrive as an ApiError of the 503 (service unavailable) class, with their code intact. Do not retry in a loop: see Suspended features.

Receive notifications (webhooks)#

Webhooks.constructEvent(rawBody, header, secret) verifies the signature on the raw body (string or bytes: with Express, express.raw()), then returns the typed event (event.event, event.payment). An array of secrets is accepted during a rotation; Webhooks.sign(body, secret) builds a header for your tests.

Receiving endpoint (callback_url)
import { WEBHOOK_HEADERS, Webhooks } from '@labyrinthe/labpay';

// Next.js App Router : app/api/webhooks/labpay/route.ts (Express : express.raw())
export async function POST(request: Request) {
  const rawBody = await request.text(); // corps BRUT
  try {
    const event = await Webhooks.constructEvent(
      rawBody,
      request.headers.get(WEBHOOK_HEADERS.signature),
      process.env.LABPAY_WEBHOOK_SECRET!, // whsec_xxxxxxxxxxxxxxxx
    );
    if (event.event === 'payment.completed') {
      // livrer event.payment.reference, une seule fois (X-Labyrinthe-Delivery ou payment.id)
    }
  } catch {
    return new Response(null, { status: 400 }); // signature ou corps invalide
  }
  return new Response(null, { status: 200 }); // en moins de 10 secondes
}
  • The whsec_… secret is read on GET /V2/account with the live token (LABPAY_WEBHOOK_SECRET variable).
  • Invalid signature or body: answer 400. Otherwise 200 within 10 seconds, long work afterwards.
  • The same notification can arrive more than once: deduplicate on X-Labyrinthe-Delivery or the payment id.

History and resend#

GET /V2/notifications, POST /V2/notifications/{id}/resend
import { LabPay } from '@labyrinthe/labpay';

const labpay = new LabPay({ apiKey: 'test_xxxxxxxxxxxxxxxx' }); // ou new LabPay() (LABPAY_API_KEY)

const page = await labpay.notifications.list({ status: 'abandoned' });
for (const notification of page.data) {
  await labpay.notifications.resend(notification.id); // nouvelle notification, historique conservé
}

Shared behaviour#

  • `test_…` key = sandbox: no real money, separate balances and payments. Going live = switching keys.
  • Automatic reference: without reference, the SDK sends sdk_<uuid>, so that a deposit, payout or refund can be retried after a network failure or a 5xx without double payment (idempotency). Can be disabled (autoReference / auto_reference / AutoReference).
  • A refused payment is a payment: a 422 carrying the payment (refused at initiation) is returned with status: failed and failure, not thrown; a 202 is a pending payment.
  • Errors typed by `code` (then by HTTP status), with per-field validation errors; an unknown code stays readable. Base your logic on the code, never on the message (in French).
  • Safe retries (2 by default): reads and payments with a reference, 429 after Retry-After, maintenance-type refusals (503) only after a Retry-After; never DAILY_LIMIT_REACHED. Rotations, notification resends, payout cancellations and payments without reference are never retried after a network error or a 5xx.
  • Local validation before any request (amount, currency, number, reference, URL…): invalid input raises an argument error with no network call.
  • Verified webhooks: constructEvent checks X-Labyrinthe-Signature on the raw body, in constant time, with a 300 s tolerance and both secrets during a rotation.
  • Secure by default: the key never shows up in logs, dumps or exception messages.

See also: all SDKs and tools, V2 endpoint reference, error codes, Postman and OpenAPI.