npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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.

npm version TypeScript License: MIT

  • Base URL: https://api.hrskills-pay.com
  • API version: v1 · REST JSON
  • Fees: 1.5% Cash-In · 1% Cash-Out

Table of contents

  1. Why this SDK
  2. Installation
  3. Authentication model
  4. Sandbox vs Production
  5. Quick start (real demo)
  6. Sandbox walkthrough
  7. Configuration
  8. Countries & operators
  9. Modules reference
  10. Webhooks
  11. Error handling
  12. Events & middleware
  13. Token management
  14. TypeScript
  15. 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-Key is attached to every POST automatically.
  • Universal runtime — Node.js 18+, Bun, Deno, Cloudflare Workers, Next.js, Nuxt.
  • Stripe-style webhooksconstructEvent() 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/pay

Requires 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:

  1. Calls POST /v1/auth/transaction-token with Key A + Key B on the first request.
  2. Caches the JWT (in memory, and optionally on disk).
  3. Injects Authorization, X-Transaction-Token and Idempotency-Key on every call.
  4. 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.held for 48h before moving to balance.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); // boolean

Error 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
  ↓  send

Set 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 format

License

MIT © HR-Skills Pay