Official Python SDK for HR-Skills Pay — Mobile Money, Wallet, Airtime, Bills, Payroll, Virtual Cards and more.
Project description
HR-Skills Pay — Python SDK
Official Python SDK for HR-Skills Pay — Mobile Money, wallets, airtime, data, bills, payroll, virtual cards and payment links.
B2B payment infrastructure for Africa — 16 countries: Cameroon, Côte d'Ivoire, Senegal, Gabon, DR Congo, Mali, Burkina Faso, Togo, Benin, Guinea, Gambia and more.
| Base URL | https://api.hrskills-pay.com |
| Version | v1 · REST JSON |
| Python | 3.10+ |
| Easy mode | hrpay.token(...) → collect / send / check |
| Full API | HRPayClient and AsyncHRPayClient, identical surface |
| Sandbox | Amount even → SUCCESS · odd → FAILED |
Table of contents
- Installation
- Quick start — the easy way
- Sandbox vs production
- Checking status (no blocking)
- Operators by country
- Async
- The full client
- Authentication
- Cash-In — collect Mobile Money
- Cash-Out — send funds
- Statuses & transactions
- Wallet
- VAS — airtime, data, bills
- Commissions
- Payroll
- Virtual cards
- Payment links
- Webhooks
- Errors
- Configuration & resilience
- Idempotency
Installation
pip install hrpay
The only runtime dependencies are httpx (transport) and pydantic v2 (validation and typed models).
Quick start — the easy way
Two steps. ① Exchange your keys for a token. ② Act off that token — nothing ever blocks.
import hrpay
# ① Get a token. The exchange happens here, so bad keys fail right away.
tk = hrpay.token("hrsk_pk_test_...", "hrsk_sk_test_...")
print(tk.environment) # "TEST"
# ② Collect a payment. Returns immediately with the server's response.
tx = tk.collect(phone="237655500393", amount=5000, operator="orange")
print(tx.reference, tx.status) # "TX123", "PENDING" ← never blocks
# Send money out — same shape, same immediate return.
out = tk.send(phone="237690000000", amount=10000, operator="mtn")
# Check the outcome whenever you want (see the next section).
latest = tk.check(tx.reference)
print(latest.status) # "SUCCESS" / "FAILED" / "PENDING" …
- Keys can be omitted and read from
$HRPAY_PUBLIC_KEY/$HRPAY_SECRET_KEY. operatoraccepts a plain string ("orange","mtn") orhrpay.Operator.ORANGE.countrydefaults toCM; the currency is derived from it (XAF, XOF, …).collect/sendreturn the raw, typed server response (reference,status,fee,net_amount, …). Fees come straight from the response — rates vary per merchant, never recompute them.- The token auto-refreshes before it expires;
tk.valueis the raw string,tk.merchant_id/tk.environmentcome from the exchange. - Close it when done —
tk.close(), or usewith hrpay.token(...) as tk:.
Everything the full client can do is still reachable from the token: tk.wallet, tk.bills, tk.payroll, tk.client, … See The full client.
Sandbox vs production
⚠️ The single most important thing to understand. Sandbox and production share exactly the same URL (
https://api.hrskills-pay.com). The API key selects the environment, not the host. There is no/sandboxpath.
| SANDBOX · TEST | PRODUCTION · LIVE | |
|---|---|---|
| Keys | hrsk_pk_test_... / hrsk_sk_test_... |
hrsk_pk_live_... / hrsk_sk_live_... |
| URL | https://api.hrskills-pay.com |
https://api.hrskills-pay.com (identical) |
| Payments | No real payment | Real MTN / Orange payments |
| Prerequisite | None | Approved KYC (else 403 KYC_NOT_APPROVED) |
| Outcome | Driven by amount parity | Driven by the real customer |
🎲 The parity rule in sandbox
In sandbox, the final status is determined by the amount's parity:
| Amount | Final status | Use for |
|---|---|---|
| Even (5000, 1000, 200…) | SUCCESS ✅ |
Testing the happy path |
| Odd (5001, 999, 301…) | FAILED ❌ |
Testing failure handling |
tk = hrpay.token("hrsk_pk_test_...", "hrsk_sk_test_...")
# Force SUCCESS: even amount
ok = tk.collect(phone="237655500393", amount=5000, operator="orange")
# Force FAILED: odd amount
ko = tk.collect(phone="237655500393", amount=5001, operator="orange")
ℹ️ The initial status is always
PENDING. Parity decides the final status, which you read withtk.check(reference)or via a webhook. The minimum amount is 100, so101is the smallest failing amount and100the smallest succeeding one.
Switching sandbox → production
No code change. Only the keys differ:
# Sandbox
tk = hrpay.token(os.environ["HRPAY_PK_TEST"], os.environ["HRPAY_SK_TEST"])
# Production — same calls, same URLs
tk = hrpay.token(os.environ["HRPAY_PK_LIVE"], os.environ["HRPAY_SK_LIVE"])
Checking status (no blocking)
Mobile Money settles asynchronously: collect and send come back PENDING immediately. You learn the outcome two ways — prefer webhooks; fall back to check.
tk.check(reference) — one call, never blocks. Ask for the current status whenever it suits you: right after a payment, on a timer, or when a webhook nudges you.
tx = tk.collect(phone="237655500393", amount=5000, operator="orange")
# …some time later — a poll, a cron, a button press:
latest = tk.check(tx.reference)
if latest.succeeded:
fulfil_order(tx.reference)
elif latest.status == "FAILED":
notify_customer(tx.reference)
# still "PENDING"? just check again later.
check returns the server's current record (status, amount, fees, updated_at, …). It does not loop or wait — you stay in control of timing, which matters when a webhook is delayed or hasn't arrived.
Need a blocking helper for a script or test? The full client still has one:
tk.client.transactions.poll(reference)waits until the transaction reaches a terminal state.
Operators by country
Which Mobile Money operators can you use where? This ships as built-in reference data — no network call — so you can populate a dropdown or validate input before sending.
# One country → its operators
hrpay.operators_for_country("CM")
# [Operator.MTN, Operator.ORANGE, Operator.CAMTEL, Operator.NEXTTEL]
# From a token, same idea
tk.operators("SN") # [Operator.ORANGE, Operator.FREE, Operator.WAVE, Operator.EXPRESSO]
# Full overview: country, name, currency, operators
for info in hrpay.operators_by_country():
ops = ", ".join(op.value for op in info.operators)
print(f"{info.name} ({info.country.value}, {info.currency.value}): {ops}")
Cameroun (CM, XAF) : MTN, ORANGE, CAMTEL, NEXTTEL
Sénégal (SN, XOF) : ORANGE, FREE, WAVE, EXPRESSO
Côte d'Ivoire (CI, XOF) : ORANGE, MTN, MOOV, WAVE
Gabon (GA, XAF) : AIRTEL, MOOV
RD Congo (CD, CDF) : AIRTEL, ORANGE, MPESA, AFRIMONEY
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
Gambie (GM, GMD) : AFRIMONEY, QMONEY
This table is a convenience. The API stays the source of truth: an operator it doesn't support for a country is rejected at request time with
422 OPERATOR_NOT_AVAILABLE.
Async
Prefer async? hrpay.atoken(...) returns an AsyncToken with the same verbs — just await them.
import asyncio
import hrpay
async def main():
async with await hrpay.atoken() as tk: # keys from the environment
tx = await tk.collect(phone="237655500393", amount=5000, operator="orange")
latest = await tk.check(tx.reference)
print(latest.status)
asyncio.run(main())
operators(...) and webhook verification are CPU-only and stay synchronous on both — see Webhooks.
The full client
The token is a friendly layer over HRPayClient, which exposes every endpoint grouped by resource. Reach it directly, or via tk.client.
import hrpay
with hrpay.HRPayClient("hrsk_pk_test_...", "hrsk_sk_test_...") as client:
tx = client.cash_in.mobile_money(
phone_number="237655500393",
operator=hrpay.Operator.ORANGE,
amount=5000,
country=hrpay.Country.CM, # currency defaults to XAF
)
print(tx.reference, tx.status) # PENDING
# Blocking helper, for scripts/tests where waiting is fine:
settled = client.transactions.poll(tx.reference)
print(settled.status) # SUCCESS
The rest of this README uses the full client to document each resource; every one is also reachable from a token (tk.wallet, tk.bills, tk.payroll, …).
Async client
AsyncHRPayClient mirrors the sync client method-for-method; every request is a coroutine.
import asyncio
import hrpay
async def main():
async with hrpay.AsyncHRPayClient() as client: # keys from the environment
tx = await client.cash_in.mobile_money(
phone_number="237655500393",
operator=hrpay.Operator.ORANGE,
amount=5000,
)
settled = await client.transactions.poll(tx.reference)
print(settled.status)
asyncio.run(main())
Webhook verification is CPU-only and stays synchronous on both clients — see Webhooks.
Authentication
Every request carries two credentials: the public key (a bearer token) and a short-lived transaction token in X-Transaction-Token. The SDK fetches, caches and refreshes that token for you — you rarely touch client.auth.
client.auth.get_token() # cached token, minted on first use
client.auth.refresh() # force a new one
client.auth.is_token_valid() # bool
client.auth.merchant_id # from the last token exchange
client.auth.environment # "LIVE" or "TEST"
The same is exposed on a token(...) handle, more directly:
tk.value # the raw transaction token (auto-refreshed)
tk.refresh() # force a new one
tk.is_valid # bool
tk.merchant_id # from the exchange
tk.environment # "LIVE" or "TEST"
Share one token across processes with a custom token cache.
Cash-In — collect Mobile Money
tx = client.cash_in.mobile_money(
phone_number="237655500393",
operator=hrpay.Operator.MTN,
amount=5000,
country=hrpay.Country.CM,
description="Order #1234",
metadata={"order_id": "1234"},
idempotency_key="order-1234", # optional, makes a retry safe
)
The returned CashInResponse starts PENDING: the customer still has to confirm the prompt on their handset. Read the fees the API actually charged from tx.fee and tx.fee_percent — rates vary per merchant, so never recompute them locally.
Funds credited by a Cash-In sit in balance.held for 48 hours before becoming available.
Cash-Out — send funds
tx = client.cash_out.mobile_money(
phone_number="237655500393",
operator=hrpay.Operator.ORANGE,
amount=10000,
idempotency_key="payout-9001", # strongly recommended — see Idempotency
)
The amount plus its fee is debited from balance.available, so a Cash-In still inside its 48h hold cannot fund it.
Statuses & transactions
Statuses: PENDING → SUCCESS / FAILED / REFUNDED, or HOLD (AML review in progress — not terminal).
client.transactions.status(reference) # quick status
client.transactions.get(reference) # full record
page = client.transactions.list(status="SUCCESS", limit=50)
for tx in page:
print(tx.reference, tx.amount)
# Block until terminal (SUCCESS / FAILED / REFUNDED)
settled = client.transactions.poll(
reference,
interval=3.0,
timeout=600.0,
on_status=lambda status, attempt: print(attempt, status),
)
poll raises APIError with code POLL_TIMEOUT or POLL_MAX_ATTEMPTS_REACHED if the transaction never settles. Prefer a webhook where you can; poll where you can't.
Wallet
bal = client.wallet.balance()
print(bal.balance.available, bal.balance.held, bal.currency)
print(bal.is_frozen, bal.limits)
page = client.wallet.movements(limit=100)
for m in page:
print(m.type, m.amount, m.balance_after)
VAS — airtime, data, bills
# Airtime — one number, or up to 500 at once
client.airtime.recharge(operator=hrpay.Operator.MTN, phone="237650000000", amount=1000)
batch = client.airtime.batch([
{"phone": "237650000000", "operator": "MTN", "amount": 1000},
{"phone": "237690000000", "operator": "ORANGE", "amount": 500},
])
for item in batch.items: # a batch can be partially successful
print(item.phone, item.status)
# Data
client.data.packages(operator="MTN")
client.data.send(operator=hrpay.Operator.MTN, phone="237650000000", amount=1000)
# Bills, grouped by biller
client.bills.eneo.invoice("123456789")
client.bills.eneo.prepaid(meter="123456789", amount=5000) # response has the recharge token
client.bills.eneo.postpaid(meter="123456789", amount=5000)
client.bills.camwater.pay(meter="000111222", amount=8000)
client.bills.canal_plus.pay(decoder_number="12345678", amount=15000)
client.bills.customs.pay(declaration_ref="DEC-2026-001", amount=250000)
Commissions
Reseller commissions on VAS transactions. Rates are per-merchant — fetch them, never assume them.
client.commissions.rates() # authoritative rate per service
client.commissions.history(service="AIRTIME")
client.commissions.summary(from_="2026-01-01", to="2026-01-31")
Payroll
Mass disbursement in two steps: import a draft, then execute it.
draft = client.payroll.import_(
label="January salaries",
currency=hrpay.Currency.XAF,
recipients=[
{"phone_number": "237650000000", "operator": "MTN", "amount": 150000, "name": "Awa"},
{"phone_number": "237690000000", "operator": "ORANGE", "amount": 200000, "name": "Ben"},
],
)
client.payroll.execute(draft.batch_id, idempotency_key=f"payroll-{draft.batch_id}")
client.payroll.status(draft.batch_id)
report = client.payroll.report(draft.batch_id) # per-recipient outcomes
You can also import via file_base64= or csv_data= instead of recipients=.
Virtual cards
card = client.cards.create(label="Ads budget", currency=hrpay.Currency.USD, amount=100)
client.cards.topup(card.id, 50)
client.cards.freeze(card.id)
client.cards.unfreeze(card.id)
client.cards.cancel(card.id) # permanent
Payment links
link = client.payment_links.create(amount=25000, description="Invoice #42")
print(link.url)
client.payment_links.list(limit=20)
Webhooks
Always verify the signature against the raw request body before trusting a delivery. construct_event does both steps and raises WebhookSignatureError on a bad signature or invalid JSON.
# Flask example
@app.post("/webhooks/hrpay")
def hrpay_webhook():
raw = request.get_data() # raw bytes, not a re-parsed dict
sig = request.headers["X-Hub-Signature"]
try:
event = client.webhooks.construct_event(raw, sig, os.environ["HRPAY_WEBHOOK_SECRET"])
except hrpay.WebhookSignatureError:
return "", 400
if event.type_value == "payment.succeeded":
...
return "", 200
construct_event / verify_signature are synchronous static methods, so they work identically on AsyncHRPayClient.
Errors
Every failure derives from hrpay.HRPayError. The machine-readable code is on error.code.
try:
client.cash_out.mobile_money(phone_number="237650000000", operator="MTN", amount=999999)
except hrpay.WalletError as e: # 402 — insufficient balance / frozen
print(e.code, e.details)
except hrpay.ValidationError as e: # 400 / 422 — includes e.issues
print(e.issues)
except hrpay.RateLimitError as e: # 429
print(e.retry_after_seconds)
except hrpay.HRPayError as e: # catch-all
print(e)
| Exception | HTTP | Meaning |
|---|---|---|
AuthenticationError |
401 / 403 | Bad credentials, or unmet KYC gate |
WalletError |
402 | Insufficient balance, or frozen wallet |
ValidationError |
400 / 422 | Rejected payload (.issues) |
ConflictError |
409 | Idempotency key reused with a new payload |
RateLimitError |
429 | Too many requests (.retry_after_seconds) |
NetworkError |
— | DNS / TCP / TLS failure |
TimeoutError |
— | Exceeded the configured timeout |
CircuitBreakerOpenError |
— | Breaker open, request blocked |
APIError |
other | Any other API error |
Configuration & resilience
client = hrpay.HRPayClient(
"hrsk_pk_test_...", "hrsk_sk_test_...",
timeout=30.0, # seconds
max_retries=3, # retries 429 & 5xx with backoff
throttle=0.2, # min 200ms between requests
logger=hrpay.LoggerConfig.all(), # logs requests/responses/errors, keys redacted
failure_threshold=5, # circuit breaker
reset_timeout=15.0,
on_response=lambda r: print(r.status_code),
)
Built-in resilience, applied to every call:
- Automatic retries on 429 and 5xx, with exponential backoff (honouring
Retry-After). - Circuit breaker — after
failure_thresholdconsecutive system failures the breaker opens and fails fast forreset_timeoutseconds, then lets one probe through. - Auto token refresh — the transaction token is minted, cached and refreshed a minute before expiry.
- Secret redaction — API keys are masked in every log line.
Persist tokens across restarts, or share them across workers:
from hrpay import FileTokenCache
client = hrpay.HRPayClient(..., token_cache=FileTokenCache("~/.hrpay/tokens.json"))
Implement the TokenCache (or AsyncTokenCache) protocol for a Redis-backed cache, etc.
Idempotency
Any mutating request accepts an idempotency_key. The SDK auto-generates one per request; pass your own for anything that moves money so a network retry can't charge twice. A caller-supplied key always wins.
client.cash_out.mobile_money(
phone_number="237655500393", operator="ORANGE", amount=10000,
idempotency_key="payout-9001",
)
Reusing a key with a different payload raises ConflictError (409).
License
MIT © HR-Skills Pay
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file hrpay-0.1.0.tar.gz.
File metadata
- Download URL: hrpay-0.1.0.tar.gz
- Upload date:
- Size: 55.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1651606de371f8a3941cad6d3db04ddaf27eb4df886f68238c1bac0ff3aef13a
|
|
| MD5 |
17ce6030d08c4d9e6d9537f09f083ceb
|
|
| BLAKE2b-256 |
54189a7aedfc929529bb336d93a688a8afefb004cb61adc23a7e783f9a4f1512
|
File details
Details for the file hrpay-0.1.0-py3-none-any.whl.
File metadata
- Download URL: hrpay-0.1.0-py3-none-any.whl
- Upload date:
- Size: 80.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9e1fe660bd4c87b505f5e99df3e79a0dc0dec46b67b0c36da44c3bc5a135bd22
|
|
| MD5 |
d62d8ac6d0dee13022360eca9be76294
|
|
| BLAKE2b-256 |
559dca41df741dddd664e9e3008323f09e2e8b986c3af959fb99820970a8a053
|