@hr-skills/pay
v0.2.0
Published
Official JavaScript/TypeScript SDK for HR-Skills Pay API — Mobile Money, Wallet, Airtime, Bills, Payroll, Virtual Cards and more.
Maintainers
Readme
@hr-skills/pay
Official JavaScript / TypeScript SDK for HR-Skills Pay — the B2B payment infrastructure for Africa. Collect and send Mobile Money across 16 countries, pay bills, top up airtime & data, run mass payroll, and issue virtual cards — with a single integration.
- Base URL:
https://api.hrskills-pay.com - API version:
v1· REST JSON - Fees:
1.5%Cash-In ·1%Cash-Out
Table of contents
- Why this SDK
- Installation
- Authentication model
- Sandbox vs Production
- Quick start (real demo)
- Sandbox walkthrough
- Configuration
- Countries & operators
- Modules reference
- Webhooks
- Error handling
- Events & middleware
- Token management
- TypeScript
- Development
Why this SDK
- ✅ TypeScript-first — full type safety and IntelliSense on every call.
- ✅ Automatic token management — the double-key handshake and 45-min JWT refresh are handled for you, with a mutex that collapses concurrent refreshes into one.
- ✅ Zod validation — invalid operators, currencies, phone numbers or amounts are caught before an API call is made.
- ✅ 16 markets out of the box — every country, operator and currency from API v1.
- ✅ Smart retry — exponential backoff on
429/5xx(configurable). - ✅ Idempotency built in — a UUID
Idempotency-Keyis attached to everyPOSTautomatically. - ✅ Universal runtime — Node.js 18+, Bun, Deno, Cloudflare Workers, Next.js, Nuxt.
- ✅ Stripe-style webhooks —
constructEvent()with constant-time signature verification. - ✅ Zero mandatory dependencies — only
zod.
Installation
npm install @hr-skills/pay
# or
yarn add @hr-skills/pay
# or
pnpm add @hr-skills/payRequires Node.js ≥ 18 (or any runtime with the Web Crypto & Fetch APIs).
Authentication model
HR-Skills Pay uses a double-key + transaction-token scheme. You never manage the token yourself — the SDK does — but it helps to understand what happens under the hood:
| Credential | Format | Role |
|---|---|---|
| Key A (public) | hrsk_pk_live_… / hrsk_pk_test_… | Sent as Authorization: Bearer on every request. |
| Key B (secret) | hrsk_sk_live_… / hrsk_sk_test_… | Exchanged once for a transaction token. Never expose it client-side. |
| Transaction token | JWT (HMAC-SHA256) | Sent as X-Transaction-Token. TTL 45 min (expires_in: 2700), auto-refreshed. |
You get both keys from Dashboard → Développeurs. Then:
import { HRSkillsPay } from "@hr-skills/pay";
const sdk = new HRSkillsPay({
publicKey: process.env.HRSKILLS_PUBLIC_KEY!, // Key A
secretKey: process.env.HRSKILLS_SECRET_KEY!, // Key B
});The SDK automatically:
- Calls
POST /v1/auth/transaction-tokenwith Key A + Key B on the first request. - Caches the JWT (in memory, and optionally on disk).
- Injects
Authorization,X-Transaction-TokenandIdempotency-Keyon every call. - Refreshes the token before it expires.
⚠️ Security: never commit your keys, and never instantiate the SDK in a browser or mobile app — Key B would be exposed. Always run payments from a secure backend. The SDK prints a warning if it detects a browser environment.
Sandbox vs Production
The SDK selects the environment from your key prefix — no flag needed.
| | Sandbox (TEST) | Production (LIVE) |
|---|---|---|
| Keys | hrsk_pk_test_… / hrsk_sk_test_… | hrsk_pk_live_… / hrsk_sk_live_… |
| Real money | No | Yes — real MTN / Orange / … debits |
| Requirement | None | KYC/KYB approved (else 403 KYC_NOT_APPROVED) |
| Outcome rule | Even amount → SUCCESS, odd amount → FAILED | Determined by the real customer confirmation |
This means you can deterministically test both success and failure paths in sandbox just by choosing the amount:
// Sandbox: this WILL succeed (amount is even)
await sdk.cashin.mobileMoney({ phoneNumber: "237655500393", operator: "ORANGE", amount: 5000 });
// Sandbox: this WILL fail (amount is odd)
await sdk.cashin.mobileMoney({ phoneNumber: "237655500393", operator: "ORANGE", amount: 5001 });Quick start (real demo)
A complete Cash-In flow — from balance check to final status — as you'd run it in production:
import { HRSkillsPay, WalletError } from "@hr-skills/pay";
const sdk = new HRSkillsPay({
publicKey: process.env.HRSKILLS_PUBLIC_KEY!, // hrsk_pk_live_…
secretKey: process.env.HRSKILLS_SECRET_KEY!, // hrsk_sk_live_…
logger: true,
});
async function collectPayment() {
// 1. (Optional) verify the account is active and has room
const { balance } = await sdk.wallet.balance();
console.log(`Available: ${balance.available} XAF`);
// 2. Initiate the collection → returns immediately with status PENDING
const tx = await sdk.cashin.mobileMoney({
phoneNumber: "237655500393", // country code, no "+"
operator: "ORANGE", // ORANGE · MTN · MOOV · AIRTEL · WAVE · …
country: "CM",
amount: 5000, // fee = 75 (1.5%), net_amount = 4925
currency: "XAF",
description: "Order #A-1024",
metadata: { orderId: "A-1024" }, // echoed back in the webhook
});
console.log(tx.reference); // "ref_d5b40df948dc52cc" — store this
console.log(tx.status); // "PENDING"
console.log(tx.net_amount); // 4925
// 3. The customer approves the USSD prompt (Orange) / push (MTN).
// Wait for the final status — poll, or better, rely on a webhook.
const final = await sdk.transactions.poll(tx.reference, {
interval: 10_000, // check every 10s
timeout: 600_000, // give up after 10 min (matches the API timeout)
onStatus: (status, attempt) => console.log(`[${attempt}] ${status}`),
});
if (final.status === "SUCCESS") {
console.log("✅ Collected. Funds are on hold for 48h, then available.");
} else {
console.log(`❌ ${final.status}`);
}
}
collectPayment().catch((e) => {
if (e instanceof WalletError) console.error("Balance / wallet problem:", e.message);
else console.error(e);
});💡 Holds: after a successful Cash-In, the net amount sits in
balance.heldfor 48h before moving tobalance.available. You cannot spend held funds.
Cash-Out (send money)
// Requires balance.available >= amount + 1% fee
const payout = await sdk.cashout.mobileMoney({
phoneNumber: "237680216505",
operator: "MTN",
country: "CM",
amount: 5000, // wallet is debited 5000 + 50 (1%)
currency: "XAF",
});
// A hold is placed on amount + fee; released automatically if it fails.Sandbox walkthrough
Point the SDK at your test keys and drive both outcomes deterministically:
import { HRSkillsPay } from "@hr-skills/pay";
const sdk = new HRSkillsPay({
publicKey: "hrsk_pk_test_…",
secretKey: "hrsk_sk_test_…",
logger: true,
});
// SUCCESS path — even amount
const ok = await sdk.cashin.mobileMoney({
phoneNumber: "237655500393", operator: "ORANGE", amount: 5000,
});
const okFinal = await sdk.transactions.poll(ok.reference, { interval: 2000, timeout: 60_000 });
console.assert(okFinal.status === "SUCCESS");
// FAILED path — odd amount
const ko = await sdk.cashin.mobileMoney({
phoneNumber: "237680216505", operator: "MTN", amount: 5001,
});
const koFinal = await sdk.transactions.poll(ko.reference, { interval: 2000, timeout: 60_000 });
console.assert(koFinal.status === "FAILED");No real money moves in sandbox, and both paths are reproducible — ideal for CI.
Configuration
const sdk = new HRSkillsPay({
publicKey: "hrsk_pk_live_…", // Required — Key A
secretKey: "hrsk_sk_live_…", // Required — Key B (server-side only)
timeout: 30_000, // Optional — per-request timeout in ms (default 30s)
maxRetries: 3, // Optional — retries on 429/5xx (default 3)
logger: true, // Optional — request/response/error logging
throttle: { minDelayMs: 100 }, // Optional — client-side rate limiting
tokenCachePath: ".hrskills-token-cache.json", // Optional — persist token across restarts (Node only)
});Logger options
const sdk = new HRSkillsPay({
// …
logger: {
requests: true,
responses: true,
errors: true,
log: (message) => myLogger.info(message), // plug in pino/winston/etc.
},
});Countries & operators
The SDK validates country, operator and currency against the full catalog below (inputs are case-insensitive). Helper maps COUNTRY_CURRENCY and COUNTRY_OPERATORS are exported.
| Country | Code | Currency | Operators |
|---|---|---|---|
| Cameroun | CM | XAF | mtn · orange · camtel |
| Sénégal | SN | XOF | orange · wave · free · expresso |
| Côte d'Ivoire | CI | XOF | orange · mtn · moov · wave |
| Gabon | GA | XAF | airtel · moov |
| RD Congo | CD | CDF | airtel · orange · mpesa |
| Mali | ML | XOF | orange · moov |
| Burkina Faso | BF | XOF | orange · moov · coris |
| Togo | TG | XOF | tmoney · flooz |
| Bénin | BJ | XOF | mtn · moov |
| Guinée | GN | GNF | orange · mtn · afrimoney |
| Gambie | GM | GMD | afrimoney · qmoney |
import { COUNTRY_CURRENCY, COUNTRY_OPERATORS } from "@hr-skills/pay";
COUNTRY_CURRENCY.SN; // "XOF"
COUNTRY_OPERATORS.CD; // ["AIRTEL", "ORANGE", "MPESA"]Modules reference
🔐 Auth
const token = await sdk.auth.getToken(); // current transaction token
await sdk.auth.refresh(); // force a refresh
sdk.auth.isTokenValid(); // boolean
sdk.auth.clearCache(); // wipe the cached token💰 Cash-In
await sdk.cashin.mobileMoney({
phoneNumber: "237655500393",
operator: "ORANGE", // any operator from the catalog (case-insensitive)
country: "CM",
amount: 5000, // minimum 100
currency: "XAF",
description: "Order #123",
metadata: { orderId: "123" },
});💸 Cash-Out
await sdk.cashout.mobileMoney({
phoneNumber: "221771234567",
operator: "wave",
country: "SN",
amount: 10000,
currency: "XOF",
});
// Throws WalletError (402) if balance.available < amount + 1%📊 Transactions
const tx = await sdk.transactions.get("ref_abc123"); // detail
const status = await sdk.transactions.status("ref_abc"); // status only
const list = await sdk.transactions.list({
status: "SUCCESS",
operator: "ORANGE",
from: "2026-06-01T00:00:00Z",
limit: 20, // max 100
});
const final = await sdk.transactions.poll("ref_abc123", {
interval: 10_000,
timeout: 600_000,
maxAttempts: 60,
onStatus: (status, attempt) => console.log(`Attempt ${attempt}: ${status}`),
});👛 Wallet
const { balance, holds, limits } = await sdk.wallet.balance();
// balance: { available, held, total }
// holds: [{ amount, available_at }] ← funds unlocking within 48h
// limits: { daily_cashin_limit, daily_cashout_limit }
const movements = await sdk.wallet.movements({ limit: 20 }); // CREDIT / DEBIT history📱 Airtime · 🌐 Data
VAS services may require a backend deploy; until then they return
503 PROVIDER_NOT_CONFIGURED.
await sdk.airtime.recharge({ operator: "mtn", phone: "237680216505", amount: 500 });
const packages = await sdk.data.packages("mtn");
await sdk.data.send({ operator: "mtn", phone: "237680216505", amount: 1000 });💡 Bills
// ENEO (electricity)
await sdk.bills.eneo.prepaid({ meter: "12345678", amount: 5000, customerPhone: "237680216505" });
await sdk.bills.eneo.postpaid({ meter: "12345678", amount: 12500 });
// CAMWATER (water)
await sdk.bills.camwater.pay({ meter: "CW123456", amount: 8500, customerPhone: "237680216505" });
// Canal+ (TV)
await sdk.bills.canalplus.pay({ decoderNumber: "CAM123456789", amount: 10000, customerName: "Jean Dupont" });
// Douanes / Customs (DGD)
await sdk.bills.customs.pay({ declarationRef: "DGD-2026-001234", amount: 150000 });💼 Payroll (mass payouts)
// 1. Import
const { data } = await sdk.payroll.import({
label: "Salaires Juin 2026",
currency: "XAF",
recipients: [
{ phone_number: "237670000001", operator: "MTN", amount: 150000, name: "Jean Dupont" },
{ phone_number: "237655000002", operator: "ORANGE", amount: 200000, name: "Marie Martin" },
],
});
// 2. Execute
await sdk.payroll.execute(data.batch_id);
// 3. Track
const report = await sdk.payroll.report(data.batch_id);💳 Virtual Cards
const card = await sdk.cards.create({ label: "Dépenses", currency: "XAF", amount: 50000 });
await sdk.cards.topup(card.id, 10000);
await sdk.cards.freeze(card.id);
await sdk.cards.unfreeze(card.id);
await sdk.cards.cancel(card.id); // ⚠️ irreversible🔗 Payment Links · 📈 Analytics · 🎯 Commissions
const link = await sdk.paymentLinks.create({
amount: 5000, currency: "XAF", description: "Facture #2026-042", expiresAt: "2026-12-31T23:59:59Z",
});
const summary = await sdk.analytics.summary();
const rates = await sdk.commissions.rates(); // e.g. airtime 3%, ENEO 1.5%, Canal+ 2%
const history = await sdk.commissions.history("AIRTIME");Webhooks
Configure your endpoint in Dashboard → Paramètres → Webhooks. Each delivery carries the header X-Hub-Signature: sha256=<hmac>, an HMAC-SHA256 of the raw body keyed with your webhook secret.
| Event | Trigger |
|---|---|
| payment.succeeded | Cash-In or Cash-Out reached SUCCESS |
| payment.failed | Cash-In or Cash-Out reached FAILED |
| payment.hold | Transaction held by AML review |
| payment.refunded | A refund was issued |
Express handler:
import express from "express";
app.post("/webhooks/hrskills", express.raw({ type: "application/json" }), async (req, res) => {
const signature = req.headers["x-hub-signature"] as string;
try {
const event = await sdk.webhooks.constructEvent(
req.body.toString(), // the RAW body — do not JSON.parse first
signature, // "sha256=…" (prefix handled automatically)
process.env.HRSKILLS_WEBHOOK_SECRET!,
);
switch (event.type) {
case "payment.succeeded":
// fulfil the order…
break;
case "payment.failed":
// notify the customer…
break;
}
res.json({ received: true }); // ack quickly
} catch {
res.status(400).json({ error: "Invalid signature" });
}
});You can also verify manually:
const ok = await sdk.webhooks.verifySignature(rawBody, signature, secret); // booleanError handling
Every error extends HRSkillsError, so you can catch broadly or narrowly. API errors have the shape { "error": "CODE", "message": "…", "details": {…} }.
import {
HRSkillsError, ValidationError, AuthenticationError, WalletError,
KycError, IdempotencyError, RateLimitError, ProviderError,
NetworkError, TimeoutError, WebhookSignatureError,
} from "@hr-skills/pay";
try {
await sdk.cashout.mobileMoney({ /* … */ });
} catch (e) {
if (e instanceof WalletError) {
console.error("Insufficient / frozen wallet:", e.message, e.raw);
} else if (e instanceof ValidationError) {
console.error("Invalid input:", e.issues); // [{ field, message }]
} else if (e instanceof KycError) {
console.error("KYC not approved — LIVE keys are blocked.");
} else if (e instanceof RateLimitError) {
console.error(`Rate limited. Retry after ${e.retryAfter}s`);
} else if (e instanceof ProviderError) {
console.error("VAS provider unavailable:", e.code);
} else if (e instanceof HRSkillsError) {
console.error(`API error [${e.statusCode}] ${e.code}: ${e.message}`);
}
}| HTTP | Code | SDK error |
|---|---|---|
| 400 | VALIDATION_ERROR, MISSING_REQUIRED_FIELD | ValidationError |
| 401 | MISSING/INVALID_API_KEY, …_TRANSACTION_TOKEN | AuthenticationError |
| 402 | WALLET_BALANCE_INSUFFICIENT | WalletError |
| 403 | KYC_NOT_APPROVED | KycError |
| 403 | WALLET_FROZEN | WalletError |
| 409 | IDEMPOTENCY_KEY_CONFLICT | IdempotencyError |
| 422 | OPERATOR_NOT_AVAILABLE, CURRENCY_MISMATCH | ApiError |
| 429 | RATE_LIMIT_EXCEEDED | RateLimitError |
| 503 | PROVIDER_NOT_CONFIGURED | ProviderError |
| 504 | PROVIDER_TIMEOUT | ProviderError |
Events & middleware
sdk.on("request", ({ method, url }) => console.log(`→ ${method} ${url}`));
sdk.on("response", ({ status, url }) => console.log(`← ${status} ${url}`));
sdk.on("error", ({ error, url }) => console.error(`✖ ${url}`, error));
// Custom middleware (e.g. OpenTelemetry / Sentry)
sdk.use(async (ctx, next) => {
const span = tracer.startSpan(`hrskills.${ctx.request.method} ${ctx.request.url}`);
try { await next(); } finally { span.end(); }
});Token management
The SDK manages the 45-minute JWT for you:
Request
↓ token cached & valid? → reuse it
↓ expired / missing → POST /v1/auth/transaction-token (Key A + Key B)
↓ mutex: 100 concurrent requests collapse into 1 refresh
↓ cache (memory + optional file) and inject headers
↓ sendSet tokenCachePath to persist the token across process restarts (Node.js only).
TypeScript
import type {
Operator, Currency, Country,
Transaction, TransactionStatus,
WalletBalance, PayrollBatch, VirtualCard, WebhookEvent,
} from "@hr-skills/pay";
import { OPERATORS, CURRENCIES, COUNTRIES, ERROR_CODES } from "@hr-skills/pay";Development
npm install
npm run build # ESM + CJS + type declarations
npm run test # Vitest (MSW-mocked API)
npm run test:watch
npm run typecheck
npm run lint
npm run formatLicense
MIT © HR-Skills Pay
