Python
The wajub package, its resources, and the one thing blocking pip today.
The Wajub Python SDK is a thin, synchronous client over the merchant API. It holds your secret
key, creates payments, reads their real status and verifies webhook signatures. One dependency,
httpx, and nothing else.
wajub
Stable · GAPyPI
- Version
- 1.1.1
- Runtime
- Python 3.10+, httpx 0.27+
Covers
- Payments
- Billing
- Transfers
- Sync
- Shield
- Tax
The published package is currently empty
pip install wajub succeeds, and then import wajub raises ModuleNotFoundError. The
1.1.1 wheel on PyPI contains a single file, wajub/py.typed, and no module at all. The source
in the repository is complete and correct, so everything documented below is accurate. Only the
distribution is broken.
Until a fixed release lands, install from the tag instead. The package builds correctly from source.
pip install "git+https://github.com/wajubhq/wajub-python@v1.1.1"Once the release is fixed, the ordinary line works and nothing else on this page changes.
pip install wajubCreate the client
The constructor is keyword only, and it falls back to the environment, so the common case is no arguments at all.
import os
from wajub import Wajub
# Reads WAJUB_API_KEY and WAJUB_WEBHOOK_SECRET from the environment.
wajub = Wajub()
# Or pass them yourself.
wajub = Wajub(
api_key=os.environ["WAJUB_API_KEY"],
webhook_secret=os.environ["WAJUB_WEBHOOK_SECRET"],
)| Keyword | What it does |
|---|---|
api_key | Your sk. or sk_test. key. Falls back to WAJUB_API_KEY |
webhook_secret | The whsec_ secret. Falls back to WAJUB_WEBHOOK_SECRET |
idempotency_key_prefix | Prefix of the generated key. Defaults to "wajub" |
http_client | Your own httpx.Client, when you need a proxy or a mounted transport |
max_network_retries | Retries on transient failures. Defaults to 2 |
An empty key raises ValueError: Wajub: api_key is required at construction, not on the first
call, so a bad configuration fails at import time rather than in production.
It is a context manager
Wajub owns an httpx.Client and its connection pool. Closing it matters in a script and does
not matter in a long lived web process.
# A script, a task, a notebook: let the block close the pool.
with Wajub() as wajub:
payment = wajub.payments.create({"amount": 25000, "currency": "XAF"})
# A Django or FastAPI process: build it once at import and leave it open.
wajub = Wajub()Pass your own client rather than reaching for async
The SDK is synchronous. In an async def view, wrap a call in asyncio.to_thread or run it on
your framework's thread pool. Handing http_client= an httpx.AsyncClient does not work, the
calls are plain client.request.
The first call
payment = wajub.payments.create(
{
"amount": 25000,
"currency": "XAF",
"email": "amina@example.com",
"description": "Order 4172",
"reference": "order-4172",
"callback": "https://shop.example.com/complete",
}
)
print(payment.id)
print(payment.authorization_url)Parameters go in as a single dict, not as keyword arguments, and they keep the API's exact field
names. The result is an ApiObject, which exposes every field as an attribute and still behaves
like a mapping.
payment.authorization_url
payment["authorization_url"]
# A field the API added after this release is still reachable.
payment["settlement_batch_id"]Amounts are in the major unit
25000 with XAF is twenty-five thousand francs. A decimal currency takes a decimal:
"amount": 12.50 with GHS.
Every resource on the client
| Attribute | Methods |
|---|---|
| wajub.global_ | ping, channels, countries, currencies |
| wajub.payments | create, initialize, retrieve, list, cancel, process, process_split, list_refunds |
| wajub.customers | create, retrieve, update, delete, list, block, unblock, activate, deactivate, list_tax_ids, create_tax_id, delete_tax_id |
| wajub.refunds | create, retrieve, list |
| wajub.transfers | create, retrieve, list |
| wajub.beneficiaries | create, retrieve, update, delete, list |
| wajub.links | create, retrieve, update, delete, list |
| wajub.invoices | create, retrieve, update, delete, list, send, mark_paid, cancel |
| wajub.accounts | create, retrieve, update, delete, list, regenerate_token |
| wajub.webhook_endpoints | create, retrieve, update, delete, list, rotate_secret |
| wajub.balance | retrieve |
| wajub.events | list, retrieve, resend |
| wajub.disputes | list, retrieve, submit_evidence, accept, close, send_message |
| wajub.identity | resolve, validate |
| wajub.tax | get_settings, update_settings, rates, calculate, reports, list_codes, retrieve_code, list_registrations, create_registration, retrieve_registration, update_registration, delete_registration, jurisdictions, thresholds, threshold_alerts |
| wajub.shield | get_settings, update_settings, stats, list_blocklist, add_to_blocklist, remove_from_blocklist |
| wajub.listen | config, auth |
| wajub.webhooks | construct_event |
globalis a Python keyword, so the accessor iswajub.global_for ping, channels, countries and currencies.webhookssignature verification runs locally, no HTTP call. All other resources call the merchant REST API.links,invoices,taxandshieldare live only. A sandbox key gets403 This feature is only available in live mode.on every one of their methods.refundsandtransfersare create, retrieve and list only. The shared CRUD base also exposesupdateanddeleteon them, but the API serves no such route.
Paging through a list
list() returns a PagedResult. It holds the rows, the metadata, and two ways to keep going.
page = wajub.payments.list({"status": "success", "per_page": 50})
# page.data holds plain dicts, not ApiObject, so use item access here.
for payment in page.data:
print(payment["id"], payment["amount"])
if page.has_more:
page = page.get_next_page()
# Or let it walk every page for you.
for payment in wajub.payments.list().auto_paging_iter():
reconcile(payment)Idempotency and retries
Every POST and PUT carries a generated Idempotency-Key, which is what makes the automatic
retry safe. Supply your own whenever you have a natural one.
from wajub import RequestOptions
wajub.payments.create(
params,
RequestOptions(idempotency_key=f"order-{order_id}"),
)| What | Value |
|---|---|
| Retried statuses | 429, 500, 502, 503, 504, and any network failure |
| Attempts | 3 in total, one original plus two retries |
| Backoff | Exponential with jitter, honouring Retry-After on a 429 |
| Timeout | 30 seconds per request, set on the default httpx.Client |
To change the timeout, pass your own client: Wajub(http_client=httpx.Client(timeout=60.0)).
Acting for a connected account
from wajub import RequestOptions
wajub.payments.create(
{"amount": 25000, "currency": "XAF", "email": buyer.email},
RequestOptions(sync=seller.wajub_account_id),
)sync becomes the X-Sync header. Setup is on Sync.
Webhooks
construct_event verifies the signature and returns the parsed event. It needs the body exactly
as it arrived.
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from .wajub_client import wajub
@csrf_exempt
def wajub_webhook(request):
try:
event = wajub.webhooks.construct_event(
request.body,
request.headers["X-Wajub-Signature"],
request.headers["X-Wajub-Timestamp"],
)
except Exception:
return HttpResponse(status=400)
if event["event"] == "payment.succeeded":
fulfil(event["data"])
return HttpResponse(status=200)request.json is not the body
The signature covers {timestamp}.{raw body}. Re-serialising a parsed dict changes key order
and spacing, so the hash stops matching. Use request.body in Django, get_data() in Flask,
await request.body() in FastAPI. Every one of them returns bytes, which is what
construct_event wants.
Tolerance is 300 seconds by default, and construct_event takes a fourth argument if your clocks
drift further. Details on
Signature verification.
Errors
from wajub import InvalidRequestError, RateLimitError, WajubError
try:
wajub.payments.create(params)
except InvalidRequestError as error:
# error.errors is {"amount": ["The amount must be at least 25."]}
return JsonResponse({"fields": error.errors}, status=422)
except RateLimitError as error:
return HttpResponse(status=503, headers={"Retry-After": str(error.retry_after or 5)})
except WajubError as error:
logger.error("wajub failed", extra={"code": error.code, "status": error.http_status})
raise| Class | Raised on |
|---|---|
AuthenticationError | 401 |
PermissionError | 403 |
NotFoundError | 404 |
InvalidRequestError | 400 and 422 |
RateLimitError | 429, with retry_after in seconds |
WajubError | Every other status, and the base of all of the above |
ApiConnectionError | No response at all, network or timeout |
WebhookSignatureVerificationError | A webhook that did not verify |
Each one carries message, code, http_status, errors and raw.
PermissionError shadows a builtin
wajub.PermissionError and Python's own PermissionError share a name. A bare
from wajub import * silently replaces the builtin. Import the names you use, or keep the
module: import wajub then except wajub.PermissionError.
Typing
The package ships py.typed, so mypy and Pyright read its annotations with nothing to install.
The shapes are honest about what the SDK knows: parameters are dict[str, Any], and results are
ApiObject, a dynamic mapping. You get type safety on the call, not on every response field.
from typing import TypedDict
class Checkout(TypedDict):
session_token: str
pay_url: str
def start_checkout(order_id: str, amount: float, email: str) -> Checkout:
payment = wajub.payments.create(
{
"amount": amount,
"currency": "XAF",
"email": email,
"reference": order_id,
},
RequestOptions(idempotency_key=f"order-{order_id}"),
)
return {
"session_token": payment.authorization_token,
"pay_url": payment.authorization_url,
}Charging without the hosted page
payment = wajub.payments.create(
{"amount": 25000, "currency": "XAF", "phone": "+237670000000"}
)
wajub.payments.process(
payment.id,
{"channel": "cm.mtn", "phone": "+237670000000"},
)A channel is country.operator, so cm.mtn is MTN Mobile Money in Cameroon. The full list is on
Payment methods. The payer still approves on their handset, so the
outcome arrives on the webhook.
Paying out
transfer = wajub.transfers.create(
{
"amount": 100000,
"currency": "XAF",
"beneficiary": {
"name": "Amina Diallo",
"channel": "cm.mtn",
"phone": "+237670000000",
},
"reference": "payout-892",
},
RequestOptions(idempotency_key="payout-892"),
)beneficiary also takes the ben_… id of a saved beneficiary, which is the better shape once you
pay the same person twice.
Local development
Forward webhooks to your machine with the CLI rather than exposing a tunnel.
wajub listen --forward-to localhost:8000/webhooks/wajub
wajub trigger payment.succeededMore on the CLI.
Related pages
- SDK QuickstartThe same first call, in five languages side by side.
- WebhooksEvery event, and the delivery guarantees behind them.
- Naming conventionsWhy this SDK stays snake_case while Go and Java do not.
- IdempotencyWhat a key protects and for how long.
- Error handlingWhich failures to retry and which to surface.
- API referenceThe endpoints behind every method above.