Back to 0fee
0fee

PawaPay: Pan-African Mobile Money for 21+ Countries

How 0fee.dev integrates PawaPay's hosted payment page for mobile money across 21+ African countries with correspondent codes.

Thales & Claude | March 25, 2026 9 min 0fee
pawapaymobile-moneyafricacorrespondent-codespayment-page

PawaPay is the broadest mobile money aggregator in 0fee's provider ecosystem. While Hub2 covers 8 Francophone countries and PaiementPro covers 7 West African nations, PawaPay spans 21+ countries across West, East, Central, and Southern Africa. It supports every major mobile money operator on the continent -- Orange, MTN, Airtel, Vodacom, M-Pesa, Safaricom, and dozens of regional operators.

For 0fee.dev, PawaPay serves a dual role: it is the second-priority provider for Francophone African payments and the primary provider for East and Southern African markets where Hub2 and PaiementPro have no coverage.

This article covers PawaPay's hosted payment page integration, country and correspondent code mapping, status polling, and how we handle the complexity of 21+ countries with different operators.

Two Integration Modes

PawaPay offers two integration modes. We built adapters for both:

ModeClassUse Case
Direct APIPawapayProviderSend USSD push directly to customer's phone
Hosted Payment PagePawapayPageProviderRedirect customer to PawaPay's checkout

The hosted payment page became our preferred integration because it handles country selection, operator detection, and phone number validation -- reducing the complexity we need to manage.

Direct API (Original Implementation)

The direct API integration was built in Session 001. It sends a payment request directly to the customer's phone via USSD push:

pythonclass PawapayProvider(BasePayinProvider):
    """PawaPay direct API for mobile money payments."""

    PROVIDER_ID = "pawapay"

    async def initiate_payment(self, data: dict) -> dict:
        correspondent = self._get_correspondent(
            data["country"], data["payment_method"]
        )

        payload = {
            "depositId": data["transaction_id"],
            "amount": str(data["amount"]),
            "currency": data["currency"],
            "correspondent": correspondent,
            "payer": {
                "type": "MSISDN",
                "address": {
                    "value": data["phone"],
                },
            },
            "customerTimestamp": datetime.utcnow().isoformat() + "Z",
            "statementDescription": f"Payment {data['reference']}",
        }

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

        result = response.json()
        return {
            "status": "pending",
            "provider_reference": result.get("depositId"),
            "payment_flow": {"type": "ussd_push"},
        }

Hosted Payment Page (Preferred)

The hosted payment page was added in Session 008. It redirects the customer to PawaPay's widget, where PawaPay handles the entire payment collection flow:

pythonclass PawapayPageProvider(BasePayinProvider):
    """PawaPay hosted payment page for mobile money."""

    PROVIDER_ID = "pawapay_page"

    async def initiate_payment(self, data: dict) -> dict:
        callback_url = f"{BASE_URL}/v1/payments/{data['transaction_id']}/return"

        payload = {
            "depositId": data["transaction_id"],
            "amount": str(data["amount"]),
            "currency": data["currency"],
            "returnUrl": callback_url,
            "cancellationUrl": callback_url + "?cancelled=true",
            "country": data["country"],
            "reason": f"Payment {data['reference']}",
            "metadata": [
                {"fieldName": "transaction_id", "fieldValue": data["transaction_id"]},
                {"fieldName": "app_id", "fieldValue": data["app_id"]},
            ],
        }

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

        result = response.json()
        return {
            "status": "pending",
            "provider_reference": result.get("depositId"),
            "redirect_url": result.get("redirectUrl"),
            "payment_flow": {"type": "redirect"},
        }

The hosted page approach has significant advantages:

  1. PawaPay detects the customer's operator from their phone number
  2. PawaPay handles USSD push, STK push, and QR code flows automatically
  3. PawaPay manages timeouts and retry prompts
  4. The checkout page is localized for each country

Correspondent Codes

PawaPay uses "correspondent codes" to identify specific mobile money operators in specific countries. Each combination of country and operator has a unique code:

pythonCORRESPONDENT_MAP = {
    # Ivory Coast
    "CI": {
        "ORANGE": "ORANGE_CIV",
        "MTN": "MTN_CIV",
        "WAVE": "WAVE_CIV",
        "MOOV": "MOOV_CIV",
    },
    # Senegal
    "SN": {
        "ORANGE": "ORANGE_SEN",
        "WAVE": "WAVE_SEN",
        "FREE": "FREE_SEN",
    },
    # Kenya
    "KE": {
        "MPESA": "MPESA_KEN",
        "AIRTEL": "AIRTEL_KEN",
    },
    # Tanzania
    "TZ": {
        "VODACOM": "VODACOM_TZN",
        "AIRTEL": "AIRTEL_TZN",
        "TIGO": "TIGO_TZN",
        "HALOTEL": "HALOTEL_TZN",
    },
    # Ghana
    "GH": {
        "MTN": "MTN_GHA",
        "VODAFONE": "VODAFONE_GHA",
        "AIRTEL_TIGO": "AIRTELTIGO_GHA",
    },
    # Uganda
    "UG": {
        "MTN": "MTN_UGA",
        "AIRTEL": "AIRTEL_UGA",
    },
    # Rwanda
    "RW": {
        "MTN": "MTN_RWA",
        "AIRTEL": "AIRTEL_RWA",
    },
    # Cameroon
    "CM": {
        "ORANGE": "ORANGE_CMR",
        "MTN": "MTN_CMR",
    },
    # Benin
    "BJ": {
        "MTN": "MTN_BEN",
        "MOOV": "MOOV_BEN",
    },
    # Burkina Faso
    "BF": {
        "ORANGE": "ORANGE_BFA",
        "MOOV": "MOOV_BFA",
    },
    # Democratic Republic of Congo
    "CD": {
        "VODACOM": "VODACOM_COD",
        "AIRTEL": "AIRTEL_COD",
        "ORANGE": "ORANGE_COD",
    },
    # Zambia
    "ZM": {
        "MTN": "MTN_ZMB",
        "AIRTEL": "AIRTEL_ZMB",
    },
    # Malawi
    "MW": {
        "AIRTEL": "AIRTEL_MWI",
        "TNM": "TNM_MWI",
    },
    # Mozambique
    "MZ": {
        "VODACOM": "VODACOM_MOZ",
    },
    # Congo-Brazzaville
    "CG": {
        "MTN": "MTN_COG",
        "AIRTEL": "AIRTEL_COG",
    },
    # Sierra Leone
    "SL": {
        "ORANGE": "ORANGE_SLE",
    },
    # Nigeria
    "NG": {
        "MTN": "MTN_NGA",
        "AIRTEL": "AIRTEL_NGA",
    },
    # Mali
    "ML": {
        "ORANGE": "ORANGE_MLI",
        "MOOV": "MOOV_MLI",
    },
    # Togo
    "TG": {
        "MOOV": "MOOV_TGO",
    },
    # Guinea
    "GN": {
        "ORANGE": "ORANGE_GIN",
        "MTN": "MTN_GIN",
    },
}

The correspondent code pattern follows a consistent format: {OPERATOR}_{ISO3166_ALPHA3}. This makes it predictable, but you cannot assume the three-letter country code -- some use the alpha-3 code (CIV for Ivory Coast) while others use variations (SEN for Senegal, not SEN_EGAL).

Mapping from 0fee's Unified Format

Our unified payment method format (e.g., PAYIN_ORANGE_CI) must be translated to PawaPay's correspondent code:

pythondef _get_correspondent(self, country: str, payment_method: str) -> str:
    """Convert 0fee payment method to PawaPay correspondent code.

    PAYIN_ORANGE_CI -> ORANGE_CIV
    PAYIN_MTN_GH -> MTN_GHA
    PAYIN_MPESA_KE -> MPESA_KEN
    """
    # Extract operator from payment method
    # PAYIN_ORANGE_CI -> ORANGE
    parts = payment_method.replace("PAYIN_", "").rsplit("_", 1)
    operator = parts[0] if len(parts) > 1 else payment_method

    country_map = self.CORRESPONDENT_MAP.get(country, {})
    correspondent = country_map.get(operator)

    if not correspondent:
        raise ValueError(
            f"No PawaPay correspondent for {operator} in {country}"
        )

    return correspondent

Country Coverage

PawaPay's 21+ country coverage makes it the widest African mobile money aggregator in our ecosystem:

RegionCountriesKey Operators
West AfricaCI, SN, GH, NG, BJ, BF, ML, TG, GN, SLOrange, MTN, Wave, Moov, Airtel
East AfricaKE, TZ, UG, RWM-Pesa, MTN, Airtel, Vodacom
Central AfricaCM, CD, CGOrange, MTN, Vodacom, Airtel
Southern AfricaZM, MW, MZMTN, Airtel, Vodacom, TNM

For East African markets (Kenya, Tanzania, Uganda, Rwanda), PawaPay is often the only provider in 0fee's routing table. Hub2 and PaiementPro do not cover these countries.

Status Polling

PawaPay processes payments asynchronously. After initiating a payment (either direct or via the hosted page), we need to check the status:

pythonasync def check_payment_status(self, deposit_id: str) -> dict:
    """Poll PawaPay for payment status."""
    async with httpx.AsyncClient() as client:
        response = await client.get(
            f"{self.base_url}/deposits/{deposit_id}",
            headers=self._get_headers(),
        )

    result = response.json()

    status_mapping = {
        "ACCEPTED": "pending",
        "SUBMITTED": "pending",
        "COMPLETED": "completed",
        "FAILED": "failed",
        "CANCELLED": "failed",
        "EXPIRED": "failed",
        "REJECTED": "failed",
    }

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

PawaPay's deposit lifecycle:

ACCEPTED -> SUBMITTED -> COMPLETED
                |
                +-> FAILED
                +-> CANCELLED
                +-> EXPIRED
                +-> REJECTED
StatusMeaningTypical Wait
ACCEPTEDPawaPay received the requestImmediate
SUBMITTEDSent to mobile money operator1-5 seconds
COMPLETEDCustomer confirmed payment10-60 seconds
FAILEDOperator rejected paymentVariable
EXPIREDCustomer did not respond5-15 minutes

The gap between SUBMITTED and COMPLETED is where the customer is interacting with their phone. For USSD push payments, this typically takes 10-30 seconds. For hosted page payments, it can take longer because the customer needs to navigate the checkout flow first.

Middleman Callback for PawaPay

For the hosted payment page flow, we apply the same middleman callback pattern used for Stripe:

pythonasync def verify_pawapay_payment(
    transaction_id: str, deposit_id: str
) -> bool:
    """Verify PawaPay payment via API before redirecting customer."""
    async with httpx.AsyncClient() as client:
        response = await client.get(
            f"{PAWAPAY_API_URL}/deposits/{deposit_id}",
            headers=_get_pawapay_headers(transaction_id),
        )

    result = response.json()

    if result.get("status") == "COMPLETED":
        await update_transaction_status(transaction_id, "completed")
        await update_invoice_status(transaction_id, "paid")
        return True

    # Payment not yet completed -- may still be processing
    return False

The callback flow:

1. Customer completes payment on PawaPay hosted page
2. PawaPay redirects to /v1/payments/{txn_id}/return
3. 0fee calls PawaPay API: GET /deposits/{deposit_id}
4. If status == COMPLETED: mark transaction complete, redirect to developer
5. If status != COMPLETED: show pending page, continue polling

Authentication

PawaPay uses API key authentication with a bearer token:

pythondef _get_headers(self) -> dict:
    """Get PawaPay API headers."""
    return {
        "Authorization": f"Bearer {self.api_key}",
        "Content-Type": "application/json",
    }

The API key is stored encrypted in 0fee's database per app. When the routing engine selects PawaPay for a payment, it decrypts the merchant's PawaPay credentials and passes them to the provider adapter.

PawaPay in the Routing Table

PawaPay typically sits at priority 2 for Francophone African countries (behind PaiementPro) and priority 1 for East/Southern African countries:

Payment MethodPawaPay PriorityOther Providers
PAYIN_ORANGE_CI2PaiementPro (1), Hub2 (3)
PAYIN_MTN_GH1--
PAYIN_MPESA_KE1--
PAYIN_VODACOM_TZ1--
PAYIN_MTN_UG1--
PAYIN_AIRTEL_RW1--
PAYIN_MTN_ZM1--

For markets like Kenya (M-Pesa) and Ghana (MTN), PawaPay is the sole provider. The routing engine does not need to fall back because no alternative exists in our ecosystem.

What We Learned

PawaPay's integration taught us three things about pan-African payment coverage:

  1. Hosted payment pages reduce integration complexity enormously. By letting PawaPay handle operator detection, phone validation, and payment collection, we avoid maintaining correspondent code logic for 21+ countries in our checkout flow. The trade-off is less control over the UI, but for a payment orchestrator, reliability matters more than pixel-perfect branding.
  1. Correspondent codes are not standardized. Each aggregator has its own naming convention. PawaPay uses ORANGE_CIV, Hub2 uses ORANGE_MONEY, and PaiementPro uses OMCIV2. The adapter layer must translate between our unified format and each provider's specific codes.
  1. Africa is not one market -- it is 54 markets. PawaPay's strength is breadth: 21+ countries with a single integration. But each country has different operators, different currencies, and different customer behaviors. The routing table must reflect these differences, not abstract them away.

PawaPay was one of the five providers built in Session 001 and expanded in Session 008 with the hosted payment page. Its broad coverage makes it indispensable for any pan-African payment platform.


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