@hr-skills/pay
v0.3.1
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:
2%platform default for Cash-In/Cash-Out (negotiable per merchant — checksdk.transactions.fees(), never hardcode it)
🇨🇲 Not using JavaScript? Detailed, French-language reference docs (with runnable
curlscripts inexamples/curl/) live underdocs/:CASHIN-CASHOUT.md(Mobile Money, 16 countries),AIRTIME.md(Cameroun-only top-up),VIRTUAL-CARDS.md(Visa/Mastercard issuance), andCARD-PAYMENTS.md(E-NKAP card checkout).
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 only when exchanging it for a transaction token. |
| Key B (secret) | hrsk_sk_live_… / hrsk_sk_test_… | Sent in the token-exchange body, then reused as Authorization: Bearer on every payment call that follows. 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: Bearer <Key B>,X-Transaction-TokenandIdempotency-Keyon every subsequent call. - Refreshes the token before it expires.
A few endpoints don't follow this flow at all: sdk.wallet.balance() only strictly needs Key B (no token required, though the SDK sends one anyway), while sdk.wallet.cardCollect.requestWithdrawal()/.withdrawalRequests()/.chargebacks() and sdk.wallet.supportedCountries() require a dashboard-issued merchant JWT instead — the SDK has no login flow, so you must obtain that JWT yourself and pass it as { jwt: "..." }.
⚠️ 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/net_amount depend on your rate — see sdk.transactions.fees()
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 + fee (check sdk.transactions.fees() for your real rate)
const payout = await sdk.cashout.mobileMoney({
phoneNumber: "237680216505",
operator: "MTN",
country: "CM",
amount: 5000, // wallet is debited amount + fee
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 catalog below (inputs are case-insensitive) for Mobile Money (Cash-In/Cash-Out) only — this is a different operator set than Airtime top-up (see below) or Virtual Cards. Helper maps COUNTRY_CURRENCY and COUNTRY_OPERATORS are exported, but per the API docs this list can drift from reality — treat it as a reference, not a source of truth, and use sdk.wallet.supportedCountries() (requires a merchant dashboard JWT) for anything business-critical, since it also reports your own per-country activation status.
| Country | Code | Currency | Operators |
|---|---|---|---|
| Cameroun | CM | XAF | mtn · orange |
| Gabon | GA | XAF | airtel · moov |
| Congo Brazzaville | CG | XAF | airtel · mtn |
| Tchad | TD | XAF | airtel · moov |
| Rép. Centrafricaine | CF | XAF | orange |
| Côte d'Ivoire | CI | XOF | moov · mtn · orange · wave |
| Sénégal | SN | XOF | expresso · free · orange · wave |
| Mali | ML | XOF | moov · orange |
| Burkina Faso | BF | XOF | moov · orange · wligdicash |
| Togo | TG | XOF | moov · tmoney |
| Bénin | BJ | XOF | moov · mtn · celtiis · coris |
| Niger | NE | XOF | airtel |
| Guinée-Bissau | GW | XOF | orange |
| RD Congo | CD | CDF | airtel · mpesa · orange · afrimoney |
| Guinée Conakry | GN | GNF | mtn · orange |
| Gambie | GM | GMD | afrimoney |
⚠️
camtelandnexttelare not Mobile Money operators — they only apply to Airtime top-up (sdk.airtime, Cameroun-only, see below), and even thereorange/nexttelare currently non-operational at the provider.
import { COUNTRY_CURRENCY, COUNTRY_OPERATORS } from "@hr-skills/pay";
COUNTRY_CURRENCY.SN; // "XOF"
COUNTRY_OPERATORS.CD; // ["AIRTEL", "MPESA", "ORANGE", "AFRIMONEY"]
// Live, per-merchant source of truth (requires a merchant dashboard JWT):
const countries = await sdk.wallet.supportedCountries({ jwt: dashboardJwt });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}`),
});
// Live fee schedule per country/direction — never hardcode a rate, check this instead
const fees = await sdk.transactions.fees();
// Refund a transaction (Idempotency-Key required by the API)
await sdk.transactions.refund("ref_abc123", { idempotencyKey: crypto.randomUUID() });👛 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 (Cameroun only) · 🌐 Data
Airtime top-up is Cameroun-only — the SDK always sends
country: "CM"for you. Of the 4 operators accepted by the API (mtn,orange,camtel,nexttel), onlymtnandcamtelare currently operational at the provider;orange/nexttelreturn a502 provider_error. SeeAIRTIME_OPERATORS_STATUSanddocs/AIRTIME.mdfor the full picture (the built-insdk.airtime.offers()under-reports this — it only listsmtn/orange).
import { AIRTIME_OPERATORS_STATUS } from "@hr-skills/pay";
const recharge = await sdk.airtime.recharge({ operator: "mtn", phone: "237680216505", amount: 500 });
// recharge.fee / .net_amount / .wallet_balance_after — see docs/AIRTIME.md for the full response shape
await sdk.airtime.batch({
items: [
{ phone: "237680216505", operator: "mtn", amount: 500 },
{ phone: "237697001122", operator: "camtel", amount: 1000 },
],
}); // → { success_count, failed_count, items: [...] }
console.log(AIRTIME_OPERATORS_STATUS); // verified operator status, unlike sdk.airtime.offers()
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 (issue your own Visa/Mastercard, USD)
Distinct from Card Payments below — this is about issuing prepaid cards to your own beneficiaries (employees, customers), not accepting a card payment. Three steps are mandatory, in order: KYC → fund the USD card wallet → issue the card. See
docs/VIRTUAL-CARDS.mdfor the full flow, error codes, and state machine.⚠️ The
cardobject returned bycreate/get/listis serialized in PascalCase (ID,CustomerID,Status...) — the only place in the whole API that isn'tsnake_case.
// 1. KYC — reviewed by an HR-Skills Pay admin, not instant
const customer = await sdk.cards.customers.create({
first_name: "Jean", last_name: "Dupont", email: "[email protected]",
country: "Cameroon", country_iso_code: "CM", country_phone_code: "+237",
phone_number: "690001234", street: "Rue 1.234, Bonanjo", city: "Douala",
state: "Littoral", postal_code: "00237", identification_number: "123456789",
id_document_type: "NIN", date_of_birth: "1990-04-12",
id_document_front: "data:image/jpeg;base64,...", id_document_back: "data:image/jpeg;base64,...",
});
// ... wait for customer.kyc_status === "ENROLLED" ...
// 2. Fund the USD card wallet (separate from your main XAF wallet)
await sdk.cards.wallet.fund({ amount_usd: 10 });
// 3. Issue the card (⚠️ PascalCase `card` object)
const issued = await sdk.cards.create(
{ customer_id: customer.id, brand: "VISA", amount: 20 },
{ idempotencyKey: "card-" + crypto.randomUUID() },
);
if ("card" in issued) {
await sdk.cards.topup(issued.card.ID, 50);
await sdk.cards.freeze(issued.card.ID);
await sdk.cards.unfreeze(issued.card.ID);
await sdk.cards.cancel(issued.card.ID); // ⚠️ irreversible, alias .terminate()
}🧾 Card Payments (accept a card payment via hosted checkout)
A completely different domain from Virtual Cards above: this is for accepting a Visa/Mastercard payment from your own customer via a hosted, redirect-based E-NKAP/Flocash page. Captured funds land in a separate
card_collectsettlement wallet — never your main wallet. Seedocs/CARD-PAYMENTS.md.
const payment = await sdk.cardPayments.create(
{ amount: 15000, return_url: "https://mysite.cm/pay/return", cancel_url: "https://mysite.cm/pay/cancel" },
{ idempotencyKey: "order-58231-attempt-1" }, // the ONLY safe way to retry a network timeout
);
// redirect your customer to payment.checkout_url
const status = await sdk.cardPayments.get(payment.reference);
// status.status === "CAPTURED" or "SETTLED" is the only reliable proof of success —
// never trust a browser redirect to return_url alone.
// Separate settlement wallet, fed only by card payments:
const cardCollectBalance = await sdk.wallet.cardCollect.balance();
// requestWithdrawal()/withdrawalRequests()/chargebacks() require a merchant dashboard JWT:
await sdk.wallet.cardCollect.requestWithdrawal(
{ amount: 100000, note: "Monthly withdrawal" },
{ jwt: dashboardJwt, idempotencyKey: crypto.randomUUID() },
);🔗 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 2.5%/2.0% (Enterprise/Freelance), 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.
Delivered envelope: {"id", "event": "payment.succeeded", "merchant_id", "created_at", "data": {...}} — the event name is on the event field, not type.
| Event | Domain | Trigger |
|---|---|---|
| payment.succeeded / payment.failed / payment.refunded | Mobile Money (CashIn/CashOut) | Only these 3 exist for this domain — no payment.pending/payment.hold |
| card.created / card.funded / card.withdrawn / card.terminated | Virtual Cards | Reconciliation of a PENDING action, or provider-initiated termination |
| card.transaction.approved / card.transaction.declined | Virtual Cards | A purchase authorization on an issued card |
| card_payment.succeeded / .failed / .canceled / .fraud_rejected | Card Payments (E-NKAP) | Hosted checkout outcome |
| card_payment.chargeback_opened / .chargeback_evidence_needed / .chargeback_won / .chargeback_lost | Card Payments | Chargeback lifecycle (admin-managed) |
| card_balance.withdrawal_completed | Card Payments | card_collect withdrawal request approved and applied |
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.event) {
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 |
This table covers the codes common across domains. Virtual Cards and Card Payments have their own larger, domain-specific error tables (many business-rule 400s like
customer_not_enrolled,card_not_active,FRAUD_BLOCKED...) — these map toValidationError/ApiError/AuthenticationErrorwith the original business code preserved on.code, not a dedicated class. Seedocs/VIRTUAL-CARDS.md§8 anddocs/CARD-PAYMENTS.md§8 for the full tables.
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, WebhookEvent,
VirtualCard, VirtualCardCreateResponse, CardCustomer,
CardPaymentCreateResponse, CardPaymentStatusResponse,
CardCollectBalance, SupportedCountry, PaymentFeeSchedule,
} from "@hr-skills/pay";
import {
OPERATORS, CURRENCIES, COUNTRIES, ERROR_CODES,
AIRTIME_OPERATORS_STATUS, CARD_BRANDS, ID_DOCUMENT_TYPES,
} 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
