Skip to main content

A comprehensive Python SDK for the Wave Business API (Balance, Checkout, Payout, Aggregated Merchants, Webhooks)

Project description

wave-business-api

PyPI version Python 3.8+ License: MIT

A comprehensive, production-ready Python SDK for the Wave Business API.
Supports Balance & Reconciliation, Checkout, Payout, Aggregated Merchants, and Webhooks.

Built by Jean Marie Daniel Vianney Guedegbe — Full Stack Python & JavaScript Developer
GitHub: @daniel10027 · LinkedIn: daniel-guedegbe


Table of Contents


Installation

pip install wave-business-api

Requirements: Python 3.8+, requests>=2.28


Quick Start

from wave_business_api import WaveClient

client = WaveClient(api_key="wave_sn_prod_YhUNb9d...i4bA6")

# Check your wallet balance
balance = client.balance.get()
print(f"Balance: {balance['amount']} {balance['currency']}")

# Create a payment checkout
session = client.checkout.create_session(
    amount="5000",
    currency="XOF",
    success_url="https://myapp.com/success",
    error_url="https://myapp.com/error",
)
print("Redirect user to:", session["wave_launch_url"])

# Send money to someone
payout = client.payout.create(
    currency="XOF",
    receive_amount="10000",
    mobile="+221555110219",
    name="Fatou Ndiaye",
)
print("Payout status:", payout["status"])

Authentication

API Key

All requests require a Wave Business API key passed as a Bearer token. Get yours from the Wave Business Portal under Developer → API Keys.

from wave_business_api import WaveClient

client = WaveClient(api_key="wave_sn_prod_YhUNb9d...i4bA6")

Security: Never hardcode your API key. Use environment variables:

import os
from wave_business_api import WaveClient

client = WaveClient(api_key=os.environ["WAVE_API_KEY"])

Request Signing (HMAC-SHA256)

For enhanced security, enable request signing when creating your API key in the Wave Business Portal. Every request will include a Wave-Signature header with an HMAC-SHA256 signature.

client = WaveClient(
    api_key=os.environ["WAVE_API_KEY"],
    signing_secret=os.environ["WAVE_SIGNING_SECRET"],
)

Once enabled on an API key, request signing cannot be disabled. If you lose the signing secret, revoke the key and create a new one.


Balance & Reconciliation API

Get Balance

balance = client.balance.get()
# {"amount": "10245", "currency": "XOF"}

# Include subaccount balances
balance = client.balance.get(include_subaccounts=True)

Get Transactions

Returns transactions for a specific day (paginated, up to 1000 per page).

result = client.balance.get_transactions(date="2024-01-15")

print(f"Transactions on {result['date']}:")
for tx in result["items"]:
    print(f"  {tx['transaction_id']}: {tx['amount']} {tx['currency']}")

# Handle pagination manually
if result["page_info"]["has_next_page"]:
    next_page = client.balance.get_transactions(
        date="2024-01-15",
        after=result["page_info"]["end_cursor"],
    )

Iterate All Transactions

Automatically handles pagination and yields every transaction for a given day:

for tx in client.balance.get_all_transactions(date="2024-01-15"):
    print(tx["transaction_id"], tx["amount"], tx.get("counterparty_name"))

Refund a Transaction

client.balance.refund_transaction("T_VZSWJF5MMQ")
# Returns None on success; raises WaveAPIError on failure

Checkout API

Create a Checkout Session

session = client.checkout.create_session(
    amount="2500",           # Amount in smallest currency unit (string)
    currency="XOF",
    success_url="https://myapp.com/payment/success",
    error_url="https://myapp.com/payment/error",
    client_reference="order-42",          # Optional: your internal reference
    restrict_payer_mobile="+221555110219", # Optional: restrict to one phone
)

# Redirect the user to this URL to open the Wave app
print(session["wave_launch_url"])
print(session["id"])  # cos-18qq25rgr100a

Important: Open wave_launch_url directly in the browser. Do not wrap it in a webview.

Get a Checkout Session

session = client.checkout.get_session("cos-18qq25rgr100a")
print(session["checkout_status"])  # "open" | "complete" | "expired"
print(session["payment_status"])   # "processing" | "cancelled" | "succeeded"

Get Session by Transaction ID

session = client.checkout.get_session_by_transaction("TCN4Y4ZC3FM")

Search Sessions

Search by your client_reference:

sessions = client.checkout.search_sessions("order-42")
for s in sessions:
    print(s["id"], s["payment_status"])

Expire a Session

Prevent further payment attempts on an open session:

client.checkout.expire_session("cos-18qq25rgr100a")

Refund a Checkout

client.checkout.refund_session("cos-18qq25rgr100a")
# Idempotent: calling twice does not create a duplicate refund

Payout API

Send a Single Payout

Synchronous — Wave attempts the transfer immediately and returns the result. Always provide (or let the SDK auto-generate) an idempotency key.

payout = client.payout.create(
    currency="XOF",
    receive_amount="15000",         # Net amount recipient receives
    mobile="+221555110219",
    name="Moustapha Mbaye",         # Optional: for verification
    payment_reason="Salary Jan",    # Optional: shown in app (max 40 chars)
    client_reference="salary-jan-001",
)

if payout["status"] == "succeeded":
    print(f"Sent! Payout ID: {payout['id']}, fee: {payout['fee']}")
elif payout["status"] == "failed":
    error = payout.get("payout_error", {})
    print(f"Failed: {error.get('error_code')}{error.get('error_message')}")

Retry with same idempotency key

my_key = "my-unique-idempotency-key-001"

payout = client.payout.create(
    currency="XOF",
    receive_amount="5000",
    mobile="+221555110219",
    idempotency_key=my_key,
)

# Safe to retry on network errors — same key = no duplicate
payout = client.payout.create(
    currency="XOF",
    receive_amount="5000",
    mobile="+221555110219",
    idempotency_key=my_key,  # same key
)

Get a Payout

payout = client.payout.get("pt-185b5e4b8100c")
print(payout["status"])  # "processing" | "succeeded" | "failed" | "reversed"

Search Payouts

payouts = client.payout.search("salary-jan-001")
for p in payouts:
    print(p["id"], p["status"])

Create a Payout Batch

Asynchronous — submit many payouts at once and poll for results.

batch = client.payout.create_batch([
    {"currency": "XOF", "receive_amount": "1000", "mobile": "+221555110219", "name": "Fatou"},
    {"currency": "XOF", "receive_amount": "2000", "mobile": "+221555110233", "name": "Moustapha"},
    {"currency": "XOF", "receive_amount": "3000", "mobile": "+221555144081", "name": "Mame"},
])

batch_id = batch["id"]
print(f"Batch submitted: {batch_id}")

Get a Payout Batch

Poll until status == "complete":

import time

while True:
    batch = client.payout.get_batch(batch_id)
    if batch["status"] == "complete":
        break
    print("Still processing...")
    time.sleep(3)

for p in batch["payouts"]:
    if p["status"] == "succeeded":
        print(f"✓ {p['mobile']} received {p['receive_amount']}")
    elif p["status"] == "failed":
        err = p.get("payout_error", {})
        print(f"✗ {p['mobile']} failed: {err.get('error_code')}")

Reverse a Payout

Reverse a payout within 3 days of creation:

client.payout.reverse("pt-185b5e4b8100c")
# Idempotent: reversing an already reversed payout returns success

Verify a Recipient

Check a recipient exists and the amount is within their limits before sending:

result = client.payout.verify_recipient(
    mobile="+221555110219",
    name="Fatou Ndiaye",
    amount="10000",
    currency="XOF",
)

# result["name_match"]:    "MATCH" | "NO_MATCH" | "NAME_NOT_KNOWN"
# result["within_limits"]: True | False | None (if no amount provided)
# result["national_id_match"]: "MATCH" | "NO_MATCH" | "ID_NOT_KNOWN" (if enabled)

if result.get("within_limits") and result.get("name_match") == "MATCH":
    payout = client.payout.create(
        currency="XOF",
        receive_amount="10000",
        mobile="+221555110219",
    )

Aggregated Merchants API

Access is limited to selected Wave aggregator partners. Contact your Wave representative to request access.

List Merchants

result = client.aggregated_merchants.list(first=20)
for merchant in result["items"]:
    print(merchant["id"], merchant["name"], merchant["is_locked"])

# Automatically paginate through all merchants
for merchant in client.aggregated_merchants.list_all():
    print(merchant["name"])

Create a Merchant

merchant = client.aggregated_merchants.create(
    name="Fatou's Grocery Store",          # Must be unique
    business_type="other",                  # "other" or "fintech"
    business_description="Local grocery store in Dakar.",
    business_sector="retail",
    website_url="https://fatous-grocery.example.com",
    manager_name="Fatou Diallo",
    business_registration_identifier="RC-DKR-2024-001",
)

print(merchant["id"])          # am-...
print(merchant["is_locked"])   # False (until Wave reviews it)

Get a Merchant

merchant = client.aggregated_merchants.get("am-7lks22ap113t4")
print(merchant["payout_fee_structure_name"])    # "one_percent" or "one_fifty_bps"
print(merchant["checkout_fee_structure_name"])

Update a Merchant

updated = client.aggregated_merchants.update(
    merchant_id="am-7lks22ap113t4",
    name="Fatou's Super Market",
    business_type="other",
    business_description="An expanded grocery and household store.",
)
# Raises WaveAuthError if the merchant is locked by Wave

Delete a Merchant

client.aggregated_merchants.delete("am-7lks22ap113t4")

Webhooks

Wave sends HTTP POST requests to your endpoint when events occur. Always verify the signature before processing.

Signing Secret (Recommended)

from wave_business_api import WebhookVerifier, WaveWebhookError

verifier = WebhookVerifier(signing_secret="wave_sn_WHS_...")

Shared Secret

verifier = WebhookVerifier(shared_secret="your_shared_secret")

Flask Integration

from flask import Flask, request, jsonify
from wave_business_api import WebhookVerifier, WaveWebhookError
import os

app = Flask(__name__)
verifier = WebhookVerifier(signing_secret=os.environ["WAVE_WEBHOOK_SECRET"])

@app.route("/webhook/wave", methods=["POST"])
def wave_webhook():
    try:
        event = verifier.verify_and_parse(
            raw_body=request.get_data(as_text=True),
            wave_signature=request.headers.get("Wave-Signature"),
        )
    except WaveWebhookError as e:
        app.logger.warning(f"Webhook verification failed: {e}")
        return jsonify({"error": "Invalid signature"}), 401

    event_type = event["type"]
    data = event["data"]

    if event_type == "checkout.session.completed":
        client_ref = data.get("client_reference")
        transaction_id = data.get("transaction_id")
        # Fulfill the order...

    elif event_type == "checkout.session.payment_failed":
        # Notify customer...
        pass

    elif event_type == "merchant.payment_received":
        amount = data.get("amount")
        sender = data.get("sender_mobile")
        # Update your records...
        pass

    return jsonify({"status": "ok"}), 200

Django Integration

# views.py
import json
from django.http import HttpResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from wave_business_api import WebhookVerifier, WaveWebhookError
import os

verifier = WebhookVerifier(signing_secret=os.environ["WAVE_WEBHOOK_SECRET"])

@csrf_exempt
def wave_webhook(request):
    if request.method != "POST":
        return HttpResponse(status=405)

    try:
        event = verifier.verify_and_parse(
            raw_body=request.body.decode("utf-8"),
            wave_signature=request.META.get("HTTP_WAVE_SIGNATURE"),
        )
    except WaveWebhookError:
        return HttpResponseForbidden("Invalid signature")

    event_type = event["type"]

    if event_type == "checkout.session.completed":
        # process payment...
        pass

    return HttpResponse("OK")

FastAPI Integration

from fastapi import FastAPI, Request, HTTPException
from wave_business_api import WebhookVerifier, WaveWebhookError
import os

app = FastAPI()
verifier = WebhookVerifier(signing_secret=os.environ["WAVE_WEBHOOK_SECRET"])

@app.post("/webhook/wave")
async def wave_webhook(request: Request):
    raw_body = (await request.body()).decode("utf-8")
    wave_signature = request.headers.get("wave-signature")

    try:
        event = verifier.verify_and_parse(
            raw_body=raw_body,
            wave_signature=wave_signature,
        )
    except WaveWebhookError as e:
        raise HTTPException(status_code=401, detail=str(e))

    event_type = event["type"]

    if event_type == "checkout.session.completed":
        data = event["data"]
        # Fulfill order for data["client_reference"]

    return {"status": "ok"}

Event Types

Event Description
checkout.session.completed Customer successfully paid a checkout session
checkout.session.payment_failed A checkout payment attempt failed
b2b.payment_received Your business received a B2B payment
b2b.payment_failed A B2B payment to your business failed
merchant.payment_received Your business received a customer payment
test.test_event Manual test event from the Business Portal

Error Handling

All API errors raise typed exceptions that extend WaveAPIError:

Exception HTTP Status When
WaveAuthError 401, 403 Invalid key, revoked key, missing signature
WaveNotFoundError 404 Resource not found
WaveValidationError 400, 422 Invalid request data
WaveIdempotencyError 409 Idempotency key conflict
WaveRateLimitError 429 Too many requests
WaveServerError 500, 503 Wave server error
WaveWebhookError Invalid webhook signature
from wave_business_api import WaveClient
from wave_business_api.exceptions import (
    WaveAuthError,
    WaveNotFoundError,
    WaveRateLimitError,
    WaveServerError,
    WaveValidationError,
)
import time

client = WaveClient(api_key="...")

try:
    payout = client.payout.create(
        currency="XOF",
        receive_amount="5000",
        mobile="+221555110219",
    )
except WaveAuthError as e:
    print(f"Authentication failed: {e.error_code}")
except WaveValidationError as e:
    print(f"Invalid request: {e.message}")
    if e.details:
        for detail in e.details:
            print(f"  Field {detail['loc']}: {detail['msg']}")
except WaveRateLimitError:
    print("Rate limited — waiting before retry")
    time.sleep(5)
except WaveServerError as e:
    print(f"Wave server error (retry with same idempotency key): {e}")
except WaveNotFoundError:
    print("Resource not found")

All exceptions expose:

  • .message — human-readable error message
  • .status_code — HTTP status code
  • .error_code — Wave error code string
  • .details — validation error details (if any)

IP Whitelisting

If you enable IP whitelisting in the Wave Business Portal, ensure your server's IP is on the allowlist. For webhooks, whitelist these Wave IP addresses on your firewall:

104.155.43.220/32    34.140.23.175/32    34.22.138.147/32
34.76.157.22/32      34.78.253.137/32    34.79.119.200/32
35.189.207.30/32     35.195.255.192/32   35.205.122.113/32
35.205.190.121/32    35.233.61.130/32    35.240.61.196/32
35.240.75.65/32      35.241.190.127/32   35.241.219.1/32

Testing

# Install dev dependencies
pip install wave-business-api[dev]

# Run the test suite
pytest tests/ -v

# With coverage
pytest tests/ -v --cov=wave_business_api --cov-report=term-missing

Contributing

Contributions are welcome!

  1. Fork the repository: github.com/daniel10027/wave-business-api
  2. Create your feature branch: git checkout -b feature/my-feature
  3. Run tests: pytest tests/ -v
  4. Submit a pull request

License

MIT License — see LICENSE for details.


Author

Jean Marie Daniel Vianney Guedegbe
Full Stack Python & JavaScript Developer · 6+ years experience
📧 danieldanielguedegbe10027@gmail.com
🌐 me.myoctogone.com
🐙 github.com/daniel10027
💼 linkedin.com/in/daniel-guedegbe #\x00 \x00w\x00a\x00v\x00e\x00-\x00b\x00u\x00s\x00i\x00n\x00e\x00s\x00s\x00-\x00a\x00p\x00i\x00 \x00 \x00#\x00 \x00w\x00a\x00v\x00e\x00-\x00b\x00u\x00s\x00i\x00n\x00e\x00s\x00s\x00-\x00a\x00p\x00i\x00 \x00 \x00

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

wave_business_api-1.0.0.tar.gz (30.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

wave_business_api-1.0.0-py3-none-any.whl (24.4 kB view details)

Uploaded Python 3

File details

Details for the file wave_business_api-1.0.0.tar.gz.

File metadata

  • Download URL: wave_business_api-1.0.0.tar.gz
  • Upload date:
  • Size: 30.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.12

File hashes

Hashes for wave_business_api-1.0.0.tar.gz
Algorithm Hash digest
SHA256 8915cfb36e35b90f5852ccce626ec9c3063b320a3f810b4342c437224c614ab9
MD5 253171d2b2bc945457bdc465150799ab
BLAKE2b-256 83521fdf801baad9574391f6e11fbc98504c12fdc2e7dc459cbae528848fbe0a

See more details on using hashes here.

File details

Details for the file wave_business_api-1.0.0-py3-none-any.whl.

File metadata

File hashes

Hashes for wave_business_api-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 d28e31e805fff2d2cdb49aa366d4abbe731c23bb7aff1730ba2759b7170daca2
MD5 ff7d2e7c3ce965c0a7c05e0683913953
BLAKE2b-256 f2184a074e05a77f2387c7cfd2c53cbec393de9a49172d09702b4558eebca70c

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page