Skip to content

Python

The wajub package, its resources, and the version to install.

The wajub package is your server's Wajub client: with your secret key, it creates payments, reads their status and verifies webhook signatures. Its calls are synchronous.

wajub

Stable · GA

PyPI

Version
1.2.1
Runtime
Python 3.10+, httpx 0.27+

Covers

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

Install

pip install "wajub>=1.2.1"

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
http_clientYour own httpx.Client, for a proxy or a mounted transport. Give it base_url="https://api.wajub.com"
max_network_retriesHow many times a temporary failure is retried

It is a context manager

In a script, a task or a notebook, open it in a with block so it is closed when the block ends. In a web process, build it once and leave it open.

A script closes, a server does not
# A script, a task, a notebook: let the block close it.
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()

In async code, use a thread

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. http_client= takes an httpx.Client, not an httpx.AsyncClient.

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"]

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, 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.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.

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": "succeeded", "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

Temporary failures are retried for you; max_network_retries changes how many times. To make a create safe to repeat on your side too, supply your own idempotency key 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}"),
)

To set the request timeout, pass your own client:

A longer timeout
import httpx

wajub = Wajub(http_client=httpx.Client(base_url="https://api.wajub.com", 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),
)

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)

If your clocks drift, construct_event takes the tolerance in seconds as a fourth argument. 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."}, one string per field
    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

mypy and Pyright read the package's annotations with nothing to install. Parameters are dict[str, Any] and results are ApiObject, so type the fields you rely on yourself.

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 Mobile Money. 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?