While PawaPay covers the breadth of Africa and Stripe handles global cards, the best payment experiences in West Africa often come from local providers. BUI and PaiementPro are two aggregators built specifically for the UEMOA zone -- the eight West African countries sharing the CFA Franc. They understand the nuances of Orange Money OTP flows, Wave's redirect model, and the specific dial codes of each operator in each country.
Both providers were implemented in Session 002, the second session of 0fee's development. This article covers BUI's OTP validation for Orange CI, Wave redirect with URL polling, and HMAC signature verification; PaiementPro's JavaScript SDK integration, redirect flow, and status API polling.
BUI: The West African Specialist
BUI covers seven West African countries and supports Orange Money, MTN, Wave, and Moov. What makes BUI distinctive is its support for OTP-based Orange Money payments in Ivory Coast -- a flow where the customer receives a one-time password via SMS and enters it on the checkout page.
BUI Provider Architecture
pythonclass BUIProvider(BasePayinProvider):
"""BUI provider for West African mobile money."""
PROVIDER_ID = "bui"
SUPPORTED_COUNTRIES = ["CI", "BJ", "BF", "CM", "ML", "SN", "TG"]
SUPPORTED_METHODS = [
"PAYIN_ORANGE_CI", "PAYIN_ORANGE_SN", "PAYIN_ORANGE_BF",
"PAYIN_ORANGE_CM", "PAYIN_ORANGE_ML",
"PAYIN_MTN_CI", "PAYIN_MTN_BJ", "PAYIN_MTN_CM",
"PAYIN_WAVE_CI", "PAYIN_WAVE_SN",
"PAYIN_MOOV_CI", "PAYIN_MOOV_BJ", "PAYIN_MOOV_BF",
"PAYIN_MOOV_ML", "PAYIN_MOOV_TG",
]
def __init__(self, credentials: dict, environment: str = "sandbox"):
self.api_key = credentials["api_key"]
self.merchant_id = credentials["merchant_id"]
self.secret_key = credentials["secret_key"]
self.base_url = (
"https://api.bfrancepay.com"
if environment == "live"
else "https://sandbox-api.bfrancepay.com"
)OTP Validation for Orange CI
Orange Money in Ivory Coast uses a unique OTP flow. Instead of a USSD push (where the operator sends a prompt to the customer's phone), BUI's Orange CI integration requires the customer to enter an OTP code on the checkout page:
pythonasync def initiate_orange_ci_payment(self, data: dict) -> dict:
"""Initiate Orange Money CI payment with OTP requirement."""
payload = {
"amount": data["amount"],
"currency": "XOF",
"phone": self._format_phone(data["phone"]),
"merchant_id": self.merchant_id,
"reference": data["reference"],
"operator": "orange_ci",
}
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.base_url}/v1/payments/initiate",
json=payload,
headers=self._get_headers(),
)
result = response.json()
return {
"status": "pending_otp",
"provider_reference": result["transaction_id"],
"payment_flow": {
"type": "otp",
"message": "Enter the OTP code sent to your phone",
"otp_length": 6,
},
}After the customer receives the OTP and enters it on the checkout page, a second API call validates the payment:
pythonasync def validate_otp(self, provider_reference: str, otp_code: str) -> dict:
"""Validate OTP for Orange CI payment."""
payload = {
"transaction_id": provider_reference,
"otp": otp_code,
}
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.base_url}/v1/payments/validate",
json=payload,
headers=self._get_headers(),
)
result = response.json()
if result.get("status") == "success":
return {"status": "completed", "provider_reference": provider_reference}
elif result.get("status") == "pending":
return {"status": "pending", "provider_reference": provider_reference}
else:
return {
"status": "failed",
"error": result.get("message", "OTP validation failed"),
}The OTP flow adds complexity to the checkout experience but is required by Orange for certain transaction types in Ivory Coast. Our checkout widget and hosted checkout pages handle this transparently -- when the payment flow type is otp, they display an OTP input field and call the validation endpoint.
Wave Redirect with URL Polling
Wave payments through BUI use a redirect flow similar to PayPal:
pythonasync def initiate_wave_payment(self, data: dict) -> dict:
"""Initiate Wave payment via BUI with redirect."""
callback_url = f"{BASE_URL}/v1/payments/{data['transaction_id']}/return"
payload = {
"amount": data["amount"],
"currency": "XOF",
"phone": self._format_phone(data["phone"]),
"merchant_id": self.merchant_id,
"reference": data["reference"],
"operator": "wave",
"successUrl": callback_url,
"errorUrl": callback_url + "?error=true",
}
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.base_url}/v1/payments/initiate",
json=payload,
headers=self._get_headers(),
)
result = response.json()
wave_url = result.get("wave_launch_url") or result.get("redirect_url")
return {
"status": "pending",
"provider_reference": result["transaction_id"],
"redirect_url": wave_url,
"payment_flow": {"type": "redirect"},
}After the redirect, we verify the payment through our middleman callback:
pythonasync def verify_bui_payment(
transaction_id: str, provider_reference: str
) -> bool:
"""Verify BUI payment status via API."""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{BUI_API_URL}/v1/payments/{provider_reference}/status",
headers=_get_bui_headers(transaction_id),
)
result = response.json()
if result.get("status") in ("success", "completed"):
await update_transaction_status(transaction_id, "completed")
await update_invoice_status(transaction_id, "paid")
return True
return FalseHMAC Signature Verification
BUI signs its webhook payloads with HMAC-SHA256. We verify every incoming webhook before processing:
pythonimport hmac
import hashlib
def verify_bui_signature(
payload: bytes, signature: str, secret_key: str
) -> bool:
"""Verify BUI webhook HMAC-SHA256 signature."""
expected = hmac.new(
secret_key.encode(),
payload,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)
async def handle_bui_webhook(request: Request) -> dict: """Handle BUI webhook with signature verification.""" body = await request.body() signature = request.headers.get("X-BUI-Signature", "") BLANK if not verify_bui_signature(body, signature, BUI_SECRET_KEY): raise HTTPException(status_code=401, detail="Invalid signature") BLANK data = json.loads(body) transaction_id = data.get("reference") status = data.get("status") BLANK if status in ("success", "completed"): await update_transaction_status(transaction_id, "completed") await fire_developer_webhook(transaction_id, "payment.completed") elif status in ("failed", "error"): await update_transaction_status(transaction_id, "failed") await fire_developer_webhook(transaction_id, "payment.failed") BLANK return {"status": "ok"} ```
The hmac.compare_digest() function is critical -- it performs a constant-time comparison that prevents timing attacks. A naive == comparison leaks information about how many bytes of the signature match, which an attacker could exploit.
PaiementPro: The Francophone Africa Gateway
PaiementPro is the first-priority provider for most West African mobile money methods in 0fee's routing table. It covers Ivory Coast, Benin, Burkina Faso, Guinea-Bissau, Mali, Niger, and Senegal, with support for Orange Money, MTN, Moov, Wave, Free, Airtel, and card payments.
PaiementPro Provider Architecture
pythonclass PaiementProProvider(BasePayinProvider):
"""PaiementPro provider for Francophone Africa."""
PROVIDER_ID = "paiementpro"
SUPPORTED_COUNTRIES = ["CI", "BJ", "BF", "GW", "ML", "NE", "SN"]
SUPPORTED_METHODS = [
"PAYIN_ORANGE_CI", "PAYIN_ORANGE_SN", "PAYIN_ORANGE_BF",
"PAYIN_ORANGE_ML",
"PAYIN_MTN_CI", "PAYIN_MTN_BJ",
"PAYIN_WAVE_CI", "PAYIN_WAVE_SN",
"PAYIN_MOOV_CI", "PAYIN_MOOV_BJ", "PAYIN_MOOV_BF",
"PAYIN_MOOV_ML",
"PAYIN_FREE_SN",
"PAYIN_CARD",
]
# Channel mapping from 0fee method to PaiementPro channel codes
CHANNEL_MAP = {
"PAYIN_ORANGE_CI": "OMCIV2",
"PAYIN_MTN_CI": "MTNCI",
"PAYIN_WAVE_CI": "WAVECI",
"PAYIN_MOOV_CI": "MOOVCI",
"PAYIN_ORANGE_SN": "OMSN",
"PAYIN_WAVE_SN": "WAVESN",
"PAYIN_ORANGE_BF": "OMBF",
"PAYIN_MOOV_BF": "MOOVBF",
"PAYIN_MTN_BJ": "MTNBJ",
"PAYIN_MOOV_BJ": "MOOVBJ",
"PAYIN_ORANGE_ML": "OMML",
"PAYIN_MOOV_ML": "MOOVML",
"PAYIN_FREE_SN": "FREESN",
}JavaScript SDK Integration
PaiementPro's payment flow uses a JavaScript SDK that creates a payment form on the merchant's page. For 0fee's hosted checkout, we integrate this SDK in the Jinja2 template:
html<!-- PaiementPro SDK integration in checkout template -->
<script src="https://cdn.paiementpro.net/js/paiementpro.js"></script>
<script>
const pp = new PaiementPro({
merchantId: "{{ merchant_id }}",
amount: {{ amount }},
currency: "{{ currency }}",
channel: "{{ channel }}",
reference: "{{ reference }}",
customerPhone: "{{ phone }}",
returnURL: "{{ return_url }}",
notifyURL: "{{ webhook_url }}",
});
pp.on("success", function(data) {
window.location.href = "{{ return_url }}?status=success&ref=" + data.reference;
});
pp.on("error", function(data) {
window.location.href = "{{ return_url }}?status=error&ref=" + data.reference;
});
pp.submit();
</script>The Redirect Flow
For API-initiated payments, PaiementPro returns a redirect URL or processes the payment directly depending on the channel:
pythonasync def initiate_payment(self, data: dict) -> dict:
"""Initiate payment via PaiementPro."""
channel = self._get_channel(data)
callback_url = f"{BASE_URL}/v1/payments/{data['transaction_id']}/return"
payload = {
"merchantId": self.merchant_id,
"amount": data["amount"],
"currency": data["currency"],
"channel": channel,
"phone": self._format_phone(data["phone"]),
"reference": data["reference"],
"returnURL": callback_url,
"notifyURL": self._get_webhook_url(),
"customerEmail": data.get("email", ""),
"customerName": data.get("name", ""),
}
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.base_url}/v1/payments/init",
json=payload,
headers=self._get_headers(),
)
result = response.json()
if result.get("redirect_url"):
return {
"status": "pending",
"provider_reference": result["invoice_number"],
"redirect_url": result["redirect_url"],
"payment_flow": {"type": "redirect"},
}
else:
return {
"status": "pending",
"provider_reference": result["invoice_number"],
"payment_flow": {"type": "ussd_push"},
}
def _get_channel(self, data: dict) -> str:
"""Get PaiementPro channel code from payment method."""
method = data.get("payment_method", "")
# Try direct mapping first
if method in self.CHANNEL_MAP:
return self.CHANNEL_MAP[method]
# Try provider_method_code from routing table
if data.get("provider_method_code"):
return data["provider_method_code"]
# Fallback: extract from payment method
parts = method.replace("PAYIN_", "").lower()
return partsStatus API Polling
PaiementPro's status endpoint is critical for the middleman callback pattern. When the customer is redirected back to 0fee, we verify the payment by polling PaiementPro's status API:
pythonasync def verify_paiementpro_payment(
transaction_id: str, invoice_number: str
) -> bool:
"""Verify PaiementPro payment with retry logic."""
# Initial delay -- PaiementPro needs time to process
await asyncio.sleep(3)
for attempt in range(3):
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://api.paiementpro.net/status/{invoice_number}",
headers={"Authorization": f"Bearer {PAIEMENTPRO_API_KEY}"},
)
result = response.json()
if result.get("responsecode") == "0":
# Payment successful
await update_transaction_status(transaction_id, "completed")
await update_invoice_status(transaction_id, "paid")
return True
elif result.get("responsecode") in ("-1", "1"):
# Payment failed
await update_transaction_status(transaction_id, "failed")
return False
# Still pending -- wait and retry
await asyncio.sleep(2 ** attempt) # 1s, 2s, 4s
# After 3 retries, leave as pending
return FalseThe 3-second initial delay is deliberate. PaiementPro's redirect happens before their backend has fully processed the payment. Without the delay, the first status check almost always returns "pending." The exponential backoff (1s, 2s, 4s) handles cases where processing takes longer than expected.
BUI vs. PaiementPro: A Comparison
| Feature | BUI | PaiementPro |
|---|---|---|
| Countries | CI, BJ, BF, CM, ML, SN, TG | CI, BJ, BF, GW, ML, NE, SN |
| Orange CI flow | OTP validation | USSD push or redirect |
| Wave flow | Redirect | Redirect |
| Card payments | No | Yes |
| SDK integration | REST API only | JavaScript SDK + REST |
| Webhook signing | HMAC-SHA256 | Custom headers |
| Status endpoint | REST polling | REST polling |
| Priority in 0fee | 4 (fallback) | 1 (primary) |
PaiementPro ranks higher in the routing table because it supports card payments alongside mobile money and has proven more reliable in production testing. BUI serves as a fourth-priority fallback, particularly valuable when PaiementPro, PawaPay, and Hub2 all experience issues.
Channel Code Reference
PaiementPro uses specific channel codes for each operator-country combination:
| 0fee Method | PaiementPro Channel | Description |
|---|---|---|
| PAYIN_ORANGE_CI | OMCIV2 | Orange Money Ivory Coast v2 |
| PAYIN_MTN_CI | MTNCI | MTN Mobile Money Ivory Coast |
| PAYIN_WAVE_CI | WAVECI | Wave Ivory Coast |
| PAYIN_MOOV_CI | MOOVCI | Moov Money Ivory Coast |
| PAYIN_ORANGE_SN | OMSN | Orange Money Senegal |
| PAYIN_WAVE_SN | WAVESN | Wave Senegal |
| PAYIN_ORANGE_BF | OMBF | Orange Money Burkina Faso |
| PAYIN_MOOV_BF | MOOVBF | Moov Money Burkina Faso |
| PAYIN_MTN_BJ | MTNBJ | MTN Mobile Money Benin |
| PAYIN_MOOV_BJ | MOOVBJ | Moov Money Benin |
| PAYIN_ORANGE_ML | OMML | Orange Money Mali |
| PAYIN_MOOV_ML | MOOVML | Moov Money Mali |
| PAYIN_FREE_SN | FREESN | Free Money Senegal |
What We Learned
Building BUI and PaiementPro adapters taught us three things:
- Local providers understand local flows. BUI's OTP validation for Orange CI is not a limitation -- it reflects how Orange Money works in Ivory Coast for certain transaction types. Abstracting this away would break the payment flow.
- Status verification requires patience. PaiementPro's 3-second delay and 3-retry pattern exists because payment processing is not instant. The redirect arrives before the payment is confirmed. Any verification system must account for this timing gap.
- Multiple providers for the same method is not redundancy -- it is resilience. Having PaiementPro, PawaPay, Hub2, and BUI all capable of processing Orange Money in Ivory Coast means that operator outages do not block payments. The routing engine tries each in priority order until one succeeds.
Both providers were implemented in Session 002 -- the same session that built the SolidJS dashboard, the checkout widget, the TypeScript SDK, and the Python SDK. The fact that two complete payment provider adapters were part of a 60-minute session speaks to the power of having a well-defined adapter interface.
This article is part of the "How We Built 0fee.dev" series. 0fee.dev is a payment orchestrator covering 53+ providers across 200+ countries, built by Juste A. GNIMAVO and Claude from Abidjan with zero human engineers. Follow the series for the complete build story.