Metadata-Version: 2.4
Name: paylod
Version: 0.10.1
Summary: Official Python client for the paylod API -- M-Pesa (Safaricom Daraja) STK Push, status polling, signed webhooks and offline error decoding.
Project-URL: Homepage, https://paylod.dev
Project-URL: Documentation, https://paylod.dev/docs/sdk
Author: paylod / Moses Mrima
License-Expression: MIT
License-File: LICENSE
Keywords: daraja,kenya,m-pesa,mpesa,paylod,payments,safaricom,stk-push
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
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.9
Requires-Dist: httpx>=0.24
Provides-Extra: dev
Requires-Dist: pytest-cov>=4; extra == 'dev'
Requires-Dist: pytest>=7; extra == 'dev'
Description-Content-Type: text/markdown

# paylod

The official Python client for the **paylod API** — M-Pesa collections without the Daraja boilerplate.

**No backend. No fees. No custody.** You supply your own Safaricom Daraja credentials. paylod hosts the callback, refreshes the access token, decodes the result code, and sends you a signed webhook when the payment settles. The money moves between the customer and *your* till. paylod never holds the money. Early access is free.

You make one call. paylod gives you back one outcome that you can render.

---

## Install

```bash
pip install paylod
```

Requires **Python 3.9+**. There is one runtime dependency: [`httpx`](https://www.python-httpx.org/). The current client is synchronous. An asynchronous client with the same surface comes later.

## Quickstart

Call this SDK from a server only. Your `PAYLOD_API_KEY` can move money. Never ship the key in a browser bundle, a mobile application, or any other client.

Pass an idempotency key on every collect call. Mint one key for each payment attempt. Duplicates of that attempt collapse into one STK push and one charge. A double-clicked Pay button, a refreshed tab, and a redelivered job are duplicates of one attempt. Do not use the order id or the product id as the key. A retry after a wrong PIN is a new attempt, and it needs a new key.

The idempotency key is required. The SDK refuses a collect call without one, before the call leaves your process. See [Idempotency](#idempotency).

```python
import os
from paylod import Paylod

paylod = Paylod(os.environ["PAYLOD_API_KEY"])

attempt = create_attempt(order_id=order.id)   # a row per press of Pay

outcome = paylod.collect_and_wait(
    amount=100,
    phone="0712345678",
    idempotency_key=attempt.id,   # one key per payment ATTEMPT. A double-click cannot charge twice.
)

if outcome.paid:
    fulfil(outcome.receipt)       # money moved
else:
    toast(outcome.message)        # already decoded, already human
```

That code is the whole integration. `collect_and_wait` sends the STK push. The poll interval increases with a backoff. The method returns one outcome that you can **render**:

```python
print(outcome.message)
if outcome.retryable:
    show_retry_button()
```

Your application needs no result code table. paylod decodes the M-Pesa result codes for you.

---

## Setup

**One environment variable.**

```bash
PAYLOD_API_KEY=mp_live_xxxxxxxx
```

```python
paylod = Paylod(os.environ["PAYLOD_API_KEY"])
# ...or just Paylod() — with no argument it reads PAYLOD_API_KEY itself.
```

There is no base URL to configure. There is no access token to fetch or refresh. There is no callback URL to host. If you consume webhooks, add the signing secret. paylod shows the secret one time, when you create the endpoint:

```bash
PAYLOD_WEBHOOK_SECRET=whsec_xxxxxxxx   # only if you consume webhooks
```

### Escape hatches (most integrations do not need these)

```python
paylod = Paylod(
    key,
    # Point at a local stub. The origin is ALLOWLISTED: only https://paylod.dev and
    # https://api.paylod.dev (port 443) are accepted, so that a key which can move money can
    # never be sent to another host. Loopback is the one exception, and it needs the explicit
    # opt-in below -- it is never available with an `mp_live_` key.
    base_url="http://localhost:4010",
    allow_insecure_base_url=True,      # required for loopback; test-only
    timeout=30.0,                      # per HTTP request, seconds
    max_retries=2,                     # transient failures only (network, 5xx, 429)
    test_transport=my_mock_transport,  # TESTS ONLY -- refused with an `mp_live_` key
)
```

`Paylod` is also a context manager. The context manager closes the HTTP client for you:

```python
with Paylod(key) as paylod:
    outcome = paylod.collect_and_wait(amount=100, phone="0712345678", idempotency_key=k)
```

---

## API

### `Paylod(api_key=None, *, simulate=False, base_url=None, webhook_secret=None, timeout=30.0, max_retries=2, allow_insecure_base_url=False, test_transport=None)`

`Paylod` raises `PaylodConfigError` immediately if there is no key. A client without a key fails with a `401` on the first call.

**The HTTP client is SDK-owned and not replaceable.** Version 0.5.0 removed `transport=` and `http_client=`. An injected client or transport operates beneath the SDK protections. The injected transport receives your `Authorization: Bearer` header. The injected transport can then do three things before the SDK sees the request:

1. The transport can follow a cross-origin redirect.
2. The transport can re-authenticate against the new origin.
3. The transport can rewrite the request.

`follow_redirects=False` and the origin allowlist exist to prevent these three actions. Every credentialed request now goes through an SDK-owned transport. That transport pins the origin, and it refuses redirects from beneath. No layer above that transport can remove the origin pinning or the redirect refusal.

`test_transport=` takes a bare `httpx.BaseTransport` for tests. The SDK transport wraps the test transport, so origin pinning and redirect refusal still apply. The SDK **refuses `test_transport=` for `mp_live_` keys**. This posture is the same posture as `allow_insecure_base_url`.

### `collect(*, phone, amount, idempotency_key, account_reference=None, description=None, metadata=None, unsafe_generated_idempotency_key=False) -> CollectAck`

`collect()` sends the STK push. The method returns as soon as the STK push is on the handset. The SDK validates `amount` (positive integer KES, ≤ 150000), `phone` (any Kenyan format), and the field lengths **locally**. Bad input raises `PaylodInvalidRequestError` before the SDK sends the request.

**Pass an idempotency key on every collect call.** Mint one key for each payment attempt. Duplicates of that attempt collapse into one STK push and one charge. A double-clicked Pay button, a refreshed tab, and a redelivered job are duplicates of one attempt. Do not use the order id or the product id as the key. A retry after a wrong PIN is a new attempt, and it needs a new key.

The idempotency key is required. The SDK refuses a collect call without one, before the call leaves your process.

`unsafe_generated_idempotency_key=True` is the only opt-out. Do not use this option in production. The SDK then mints a throwaway key, and it warns on every call. A throwaway key protects nothing, and the call can charge a customer twice.

```python
ack = paylod.collect(
    amount=100,
    phone="0712345678",
    idempotency_key=attempt.id,     # one key per payment ATTEMPT
    account_reference="order-42",   # optional, <= 12 chars; your correlation id
    description="Coffee",           # optional, <= 64 chars; shown on the prompt
    metadata={"order_id": "42"},    # optional, stored; NOT returned on /status or the webhook
)
# CollectAck(payment_id=..., status="pending", checkout_request_id=..., idempotency_key=...)
```

### `status(payment_id) -> Payment`

`status()` returns the raw record `Payment(id, status, mpesa_receipt, result_code, result_desc)`. The state name is `success`, not `paid`.

### `check(payment_id) -> PaymentOutcome`

`check()` returns the same record, already decoded and renderable. Most integrations want this method.

### `wait(payment_id, *, timeout=120.0, on_poll=None) -> PaymentOutcome`

`wait()` polls an existing payment until the payment settles. The poll interval increases from 1s to 1s, 1.5s, 2s, 2.5s, 3s, 4s, and then to a 5s maximum. Each interval gets ±20% jitter, so that many servers do not poll at the same moment. `wait()` uses the **classifier** to decide if the payment settled. `wait()` does not use the raw `status` field.

Result code `4999` and result code `500.001.1001` mean that the STK push is live on the handset. The customer did not enter the PIN yet. The payment is IN FLIGHT, not failed. Daraja reports code `4999` on a row that Daraja also marks `failed`. Therefore `wait()` continues to poll.

### `collect_and_wait(*, phone, amount, ..., timeout=120.0, on_poll=None) -> PaymentOutcome`

`collect_and_wait()` combines `collect()` and `wait()`. Most integrations want this single call.

### The outcome: one renderable shape

```python
@dataclass(frozen=True)
class PaymentOutcome:
    status: str          # "succeeded" | "pending" | "cancelled" | "failed"
    message: str         # customer-facing, already decoded. RENDER THIS.
    retryable: bool      # SAFE TO CHARGE AGAIN. Gate your retry button on this.
    paid: bool           # the one branch a backend needs: if outcome.paid: fulfil()
    receipt: str | None  # M-Pesa confirmation code; non-None exactly when `paid`
    code: str | None     # developer detail — available, never required to render
    detail: DecodedError | None
    payment: Payment
```

Two invariants are important:

1. **`retryable` means SAFE TO CHARGE AGAIN.** It does not mean that the customer may press a button. `retryable = false` means that paylod cannot prove that no debit occurred. Result code `4999` and result code `500.001.1001` mean that the STK push is live on the handset. The customer did not enter the PIN yet. The payment is IN FLIGHT, not failed. A pending payment is never retryable. A second collect call sends a second STK push, and it can charge the customer twice. Gate the retry button on `retryable`.
2. **A wrong PIN is an answer, not an exception.** A cancellation, a wrong PIN, and a low balance come back as data, with `status` and a `message`. They do not raise an error. For these three outcomes the `status` value is `failed`.

**These conditions raise an exception:**

| Raises | When |
|---|---|
| `PaylodInvalidRequestError` | You passed a bad amount or a bad phone number. This condition is a bug in your code. |
| `PaylodConfigError` | There is no API key. This condition is a bug in your deployment. |
| `PaylodApiError` | Non-2xx from paylod (`.status`, `.is_auth_error`, `.is_rate_limited`, `.is_idempotency_conflict`, and `.is_idempotency_indeterminate` / `.is_idempotency_in_progress` / `.is_idempotency_body_conflict` to tell the three `409`s apart). |
| `PaylodConnectionError` | The network failed after retries. |
| `PaylodTimeoutError` | The payment is still `pending` at the deadline. A timeout is not a failed payment. The outcome is INDETERMINATE. The customer can still enter the PIN, and the payment can still succeed. Leave the order pending. The webhook settles the order. |

### `decode_error(result_code, raw_desc=None) -> DecodedError`

`decode_error()` decodes an M-Pesa result code offline. There is no network call. The strings are byte-identical to the strings that paylod puts in `event.data.decoded`. Your UI shows the same text for a poll and for a webhook. You rarely need this method, because `check()`, `wait()` and `collect_and_wait()` already return a decoded `PaymentOutcome`. Use `decode_error()` for logs, dashboards and support tools.

```python
paylod.decode_error(1032)
# DecodedError(code="1032", title="Payment cancelled by the customer",
#              category="customer", retryable=True,
#              customer_message="Payment cancelled -- you can try again whenever you're ready.", ...)
```

Also available standalone: `from paylod import decode_error, ERROR_CATALOG`.

`ERROR_CATALOG` is keyed on the result code **alone**, which is lossy: Daraja reuses codes across
surfaces, and `0`, `2001` and `500.001.1001` each mean two different things. `2001` is a wrong
customer PIN on an STK result but an initiator credential failure on a B2C/C2B result. The STK
entry wins in `ERROR_CATALOG` because STK is the payment path. When the surface matters, decode by
family instead:

```python
paylod.decode_daraja_result("500.001.1001", family="stk_result")  # pending -- keep polling
paylod.decode_daraja_result("500.001.1001", family="api_error")   # terminal M-Pesa system error
```

<details>
<summary>Maintainers: the code table is generated, not hand-written</summary>

`src/paylod/daraja_error_codes.json` is a byte-for-byte copy of the canonical table in the paylod
monorepo (`supabase/functions/_shared/daraja/daraja-error-codes.json`). Never hand-edit it —
regenerate it:

```bash
python scripts/sync_daraja_catalog.py           # write the copy
python scripts/sync_daraja_catalog.py --check   # exit 1 if the copy has drifted
```

The monorepo is found at `../mpesa` by default; override with `MPESA_REPO=/path/to/mpesa`.
`tests/test_catalog_drift.py` enforces this on every `pytest` run, and skips (loudly) when the
monorepo is not checked out.

</details>

---

## Test your checkout without a phone

Most payment bugs are in the failure paths. The simulator removes the handset, and it changes nothing else. You get a real sandbox payment record, real Daraja result codes, and a real signed webhook.

```python
paylod = Paylod(os.environ["PAYLOD_TEST_KEY"])   # mp_test_... key

outcome = paylod.simulate.pay(outcome="wrong_pin")
outcome.status      # "failed"
outcome.message     # "That M-Pesa PIN was incorrect. Please try again and enter the right PIN."
outcome.retryable   # True -- no money moved, so a fresh charge is safe
```

The simulator returns an ordinary `PaymentOutcome`. That object is the same object that `check()` and `wait()` return.

| `outcome=` | `status` | Result code |
| --- | --- | --- |
| `approve` | `succeeded` | `0` |
| `wrong_pin` | `failed` | `2001` |
| `insufficient_funds` | `failed` | `1` |
| `user_cancelled` | `cancelled` | `1032` |
| `timeout` | `failed` | `1037` |

`SIM_OUTCOMES` (also `paylod.simulate.outcomes`) is the complete list. To test your own code, split the simulated payment into two steps. Put your handler between the two steps. The payment id is real, so your poller, your webhook route and your UI run unchanged:

```python
sim = paylod.simulate.collect(amount=250)
paylod.simulate.outcome(sim.payment_id, "insufficient_funds")
view = read_checkout(sim.payment_id)   # <- your code, verbatim
```

You can also build the client with `simulate=True`. Your own `collect()` call then creates a simulated payment, and it sends no STK push to a handset:

```python
paylod = Paylod(os.environ["PAYLOD_TEST_KEY"], simulate=True)
view = start_checkout(order.id, "0712345678", attempt_id)  # your handler, verbatim
paylod.simulate.outcome(view.payment_id, "user_cancelled")
```

**The simulator is sandbox only.** Every simulator call refuses an `mp_live_` key locally, before the call leaves your process. The call raises `PaylodSandboxOnlyError`. An `mp_live_` key with `simulate=True` raises from the constructor.

---

## Webhooks

paylod sends a signed JSON body to your endpoint when a payment settles:

```
POST /your/webhook
x-webhook-signature: t=1700000000,v1=<hex hmac-sha256>
x-webhook-id: <event id>
x-webhook-event: payment.success
```

The signature is `HMAC-SHA256(secret, f"{t}.{raw_body}")`. The SDK checks the timestamp against a 300s tolerance, to prevent a replay. The SDK uses a constant-time compare.

Verify the signature against the raw bytes. A re-serialised body does not reproduce the same bytes, and the signature check then fails. Pass the exact bytes that arrived. In Flask use `request.get_data()`. In FastAPI use `await request.body()`.

```python
from paylod import verify_webhook, parse_webhook_event

# Pure predicate -- returns True/False, never raises for a bad signature:
ok = verify_webhook(raw_body, request.headers["x-webhook-signature"], secret)

# Or verify AND get the typed event (raises PaylodSignatureVerificationError on any failure):
event = parse_webhook_event(raw_body, request.headers.get("x-webhook-signature"), secret)
if event.type == "payment.success":
    fulfil(event.data.payment_id, event.data.mpesa_receipt)
else:
    mark_failed(event.data.payment_id, event.data.decoded.customer_message)
```

The client exposes both functions against its configured secret: `paylod.verify_webhook(raw_body, sig_header)` and `paylod.parse_webhook_event(raw_body, sig_header)`.

**paylod can deliver the same webhook more than once.** Key your fulfilment on the signed `data.payment_id`, and make the fulfilment idempotent. Do not use the `x-webhook-id` header for this check. The header is unsigned and outside the HMAC, so an attacker can replay a body under a new header value. `data.payment_id` is inside the signed payload, so nobody can forge the payment id.

The SDK exports `sign_webhook(payload, secret, timestamp=None)`. Use this function to build realistic fixtures in your own tests, with no network.

---

## Idempotency

An idempotency key names **one payment attempt**. Duplicates of that one attempt collapse into a single charge. A double-clicked Pay button, a refreshed tab, a redelivered job, and an internal network retry are duplicates of one attempt.

The idempotency key is required. The SDK refuses a collect call without one, before the call leaves your process.

| Key you pass | What happens |
| --- | --- |
| An id minted per **payment attempt** | Correct. Duplicates collapse. A new attempt is a new charge. |
| Your **order id** | Stable, but never fresh. A retry after a wrong PIN replays the **failed** first attempt. That order never gets paid. |
| A **product id** (reused across purchases) | Catastrophic. Every customer after the first one replays the first payment. paylod charges nobody after customer one. |
| `uuid4()` **at the call site** | Equivalent to no key. A double-click makes two keys, two STK pushes, and two charges. |

One rule covers all four rows: **mint the key when a payment attempt starts, and persist the key on that attempt. Never reuse the key for a different payment.**

A concurrent double-click cannot double-charge. paylod reserves the key before it calls the provider. Ten simultaneous requests with the same key produce **one** payment and **one** STK push.

There is one exception to that behaviour. Read the payment status before you retry. An interrupted request spends its idempotency key, and paylod answers `409` indeterminate (`.is_idempotency_indeterminate`). The `409` is a STOP signal, not a retry signal. paylod cannot prove that no debit occurred, so paylod refuses to repeat the request. If the payment settled, you are done. If nothing occurred, start a new attempt with a NEW key. For money, at-most-once is better than at-least-once.

`unsafe_generated_idempotency_key=True` is the only opt-out. Do not use this option in production. The SDK then mints a throwaway key, and it warns on every call. A throwaway key protects nothing, and the call can charge a customer twice.

---

## License

MIT
