Metadata-Version: 2.3
Name: hrpay
Version: 1.0.0
Summary: Official Python SDK for HR-Skills Pay — Mobile Money, Wallet, Airtime, Bills, Payroll, Virtual Cards and more.
Project-URL: Homepage, https://docs.hrskills-pay.com/sdk/python
Project-URL: Documentation, https://docs.hrskills-pay.com/sdk/python
Project-URL: Repository, https://github.com/hrskills/pay-python-sdk
Project-URL: Issues, https://github.com/hrskills/pay-python-sdk/issues
Author-email: HR-Skills Pay <dev@hrskills-pay.com>
License: MIT License
        
        Copyright (c) 2026 HR-Skills Pay
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
Keywords: airtime,cameroon,fintech,hrskills,hrskills-pay,mobile-money,mtn,orange,payment,payroll,sdk,wallet
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Office/Business :: Financial
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Requires-Dist: pydantic>=2.7
Provides-Extra: dev
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Description-Content-Type: text/markdown

# HR-Skills Pay — Python SDK

> Official Python SDK for [HR-Skills Pay](https://hrskills-pay.com) — 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](#installation)
2. [Quick start — the easy way](#quick-start--the-easy-way)
3. [Sandbox vs production](#sandbox-vs-production)
4. [Checking status (no blocking)](#checking-status-no-blocking)
5. [Operators by country](#operators-by-country)
6. [Async](#async)
7. [The full client](#the-full-client)
8. [Authentication](#authentication)
9. [Cash-In — collect Mobile Money](#cash-in--collect-mobile-money)
10. [Cash-Out — send funds](#cash-out--send-funds)
11. [Statuses &amp; transactions](#statuses--transactions)
12. [Wallet](#wallet)
13. [VAS — airtime, data, bills](#vas--airtime-data-bills)
14. [Commissions](#commissions)
15. [Payroll](#payroll)
16. [Virtual cards](#virtual-cards)
17. [Card payments](#card-payments)
18. [Payment links](#payment-links)
19. [Webhooks](#webhooks)
20. [Errors](#errors)
21. [Configuration &amp; resilience](#configuration--resilience)
22. [Idempotency](#idempotency)

---

## Installation

```bash
pip install hrpay
```

The only runtime dependencies are [`httpx`](https://www.python-httpx.org/) (transport) and [`pydantic`](https://docs.pydantic.dev/) 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.

```python
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](#the-full-client).

---

## Sandbox vs production

> ⚠️ **The single most important thing to understand.** Sandbox and production share **exactly the same host** (`https://api.hrskills-pay.com`) — the **API key** selects the environment. But unlike what you might expect from that, sandbox traffic is **not** identical to production traffic: every request path is automatically prefixed with `/sandbox` when a test key is in use. You never write `/sandbox` yourself — the SDK does it for every call, based purely on whether your public key contains `_test_` — but a mismatched key/path combination on the wire (which can't happen through this SDK, only if you're calling the API directly) is rejected with `403 sandbox_key_required`/`403 sandbox_path_required`.
>
> **Sandbox behavior itself is not uniform across domains** — read the section for whichever feature you're testing: Mobile Money and Airtime simulate deterministically (see the parity rule below); Virtual Cards are 100% locally emulated (fixed test PAN, fixed FX rate, no real Cartevo calls); Card Payments' sandbox hits a **real external** E-NKAP test environment (so real third-party flakiness can show up there); Bills' two generic endpoints are mirrored (with one path quirk — see [VAS — airtime, data, bills](#vas--airtime-data-bills)) but its four biller-specific endpoints have **no sandbox mirror at all**.

|                   | SANDBOX · TEST                              | PRODUCTION · LIVE                              |
| ----------------- | ------------------------------------------- | --------------------------------------------- |
| **Keys**          | `hrsk_pk_test_...` / `hrsk_sk_test_...`     | `hrsk_pk_live_...` / `hrsk_sk_live_...`       |
| **Path prefix**   | `/sandbox/...` (added automatically)        | none                                           |
| **Payments**      | No real payment                             | Real MTN / Orange payments                    |
| **Prerequisite**  | None                                        | **Approved KYC** (else `403 KYC_NOT_APPROVED`)|
| **Outcome**       | Driven by **amount parity** (Mobile Money/Airtime) | 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   |

```python
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:

```python
# 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.

```python
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.

```python
# 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}")
```

```text
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.

```python
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](#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`.

```python
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.

```python
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](#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`.

```python
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:

```python
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](#configuration--resilience).

---

## Cash-In — collect Mobile Money

```python
tx = client.cash_in.mobile_money(
    phone_number="+237655500393",   # E.164, with or without the leading "+"
    operator=hrpay.Operator.MTN,
    amount=5000,
    country=hrpay.Country.CM,
    description="Order #1234",
    metadata={"order_id": "1234"},
    reference="order-1234",         # optional; reused a second time -> 409 DUPLICATE_REFERENCE
    idempotency_key="order-1234",   # optional, makes a retry safe
)
```

The returned `VasTransactionResult` 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**; `client.transactions.fees()` gives you the full per-country/direction grid instead of guessing. `VasTransactionResult` is the same shape returned by Cash-Out, Airtime and every Bills payment — they all route through the same backend orchestrator.

Funds credited by a Cash-In sit in `balance.held` for 48 hours before becoming available.

16 countries are covered; `hrpay.operators_by_country()` is a static, convenience reference that can go stale — prefer the live `client.countries.supported(jwt=...)` (dashboard-JWT auth, not the usual Clé A/B flow) when you can.

---

## Cash-Out — send funds

```python
tx = client.cash_out.mobile_money(
    phone_number="+237655500393",
    operator=hrpay.Operator.ORANGE,
    amount=10000,
    reference="payout-9001",        # optional, same duplicate-reference protection as Cash-In
    idempotency_key="payout-9001",   # strongly recommended — see Idempotency
)

# Refund a settled payment (works for either direction):
client.transactions.refund(tx.reference, idempotency_key="refund-9001")
```

The amount plus its fee is debited from `balance.available`, so a Cash-In still inside its 48h hold cannot fund it.

---

## Statuses &amp; transactions

Statuses: `PENDING` → `SUCCESS` / `FAILED` / `REFUNDED`, or `HOLD` (AML review in progress — **not** terminal).

```python
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

```python
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 is **Cameroon only** — the SDK always sends `country: "CM"` itself, you can't override it. Only `mtn`/`camtel` are actually operational upstream today; `orange`/`nexttel` are accepted by validation but currently dead at the provider (`hrpay.AIRTIME_KNOWN_DEAD`) and will surface as an opaque `502 provider_error` rather than a clean rejection. `client.airtime.offers()` under-reports (only ever lists 2 of the 4 real operators) — don't build a selector from it, use `hrpay.AirtimeOperator`/`hrpay.AIRTIME_OPERATIONAL` instead.

```python
# Airtime — one number, or up to 500 at once
client.airtime.recharge(operator=hrpay.AirtimeOperator.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, item.index)

# Data
client.data.packages(operator="MTN")
client.data.send(operator=hrpay.Operator.MTN, phone="237650000000", amount=1000)

# Bills, grouped by biller — each has a lookup before you pay
client.bills.eneo.prepaid_lookup("123456789")                    # {meter, customer_name, provider}
client.bills.eneo.prepaid(meter="123456789", amount=5000)         # no recharge token in the response — see note below
client.bills.eneo.invoice("123456789")                            # postpaid: {amount_due, pay_item_id, ...}
client.bills.eneo.postpaid(meter="123456789", amount=5000)        # amount MUST equal invoice().amount_due
client.bills.camwater.invoice("000111222")                        # {invoices: [...]} — a list, not one balance
client.bills.camwater.pay(meter="000111222", amount=8000)
client.bills.canal_plus.lookup("12345678")                        # name_available=False is normal, not an error
client.bills.canal_plus.pay(decoder_number="12345678", amount=15000)
client.bills.customs.get("DEC-2026-001")
client.bills.customs.pay(declaration_ref="DEC-2026-001", amount=250000)

# Or the generic, biller-agnostic pair (handy for a single back-office code
# path, and the only way to exercise Bills against a TEST key — the four
# biller-specific endpoints above have no sandbox mirror at all):
client.bills.lookup(biller="ENEO_POSTPAID", account_number="123456789")
client.bills.pay(biller="ENEO_POSTPAID", account_number="123456789", amount=5000)
```

The ENEO prepaid kWh recharge token is **not** returned by this API today — don't build a customer-facing flow assuming `token` will be populated; contact HR-Skills Pay support if you need it relayed. Every bill payment's `type` field is the backend's *internal* direction name, not the biller's commercial name: CAMWATER comes back as `"BILL_WATER"`, Canal+ as `"SUBSCRIPTION"`, Customs as `"BILL_CUSTOMS"` — only ENEO and Airtime keep intuitive names.

---

## Commissions

Reseller commissions on VAS transactions. **Rates are per-merchant — fetch them, never assume them.**

```python
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.

```python
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

Issue USD Visa/Mastercard virtual cards (via Cartevo) to *your own* beneficiaries — employees, contractors, clients. Not to be confused with [Card payments](#card-payments), which is the other direction (accepting a card payment *from* your customer). Three steps, in order: enroll a KYC'd customer (admin-reviewed, not instant), fund the USD card-wallet from your main XAF wallet, then issue a card.

```python
# 1. Enroll a customer — kyc_status starts PENDING_REVIEW; only ENROLLED
#    customers can be issued a card, and nothing here can be sped up by you.
customer = client.cards.customers.create(
    first_name="Jean", last_name="Dupont", email="jean.dupont@client.cm",
    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=hrpay.IdDocumentType.NIN, date_of_birth="1990-04-12",
    id_document_front="data:image/jpeg;base64,...", id_document_back="data:image/jpeg;base64,...",
)

# 2. Fund the USD card-wallet from your main XAF wallet (XAF is the only
#    source currency that works today).
client.cards.wallet.quote(amount_usd=10, direction="fund")   # indicative, not locked
client.cards.wallet.fund(amount_usd=10, idempotency_key="fund-1")

# 3. Issue and operate a card for an ENROLLED customer.
result = client.cards.create(customer_id=customer.customer.id, brand=hrpay.CardBrand.VISA, amount=20)
if result.is_pending:
    print("ambiguous provider failure — reconciled later via a card.created webhook")
else:
    card = result.card               # PascalCase on the wire, snake_case here
    client.cards.topup(card.id, 50)
    client.cards.freeze(card.id)
    client.cards.unfreeze(card.id)
    detail = client.cards.get(card.id, reveal=True)   # adds PAN/CVV — show once, never log
    client.cards.cancel(card.id)     # permanent; residual balance refunds to the USD wallet
```

`client.cards.transactions(card_id)` uses **0-indexed** pagination (`page=0` is the first page), unlike every other list in this SDK. Sandbox for this whole domain is 100% locally emulated by the server (fixed test PAN `4111111111111111`/CVV `123`, fixed FX rate, no real Cartevo calls, no webhooks for test resources) — a very different sandbox model from [Card payments](#card-payments) below.

---

## Card payments

Accept a card payment (Visa/Mastercard) *from* your customer via E-NKAP/Flocash's hosted checkout page — the opposite direction from [Virtual cards](#virtual-cards) above, and a wholly separate feature despite the similar name.

> **The only valid proof of a successful payment is a verified `card_payment.succeeded` webhook, or `get()` returning `CAPTURED`/`SETTLED`.** E-NKAP itself signs nothing, and it often redirects the customer's browser back to your `return_url` *before* its own processing even finishes — never treat that redirect alone as confirmation.

```python
payment = client.card_payments.create(
    amount=15000, currency="XAF", description="Order #58231",
    return_url="https://example.cm/paiement/retour",
    cancel_url="https://example.cm/paiement/annule",
    customer={"name": "Jean Dupont", "email": "jean@example.cm", "ip_address": end_customer_ip},
    idempotency_key="order-58231-attempt-1",
)
if payment.checkout_url:
    redirect(payment.checkout_url)   # 403 FRAUD_BLOCKED never gets here — no reference exists to poll

# Later — from your webhook handler, or by polling:
settled = client.card_payments.poll(payment.reference)
print(settled.captured, settled.fee, settled.net_amount)
```

Money collected this way lands in a wholly separate `card_collect` balance — **never** your main `client.wallet.balance()`. Moving it there requires an explicit, admin-approved withdrawal request, which (like listing withdrawal requests and chargebacks) authenticates with a **merchant dashboard JWT**, not the usual Clé A/Clé B flow — this SDK cannot obtain that token for you:

```python
client.card_payments.get_card_collect_balance()                 # normal auth works here
client.card_payments.request_withdrawal(amount=100000, jwt=dashboard_jwt, idempotency_key="w-1")
client.card_payments.list_withdrawal_requests(jwt=dashboard_jwt)
client.card_payments.list_chargebacks(jwt=dashboard_jwt)         # read-only — the whole lifecycle is admin-driven
```

Unlike every other domain's sandbox, this one is **not** locally emulated — `/sandbox/api/v1/payments/card` makes a real network call to E-NKAP's own external test environment, so an occasional `503 PROVIDER_UNAVAILABLE` there can be genuine third-party flakiness, not an SDK or integration bug. There is no refund-creation endpoint in this API — `REFUNDED` is only reachable via a lost chargeback.

---

## Payment links

```python
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.

```python
# 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`.

```python
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                        |

Two deliberate exceptions to that table: a card payment's `403 FRAUD_BLOCKED` (see [Card payments](#card-payments)) raises `APIError`, not `AuthenticationError` — it's a fraud-engine block, not a credentials/KYC problem, and no reference exists afterwards to look it up. And Virtual Cards' error envelope is `{"code": ..., "message": ...}` (no `error` key, no `success` field at all) rather than the rest of the platform's `{"error": ..., "message": ...}` — both are parsed transparently into the same `error.code`, so you never need to special-case it yourself.

---

## Configuration &amp; resilience

```python
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:

```python
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.

```python
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
