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:
| Country | Code | Currency | Operators |
|---|---|---|---|
| Ivory Coast | CI | XOF | Orange Money, MTN, Wave, Moov |
| Senegal | SN | XOF | Orange Money, Wave, Free Money |
| Benin | BJ | XOF | MTN, Moov |
| Burkina Faso | BF | XOF | Orange Money, Moov |
| Cameroon | CM | XAF | Orange Money, MTN |
| Mali | ML | XOF | Orange Money, Moov |
| Togo | TG | XOF | Moov, T-Money |
| Guinea | GN | GNF | Orange 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| Input | Output | Notes |
|---|---|---|
+2250709757296 | 2250709757296 | Remove + prefix |
0709757296 | 225709757296 | Prepend country code |
2250709757296 | 2250709757296 | Already 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 Method | Provider | Priority | Method Code |
|---|---|---|---|
| PAYIN_ORANGE_CI | paiementpro | 1 | OMCIV2 |
| PAYIN_ORANGE_CI | pawapay | 2 | ORANGE_CIV |
| PAYIN_ORANGE_CI | hub2 | 3 | ORANGE_MONEY |
| PAYIN_WAVE_CI | paiementpro | 1 | wave_ci |
| PAYIN_WAVE_CI | hub2 | 2 | WAVE |
| PAYIN_MTN_CI | pawapay | 1 | MTN_CIV |
| PAYIN_MTN_CI | hub2 | 2 | MTN_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 developerThe 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 URLThe 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:
- 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_flowtype so the checkout UI knows whether to show a "waiting" indicator or redirect the customer.
- 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).
- 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.