wave-business-api
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
- Quick Start
- Authentication
- Balance & Reconciliation API
- Checkout API
- Payout API
- Aggregated Merchants API
- Webhooks
- Error Handling
- IP Whitelisting
- Testing
- Contributing
- License
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_urldirectly 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!
- Fork the repository: github.com/daniel10027/wave-business-api
- Create your feature branch:
git checkout -b feature/my-feature - Run tests:
pytest tests/ -v - 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
Release files for wave-business-api 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| wave_business_api-1.0.0.tar.gz | 30.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| wave_business_api-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 54.9 kB
Release files / wave_business_api-1.0.0.tar.gz
| Download URL | wave_business_api-1.0.0.tar.gz |
|---|---|
| Size | 30.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8915cfb36e35b90f5852ccce626ec9c3063b320a3f810b4342c437224c614ab9
|
|
BLAKE2b-256 checksum How to use checksums |
83521fdf801baad9574391f6e11fbc98504c12fdc2e7dc459cbae528848fbe0a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.13.12
|
Release files / wave_business_api-1.0.0-py3-none-any.whl
| Download URL | wave_business_api-1.0.0-py3-none-any.whl |
|---|---|
| Size | 24.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d28e31e805fff2d2cdb49aa366d4abbe731c23bb7aff1730ba2759b7170daca2
|
|
BLAKE2b-256 checksum How to use checksums |
f2184a074e05a77f2387c7cfd2c53cbec393de9a49172d09702b4558eebca70c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.13.12
|