Back to 0fee
0fee

BUI and PaiementPro: Local Champions for West Africa

How 0fee.dev integrates BUI and PaiementPro for West African mobile money with OTP validation, Wave redirect, and SDK integration.

Thales & Claude | March 25, 2026 10 min 0fee
buipaiementprowest-africaotp-validationmobile-money

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 False

HMAC 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 parts

Status 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 False

The 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

FeatureBUIPaiementPro
CountriesCI, BJ, BF, CM, ML, SN, TGCI, BJ, BF, GW, ML, NE, SN
Orange CI flowOTP validationUSSD push or redirect
Wave flowRedirectRedirect
Card paymentsNoYes
SDK integrationREST API onlyJavaScript SDK + REST
Webhook signingHMAC-SHA256Custom headers
Status endpointREST pollingREST polling
Priority in 0fee4 (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 MethodPaiementPro ChannelDescription
PAYIN_ORANGE_CIOMCIV2Orange Money Ivory Coast v2
PAYIN_MTN_CIMTNCIMTN Mobile Money Ivory Coast
PAYIN_WAVE_CIWAVECIWave Ivory Coast
PAYIN_MOOV_CIMOOVCIMoov Money Ivory Coast
PAYIN_ORANGE_SNOMSNOrange Money Senegal
PAYIN_WAVE_SNWAVESNWave Senegal
PAYIN_ORANGE_BFOMBFOrange Money Burkina Faso
PAYIN_MOOV_BFMOOVBFMoov Money Burkina Faso
PAYIN_MTN_BJMTNBJMTN Mobile Money Benin
PAYIN_MOOV_BJMOOVBJMoov Money Benin
PAYIN_ORANGE_MLOMMLOrange Money Mali
PAYIN_MOOV_MLMOOVMLMoov Money Mali
PAYIN_FREE_SNFREESNFree Money Senegal

What We Learned

Building BUI and PaiementPro adapters taught us three things:

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

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles