Back to 0fee
0fee

Hub2: Covering Francophone Africa

How 0fee.dev integrates Hub2 for mobile money payments across 8 Francophone African countries with Orange, MTN, Wave, and Moov.

Thales & Claude | March 25, 2026 9 min 0fee
hub2mobile-moneyfrancophone-africaorange-moneymtn

Hub2 is a payment aggregator built specifically for Francophone Africa. It covers eight countries in the UEMOA and CEMAC zones -- the regions where the CFA Franc is the primary currency and mobile money is the dominant payment method. For 0fee.dev, Hub2 represents the third-priority provider for West African mobile money, sitting behind PaiementPro and PawaPay in the routing table but covering a critical set of payment methods that other providers handle differently.

This article covers Hub2's country coverage, payment method support, the USSD push flow for Orange and MTN, the Wave redirect flow, and how we mapped Hub2's API to our unified provider interface.

Hub2's Coverage Map

Hub2 covers eight countries, all in the CFA Franc zone:

CountryCodeCurrencyOperators
Ivory CoastCIXOFOrange Money, MTN, Wave, Moov
SenegalSNXOFOrange Money, Wave, Free Money
BeninBJXOFMTN, Moov
Burkina FasoBFXOFOrange Money, Moov
CameroonCMXAFOrange Money, MTN
MaliMLXOFOrange Money, Moov
TogoTGXOFMoov, T-Money
GuineaGNGNFOrange Money, MTN

This coverage is significant because these eight countries represent over 150 million people, the majority of whom use mobile money for daily transactions. In Ivory Coast alone, Orange Money processes more transactions than all banks combined.

Payment Methods

Hub2 supports four major mobile money operators, each with different technical flows:

Orange Money (USSD Push)

Orange Money payments use a USSD push model. When a customer pays, Hub2 sends a payment request to Orange's backend, which triggers a USSD prompt on the customer's phone. The customer sees a menu asking them to confirm the payment and enter their PIN.

pythonasync def initiate_orange_payment(self, data: dict) -> dict:
    """Initiate Orange Money payment via Hub2."""
    payload = {
        "amount": data["amount"],
        "currency": data["currency"],
        "customerMsisdn": data["phone"],
        "merchantId": self.merchant_id,
        "reference": data["reference"],
        "callbackUrl": self.callback_url,
        "paymentMethod": "ORANGE_MONEY",
    }

    async with httpx.AsyncClient() as client:
        response = await client.post(
            f"{self.base_url}/v1/payments",
            json=payload,
            headers=self._get_headers(),
        )

    result = response.json()

    return {
        "status": "pending",
        "provider_reference": result["transactionId"],
        "payment_flow": {
            "type": "ussd_push",
            "message": "Check your phone for the Orange Money prompt",
        },
    }

The flow is entirely server-initiated. The customer does not need to navigate to a website or enter a code -- they simply confirm the USSD prompt on their phone. This is the preferred flow for Orange Money because it requires minimal customer effort.

MTN Mobile Money (USSD Push)

MTN follows the same USSD push pattern as Orange, with minor API differences:

pythonasync def initiate_mtn_payment(self, data: dict) -> dict:
    """Initiate MTN MoMo payment via Hub2."""
    payload = {
        "amount": data["amount"],
        "currency": data["currency"],
        "customerMsisdn": data["phone"],
        "merchantId": self.merchant_id,
        "reference": data["reference"],
        "callbackUrl": self.callback_url,
        "paymentMethod": "MTN_MOMO",
    }

    async with httpx.AsyncClient() as client:
        response = await client.post(
            f"{self.base_url}/v1/payments",
            json=payload,
            headers=self._get_headers(),
        )

    result = response.json()
    return {
        "status": "pending",
        "provider_reference": result["transactionId"],
        "payment_flow": {
            "type": "ussd_push",
            "message": "Check your phone for the MTN MoMo prompt",
        },
    }

Wave (Redirect)

Wave is different. Unlike Orange and MTN which use USSD push, Wave uses a redirect flow. The customer must open the Wave app or navigate to a Wave URL to complete the payment:

pythonasync def initiate_wave_payment(self, data: dict) -> dict:
    """Initiate Wave payment via Hub2."""
    payload = {
        "amount": data["amount"],
        "currency": data["currency"],
        "customerMsisdn": data["phone"],
        "merchantId": self.merchant_id,
        "reference": data["reference"],
        "callbackUrl": self.callback_url,
        "paymentMethod": "WAVE",
        "successUrl": self._get_callback_url(data["transaction_id"]),
        "errorUrl": self._get_callback_url(data["transaction_id"]),
    }

    async with httpx.AsyncClient() as client:
        response = await client.post(
            f"{self.base_url}/v1/payments",
            json=payload,
            headers=self._get_headers(),
        )

    result = response.json()
    wave_url = result.get("redirectUrl") or result.get("wave_launch_url")

    return {
        "status": "pending",
        "provider_reference": result["transactionId"],
        "redirect_url": wave_url,
        "payment_flow": {"type": "redirect"},
    }

Wave's redirect approach means we need to handle both success and error URLs through our middleman callback pattern. The customer opens the Wave app, confirms the payment, and is redirected back to 0fee for verification.

Moov Money (USSD Push)

Moov follows the same USSD push pattern as Orange and MTN:

pythonasync def initiate_moov_payment(self, data: dict) -> dict:
    """Initiate Moov Money payment via Hub2."""
    payload = {
        "amount": data["amount"],
        "currency": data["currency"],
        "customerMsisdn": data["phone"],
        "merchantId": self.merchant_id,
        "reference": data["reference"],
        "callbackUrl": self.callback_url,
        "paymentMethod": "MOOV_MONEY",
    }

    # ... same HTTP call pattern
    return {
        "status": "pending",
        "provider_reference": result["transactionId"],
        "payment_flow": {"type": "ussd_push"},
    }

The Hub2 Provider Adapter

All four payment methods are handled by a single provider class that routes to the appropriate method:

pythonclass Hub2Provider(BasePayinProvider):
    """Hub2 provider for Francophone Africa mobile money."""

    PROVIDER_ID = "hub2"
    SUPPORTED_COUNTRIES = ["CI", "SN", "BJ", "BF", "CM", "ML", "TG", "GN"]
    SUPPORTED_METHODS = [
        "PAYIN_ORANGE_CI", "PAYIN_ORANGE_SN", "PAYIN_ORANGE_BF",
        "PAYIN_ORANGE_CM", "PAYIN_ORANGE_ML", "PAYIN_ORANGE_GN",
        "PAYIN_MTN_CI", "PAYIN_MTN_BJ", "PAYIN_MTN_CM", "PAYIN_MTN_GN",
        "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.base_url = (
            "https://api.hub2.io"
            if environment == "live"
            else "https://sandbox.hub2.io"
        )

    def _get_headers(self) -> dict:
        return {
            "Authorization": f"Bearer {self.api_key}",
            "Content-Type": "application/json",
        }

    async def initiate_payment(self, data: dict) -> dict:
        """Route to the appropriate payment method handler."""
        method = data.get("payment_method", "")

        if "ORANGE" in method:
            hub2_method = "ORANGE_MONEY"
        elif "MTN" in method:
            hub2_method = "MTN_MOMO"
        elif "WAVE" in method:
            hub2_method = "WAVE"
        elif "MOOV" in method:
            hub2_method = "MOOV_MONEY"
        else:
            return {"status": "failed", "error": f"Unsupported method: {method}"}

        # Build payload with Hub2-specific method name
        payload = {
            "amount": data["amount"],
            "currency": data["currency"],
            "customerMsisdn": self._format_phone(data["phone"]),
            "merchantId": self.merchant_id,
            "reference": data["reference"],
            "callbackUrl": self._get_webhook_url(),
            "paymentMethod": hub2_method,
        }

        # Wave needs redirect URLs
        if hub2_method == "WAVE":
            callback = self._get_callback_url(data["transaction_id"])
            payload["successUrl"] = callback
            payload["errorUrl"] = callback

        async with httpx.AsyncClient() as client:
            response = await client.post(
                f"{self.base_url}/v1/payments",
                json=payload,
                headers=self._get_headers(),
            )

        result = response.json()
        flow_type = "redirect" if hub2_method == "WAVE" else "ussd_push"

        return {
            "status": "pending",
            "provider_reference": result.get("transactionId"),
            "redirect_url": result.get("redirectUrl") if flow_type == "redirect" else None,
            "payment_flow": {"type": flow_type},
        }

Phone Number Formatting

Hub2 expects phone numbers in international format without the plus sign. West African phone numbers have specific formats:

pythondef _format_phone(self, phone: str) -> str:
    """Format phone number for Hub2 API.

    Hub2 expects: 2250700000000 (no +)
    Input may be: +2250700000000 or 0700000000
    """
    phone = phone.strip()

    # Remove leading +
    if phone.startswith("+"):
        phone = phone[1:]

    # Handle local format (starts with 0)
    if phone.startswith("0") and len(phone) <= 10:
        # Assume Ivory Coast if no country code
        phone = "225" + phone[1:]

    return phone
InputOutputNotes
+22507097572962250709757296Remove + prefix
0709757296225709757296Prepend country code
22507097572962250709757296Already correct

Webhook Handling

Hub2 sends webhooks when payment status changes. The webhook payload includes the transaction reference and the new status:

pythonasync def handle_hub2_webhook(request: Request) -> dict:
    """Handle Hub2 payment status webhook."""
    body = await request.json()

    transaction_id = body.get("reference")
    status = body.get("status")

    status_mapping = {
        "SUCCESS": "completed",
        "SUCCESSFUL": "completed",
        "FAILED": "failed",
        "PENDING": "pending",
        "EXPIRED": "failed",
        "CANCELLED": "failed",
    }

    mapped_status = status_mapping.get(status, "pending")

    if mapped_status == "completed":
        await update_transaction_status(transaction_id, "completed")
        await update_invoice_status(transaction_id, "paid")
        await fire_developer_webhook(transaction_id, "payment.completed")
    elif mapped_status == "failed":
        await update_transaction_status(transaction_id, "failed")
        await fire_developer_webhook(transaction_id, "payment.failed")

    return {"status": "ok"}

Status Checking

For cases where the webhook does not arrive (network issues, timeout), we can poll Hub2's status endpoint:

pythonasync def check_payment_status(self, provider_reference: str) -> dict:
    """Check payment status via Hub2 API."""
    async with httpx.AsyncClient() as client:
        response = await client.get(
            f"{self.base_url}/v1/payments/{provider_reference}",
            headers=self._get_headers(),
        )

    result = response.json()
    status_mapping = {
        "SUCCESS": "completed",
        "SUCCESSFUL": "completed",
        "FAILED": "failed",
        "PENDING": "pending",
    }

    return {
        "status": status_mapping.get(result["status"], "pending"),
        "provider_reference": provider_reference,
        "raw_response": result,
    }

Hub2 in the Routing Table

Hub2 sits at priority 3 for most Francophone African payment methods, behind PaiementPro (priority 1) and PawaPay (priority 2):

Payment MethodProviderPriorityMethod Code
PAYIN_ORANGE_CIpaiementpro1OMCIV2
PAYIN_ORANGE_CIpawapay2ORANGE_CIV
PAYIN_ORANGE_CIhub23ORANGE_MONEY
PAYIN_WAVE_CIpaiementpro1wave_ci
PAYIN_WAVE_CIhub22WAVE
PAYIN_MTN_CIpawapay1MTN_CIV
PAYIN_MTN_CIhub22MTN_MOMO

This priority ordering means Hub2 serves as a fallback when the primary providers are unavailable or return errors. The routing engine tries each provider in priority order until one succeeds.

USSD Push vs. Redirect: User Experience

The two flow types have fundamentally different user experiences:

USSD Push (Orange, MTN, Moov)

1. Customer enters phone number on 0fee checkout
2. 0fee sends payment request to Hub2
3. Hub2 sends USSD push to customer's phone
4. Customer sees USSD prompt: "Pay 5,000 XOF to Merchant? Enter PIN:"
5. Customer enters PIN
6. Payment confirmed
7. Hub2 sends webhook to 0fee
8. 0fee fires webhook to developer

The customer never leaves the checkout page. They simply pick up their phone, confirm the payment, and the page updates.

Redirect (Wave)

1. Customer enters phone number on 0fee checkout
2. 0fee sends payment request to Hub2
3. Hub2 returns Wave redirect URL
4. Customer is redirected to Wave app/website
5. Customer confirms payment in Wave
6. Wave redirects customer back to 0fee callback
7. 0fee verifies payment status
8. 0fee redirects to developer's success URL

The redirect flow requires the middleman callback pattern to verify the payment. This is more complex but necessary because Wave does not support USSD push.

What We Learned

Hub2 taught us three things about building for Francophone Africa:

  1. One provider, multiple flows. Hub2 supports both USSD push (Orange, MTN, Moov) and redirect (Wave). The adapter must handle both transparently, returning the correct payment_flow type so the checkout UI knows whether to show a "waiting" indicator or redirect the customer.
  1. CFA Franc currencies are zero-decimal by nature. When a customer pays 5,000 XOF, the amount is 5000 -- there are no centimes. This aligns with Hub2's API, which accepts plain integer amounts, but requires careful handling when the same payment could be routed through Stripe (which needs special zero-decimal handling).
  1. Fallback providers are essential in Africa. Mobile money operators experience periodic outages. Having Hub2 as a third-priority fallback behind PaiementPro and PawaPay means that even if two providers are down, payments can still be processed through the surviving one.

Hub2 was implemented in the very first session of 0fee's development. Its architecture -- a single class handling multiple operators, multiple countries, and multiple flow types -- became the template for every mobile money provider adapter that followed.


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