Skip to content

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 · GA

PyPI

Version
1.1.1
Runtime
Python 3.10+, httpx 0.27+

Covers

  • Payments
  • Billing
  • Transfers
  • Sync
  • Shield
  • Tax

Until a fixed release lands, install from the tag instead. The package builds correctly from source.

Install from the repository
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 wajub

Create the client

The constructor is keyword only, and it falls back to the environment, so the common case is no arguments at all.

wajub_client.py
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"],
)
KeywordWhat it does
api_keyYour sk. or sk_test. key. Falls back to WAJUB_API_KEY
webhook_secretThe whsec_ secret. Falls back to WAJUB_WEBHOOK_SECRET
idempotency_key_prefixPrefix of the generated key. Defaults to "wajub"
http_clientYour own httpx.Client, when you need a proxy or a mounted transport
max_network_retriesRetries 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 closes, a server does not
# 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

Create a payment
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.

Two ways to read the same field
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

AttributeMethods
wajub.global_ping, channels, countries, currencies
wajub.paymentscreate, initialize, retrieve, list, cancel, process, process_split, list_refunds
wajub.customerscreate, retrieve, update, delete, list, block, unblock, activate, deactivate, list_tax_ids, create_tax_id, delete_tax_id
wajub.refundscreate, retrieve, list
wajub.transferscreate, retrieve, list
wajub.beneficiariescreate, retrieve, update, delete, list
wajub.linkscreate, retrieve, update, delete, list
wajub.invoicescreate, retrieve, update, delete, list, send, mark_paid, cancel
wajub.accountscreate, retrieve, update, delete, list, regenerate_token
wajub.webhook_endpointscreate, retrieve, update, delete, list, rotate_secret
wajub.balanceretrieve
wajub.eventslist, retrieve, resend
wajub.disputeslist, retrieve, submit_evidence, accept, close, send_message
wajub.identityresolve, validate
wajub.taxget_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.shieldget_settings, update_settings, stats, list_blocklist, add_to_blocklist, remove_from_blocklist
wajub.listenconfig, auth
wajub.webhooksconstruct_event
  • global is a Python keyword, so the accessor is wajub.global_ for ping, channels, countries and currencies.
  • webhooks signature verification runs locally, no HTTP call. All other resources call the merchant REST API.
  • links, invoices, tax and shield are live only. A sandbox key gets 403 This feature is only available in live mode. on every one of their methods.
  • refunds and transfers are create, retrieve and list only. The shared CRUD base also exposes update and delete on 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.

One page, or all of them
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.

An order number is the best key
from wajub import RequestOptions

wajub.payments.create(
    params,
    RequestOptions(idempotency_key=f"order-{order_id}"),
)
WhatValue
Retried statuses429, 500, 502, 503, 504, and any network failure
Attempts3 in total, one original plus two retries
BackoffExponential with jitter, honouring Retry-After on a 429
Timeout30 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

One call, one seller
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)

Tolerance is 300 seconds by default, and construct_event takes a fourth argument if your clocks drift further. Details on Signature verification.

Errors

Catch the specific one first
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
ClassRaised on
AuthenticationError401
PermissionError403
NotFoundError404
InvalidRequestError400 and 422
RateLimitError429, with retry_after in seconds
WajubErrorEvery other status, and the base of all of the above
ApiConnectionErrorNo response at all, network or timeout
WebhookSignatureVerificationErrorA webhook that did not verify

Each one carries message, code, http_status, errors and raw.

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.

Narrow it yourself where it matters
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

Mobile Money push
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

A transfer to a phone number
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.

Two terminals
wajub listen --forward-to localhost:8000/webhooks/wajub
wajub trigger payment.succeeded

More on the CLI.

What did you think of this content?