Official Python client for the paylod API -- M-Pesa (Safaricom Daraja) STK Push, status polling, signed webhooks and offline error decoding.
Project description
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
pip install paylod
Requires Python 3.9+. There is one runtime dependency: httpx. 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.
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:
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.
PAYLOD_API_KEY=mp_live_xxxxxxxx
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:
PAYLOD_WEBHOOK_SECRET=whsec_xxxxxxxx # only if you consume webhooks
Escape hatches (most integrations do not need these)
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:
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:
- The transport can follow a cross-origin redirect.
- The transport can re-authenticate against the new origin.
- 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.
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
@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:
retryablemeans SAFE TO CHARGE AGAIN. It does not mean that the customer may press a button.retryable = falsemeans that paylod cannot prove that no debit occurred. Result code4999and result code500.001.1001mean 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 onretryable.- A wrong PIN is an answer, not an exception. A cancellation, a wrong PIN, and a low balance come back as data, with
statusand amessage. They do not raise an error. For these three outcomes thestatusvalue isfailed.
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 409s 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.
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:
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
Maintainers: the code table is generated, not hand-written
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:
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.
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.
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:
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:
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().
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
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 paylod-0.10.1.tar.gz.
File metadata
- Download URL: paylod-0.10.1.tar.gz
- Upload date:
- Size: 255.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d6e8ef1d89f7e8ef31184bb4f533327f0675c779dcc1623be7bfbb17ac6c7113
|
|
| MD5 |
eee907cf45859d7a6ba16d85b08b75e6
|
|
| BLAKE2b-256 |
81d1a2c8d48f2a67b72aab7c6a593834c7c98c3b5034249b785a2f9214cba57f
|
Provenance
The following attestation bundles were made for paylod-0.10.1.tar.gz:
Publisher:
release.yml on mosesmrima/paylod-python
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
paylod-0.10.1.tar.gz -
Subject digest:
d6e8ef1d89f7e8ef31184bb4f533327f0675c779dcc1623be7bfbb17ac6c7113 - Sigstore transparency entry: 2205092012
- Sigstore integration time:
-
Permalink:
mosesmrima/paylod-python@212f2b4cfb7061fb7e9548e7a014a7160616d367 -
Branch / Tag:
refs/tags/v0.10.1 - Owner: https://github.com/mosesmrima
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@212f2b4cfb7061fb7e9548e7a014a7160616d367 -
Trigger Event:
release
-
Statement type:
File details
Details for the file paylod-0.10.1-py3-none-any.whl.
File metadata
- Download URL: paylod-0.10.1-py3-none-any.whl
- Upload date:
- Size: 133.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a1625acce9f50596d8041f2e16f5b90f85e721e980f63f0975f86c653c99946e
|
|
| MD5 |
2e09b3d05b3a20fce61223b5b66e0d79
|
|
| BLAKE2b-256 |
8b3067e709910077cc1f559a545f49e3aa444d714aac08a4159bbf1212d120d0
|
Provenance
The following attestation bundles were made for paylod-0.10.1-py3-none-any.whl:
Publisher:
release.yml on mosesmrima/paylod-python
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
paylod-0.10.1-py3-none-any.whl -
Subject digest:
a1625acce9f50596d8041f2e16f5b90f85e721e980f63f0975f86c653c99946e - Sigstore transparency entry: 2205092040
- Sigstore integration time:
-
Permalink:
mosesmrima/paylod-python@212f2b4cfb7061fb7e9548e7a014a7160616d367 -
Branch / Tag:
refs/tags/v0.10.1 - Owner: https://github.com/mosesmrima
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@212f2b4cfb7061fb7e9548e7a014a7160616d367 -
Trigger Event:
release
-
Statement type: