Skip to main content

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

  1. Installation
  2. Quick start — the easy way
  3. Sandbox vs production
  4. Checking status (no blocking)
  5. Operators by country
  6. Async
  7. The full client
  8. Authentication
  9. Cash-In — collect Mobile Money
  10. Cash-Out — send funds
  11. Statuses & transactions
  12. Wallet
  13. VAS — airtime, data, bills
  14. Commissions
  15. Payroll
  16. Virtual cards
  17. Payment links
  18. Webhooks
  19. Errors
  20. Configuration & resilience
  21. 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.
  • operator accepts a plain string ("orange", "mtn") or hrpay.Operator.ORANGE.
  • country defaults to CM; the currency is derived from it (XAF, XOF, …).
  • collect / send return 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.value is the raw string, tk.merchant_id / tk.environment come from the exchange.
  • Close it when done — tk.close(), or use with 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 /sandbox path.

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 with tk.check(reference) or via a webhook. The minimum amount is 100, so 101 is the smallest failing amount and 100 the 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_percentrates 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: PENDINGSUCCESS / 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_threshold consecutive system failures the breaker opens and fails fast for reset_timeout seconds, 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

hrpay-0.1.0.tar.gz (55.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

hrpay-0.1.0-py3-none-any.whl (80.7 kB view details)

Uploaded Python 3

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

Hashes for hrpay-0.1.0.tar.gz
Algorithm Hash digest
SHA256 1651606de371f8a3941cad6d3db04ddaf27eb4df886f68238c1bac0ff3aef13a
MD5 17ce6030d08c4d9e6d9537f09f083ceb
BLAKE2b-256 54189a7aedfc929529bb336d93a688a8afefb004cb61adc23a7e783f9a4f1512

See more details on using hashes here.

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

Hashes for hrpay-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 9e1fe660bd4c87b505f5e99df3e79a0dc0dec46b67b0c36da44c3bc5a135bd22
MD5 d62d8ac6d0dee13022360eca9be76294
BLAKE2b-256 559dca41df741dddd664e9e3008323f09e2e8b986c3af959fb99820970a8a053

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page