When a merchant sends a payment request to 0fee.dev, something has to decide which provider handles it. Not "Stripe or PayPal" -- that is a simple binary choice. The real question is: for an Orange Money payment in Ivory Coast, should the system use PaiementPro (priority 1), PawaPay (priority 2), or Hub2 (priority 3)? And if PaiementPro is down, should it automatically fall back to PawaPay? This is the routing engine.
The Core Problem
0fee.dev covers 53+ providers across 200+ countries. For many payment methods, multiple providers can handle the same transaction. In Francophone Africa, an Orange Money payment in Ivory Coast can be processed by PaiementPro, PawaPay, Hub2, or BUI. Each has different fees, different reliability records, and different coverage gaps.
The routing engine must:
- Discover which payment methods are available for a given country.
- Select the best provider for a given payment method.
- Fall back to the next provider if the first one fails.
- Respect the merchant's configured providers and credentials.
Country-Based Payment Method Discovery
The first step in any payment is discovering what payment methods are available. This is driven by the payin_methods table:
sqlCREATE TABLE payin_methods (
id TEXT PRIMARY KEY,
code TEXT UNIQUE NOT NULL, -- e.g., "PAYIN_ORANGE_CI"
name TEXT NOT NULL, -- e.g., "Orange Money Ivory Coast"
country_code TEXT NOT NULL, -- e.g., "CI"
currency TEXT NOT NULL, -- e.g., "XOF"
type TEXT NOT NULL, -- e.g., "mobile_money"
operator TEXT, -- e.g., "Orange"
is_active INTEGER DEFAULT 1,
min_amount INTEGER,
max_amount INTEGER,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);When a checkout session begins, the frontend sends the customer's country. The routing engine queries all active payment methods for that country:
pythonasync def get_payment_methods_for_country(
country_code: str
) -> list[dict]:
"""
Get all available payment methods for a country.
Returns methods sorted by type (mobile_money first, then card).
"""
with get_db() as conn:
methods = conn.execute(
"""
SELECT code, name, type, operator, currency,
min_amount, max_amount
FROM payin_methods
WHERE country_code = ? AND is_active = 1
ORDER BY
CASE type
WHEN 'mobile_money' THEN 1
WHEN 'card' THEN 2
ELSE 3
END,
name
""",
(country_code.upper(),)
).fetchall()
return [dict(m) for m in methods]For Ivory Coast (CI), this returns:
| Code | Name | Type | Operator | Currency |
|---|---|---|---|---|
PAYIN_ORANGE_CI | Orange Money Ivory Coast | mobile_money | Orange | XOF |
PAYIN_MTN_CI | MTN Mobile Money Ivory Coast | mobile_money | MTN | XOF |
PAYIN_WAVE_CI | Wave Ivory Coast | mobile_money | Wave | XOF |
PAYIN_MOOV_CI | Moov Money Ivory Coast | mobile_money | Moov | XOF |
PAYIN_CARD_GLOBAL | Card Payment | card | - | Multiple |
For Ghana (GH):
| Code | Name | Type | Operator | Currency |
|---|---|---|---|---|
PAYIN_MTN_GH | MTN Mobile Money Ghana | mobile_money | MTN | GHS |
PAYIN_VODAFONE_GH | Vodafone Cash Ghana | mobile_money | Vodafone | GHS |
PAYIN_AIRTELTIGO_GH | AirtelTigo Money Ghana | mobile_money | AirtelTigo | GHS |
PAYIN_CARD_GLOBAL | Card Payment | card | - | Multiple |
For Kenya (KE):
| Code | Name | Type | Operator | Currency |
|---|---|---|---|---|
PAYIN_MPESA_KE | M-Pesa Kenya | mobile_money | M-Pesa | KES |
PAYIN_AIRTEL_KE | Airtel Money Kenya | mobile_money | Airtel | KES |
PAYIN_CARD_GLOBAL | Card Payment | card | - | Multiple |
Priority-Based Provider Selection
Once a payment method is chosen, the routing engine determines which provider handles it. This is the provider_routing table:
sqlCREATE TABLE provider_routing (
id TEXT PRIMARY KEY,
payin_method_code TEXT NOT NULL, -- e.g., "PAYIN_ORANGE_CI"
provider_id TEXT NOT NULL, -- e.g., "paiementpro"
provider_method_code TEXT, -- e.g., "OMCIV2"
priority INTEGER NOT NULL DEFAULT 1,
environment TEXT DEFAULT 'production',
is_active INTEGER DEFAULT 1,
UNIQUE(payin_method_code, provider_id, environment)
);The three-tier priority system works as follows:
| Priority | Meaning | Example |
|---|---|---|
| 1 | Primary provider -- used first | PaiementPro for PAYIN_ORANGE_CI |
| 2 | Secondary provider -- used if priority 1 fails | PawaPay for PAYIN_ORANGE_CI |
| 3 | Tertiary provider -- last resort | Hub2 for PAYIN_ORANGE_CI |
Here is the routing configuration for Orange Money in Ivory Coast:
PAYIN_ORANGE_CI routing:
Priority 1: paiementpro (method code: OMCIV2)
Priority 2: pawapay (method code: ORANGE_CIV)
Priority 3: hub2 (method code: Orange)And for MTN in Ghana:
PAYIN_MTN_GH routing:
Priority 1: pawapay (method code: MTN_GHA)
Priority 2: hub2 (method code: MTN)The provider_method_code column is essential. Each provider uses its own internal codes for the same payment method. PaiementPro calls Orange Money in Ivory Coast "OMCIV2". PawaPay calls it "ORANGE_CIV". Hub2 simply calls it "Orange" with a country parameter. The routing table maps the unified code (PAYIN_ORANGE_CI) to each provider's internal code.
The Routing Algorithm
The core routing function queries the provider_routing table, orders by priority, and returns the best available provider:
pythonasync def get_provider_for_unified_method(
payment_method: str,
app_id: str,
environment: str = "production",
skip_providers: list[str] = None
) -> dict | None:
"""
Find the best provider for a unified payment method.
Args:
payment_method: Unified code like "PAYIN_ORANGE_CI"
app_id: The merchant's app ID
environment: "production" or "sandbox"
skip_providers: List of provider IDs to skip (for fallback)
Returns:
Dict with provider_id, provider_method_code, priority,
or None if no provider available.
"""
skip_providers = skip_providers or []
with get_db() as conn:
# Get routing entries ordered by priority
routes = conn.execute(
"""
SELECT
pr.provider_id,
pr.provider_method_code,
pr.priority
FROM provider_routing pr
WHERE pr.payin_method_code = ?
AND pr.environment = ?
AND pr.is_active = 1
AND pr.provider_id NOT IN ({})
ORDER BY pr.priority ASC
""".format(
",".join("?" * len(skip_providers))
),
(payment_method, environment, *skip_providers)
).fetchall()
if not routes:
return None
# Check which providers the merchant has credentials for
for route in routes:
has_creds = conn.execute(
"""
SELECT 1 FROM provider_credentials
WHERE app_id = ?
AND provider_id = ?
AND environment = ?
AND is_active = 1
""",
(app_id, route["provider_id"], environment)
).fetchone()
if has_creds:
return {
"provider_id": route["provider_id"],
"provider_method_code":
route["provider_method_code"],
"priority": route["priority"],
}
return NoneThe algorithm's logic:
- Query all routing entries for the payment method, ordered by priority (ascending).
- Exclude any providers in
skip_providers(used for fallback after failure). - For each route, check if the merchant has active credentials for that provider.
- Return the first match -- the highest-priority provider that the merchant can use.
This design handles a common situation: a merchant might not have PaiementPro credentials. Even though PaiementPro is priority 1 for PAYIN_ORANGE_CI, if the merchant only has Hub2 credentials, the engine skips PaiementPro and returns Hub2.
Fallback Logic
When a payment fails at the provider level, the system can retry with the next provider in the chain:
pythonasync def initiate_payment_with_fallback(
payment_method: str,
payment_data: dict,
app_id: str,
environment: str,
max_attempts: int = 3
) -> tuple[InitPaymentResult, str]:
"""
Try to initiate a payment, falling back to lower-priority
providers if the primary fails.
Returns:
Tuple of (result, provider_id)
"""
skip_providers = []
for attempt in range(max_attempts):
route = await get_provider_for_unified_method(
payment_method, app_id, environment,
skip_providers=skip_providers
)
if not route:
break
provider_id = route["provider_id"]
# Get provider instance with merchant's credentials
credentials = await get_decrypted_credentials(
app_id, provider_id, environment
)
provider = provider_registry.get_instance(
provider_id, credentials, app_id
)
# Add provider-specific method code to payment data
payment_data["provider_method_code"] = (
route["provider_method_code"]
)
# Try the payment
result = await provider.initiate_payment(payment_data)
if result.status != "failed":
return result, provider_id
# This provider failed -- skip it and try next
skip_providers.append(provider_id)
# All providers failed
return InitPaymentResult(
provider_ref="",
status="failed",
instructions="No available provider could process this payment"
), ""Consider the fallback in practice for a PAYIN_ORANGE_CI payment:
- Attempt 1: Try PaiementPro (priority 1). PaiementPro returns a timeout error.
- Attempt 2: Skip PaiementPro, try PawaPay (priority 2). PawaPay successfully initiates the payment.
- The merchant and customer never know that PaiementPro was attempted first.
Country Detection from Payment Method Code
One elegant aspect of the unified format is that the country is encoded in the payment method code itself. The routing engine extracts it automatically:
pythondef extract_country_from_method(payment_method: str) -> str:
"""
Extract country code from unified payment method.
Examples:
PAYIN_ORANGE_CI -> CI
PAYIN_MTN_GH -> GH
PAYIN_MPESA_KE -> KE
PAYIN_WAVE_SN -> SN
PAYIN_CARD_GLOBAL -> GLOBAL
"""
parts = payment_method.split("_")
return parts[-1] if parts else ""
# Country to currency mapping
COUNTRY_CURRENCY = {
"CI": "XOF", # Ivory Coast -> CFA Franc (BCEAO)
"SN": "XOF", # Senegal
"ML": "XOF", # Mali
"BF": "XOF", # Burkina Faso
"BJ": "XOF", # Benin
"TG": "XOF", # Togo
"NE": "XOF", # Niger
"GW": "XOF", # Guinea-Bissau
"GH": "GHS", # Ghana -> Cedi
"KE": "KES", # Kenya -> Shilling
"NG": "NGN", # Nigeria -> Naira
"TZ": "TZS", # Tanzania -> Shilling
"UG": "UGX", # Uganda -> Shilling
"RW": "RWF", # Rwanda -> Franc
"ZA": "ZAR", # South Africa -> Rand
"CM": "XAF", # Cameroon -> CFA Franc (BEAC)
"GLOBAL": None, # Multi-currency
}
def get_currency_for_method(payment_method: str) -> str | None:
country = extract_country_from_method(payment_method)
return COUNTRY_CURRENCY.get(country)This means a merchant can initiate a payment with just:
json{
"amount": 5000,
"payment_method": "PAYIN_ORANGE_CI",
"customer": {
"phone": "+2250709757296"
}
}No country field needed -- it is CI, extracted from the method code. No currency field needed -- it is XOF, derived from the country. The routing engine determines the provider, the provider method code, and the currency automatically.
The Routing Table: 117 Payment Methods
The full routing table maps 117 payment methods across 30+ countries to their provider configurations. Here is a representative sample:
| Payment Method | Provider | Method Code | Priority |
|---|---|---|---|
PAYIN_ORANGE_CI | paiementpro | OMCIV2 | 1 |
PAYIN_ORANGE_CI | pawapay | ORANGE_CIV | 2 |
PAYIN_ORANGE_CI | hub2 | Orange | 3 |
PAYIN_MTN_CI | paiementpro | MOMOCI | 1 |
PAYIN_MTN_CI | pawapay | MTN_CIV | 2 |
PAYIN_MTN_CI | hub2 | MTN | 3 |
PAYIN_WAVE_CI | paiementpro | WAVECI | 1 |
PAYIN_WAVE_CI | pawapay | WAVE_CIV | 2 |
PAYIN_WAVE_SN | pawapay | WAVE_SEN | 1 |
PAYIN_WAVE_SN | hub2 | Wave | 2 |
PAYIN_ORANGE_SN | pawapay | ORANGE_SEN | 1 |
PAYIN_ORANGE_SN | hub2 | Orange | 2 |
PAYIN_MTN_GH | pawapay | MTN_GHA | 1 |
PAYIN_VODAFONE_GH | pawapay | VODAFONE_GHA | 1 |
PAYIN_MPESA_KE | pawapay | MPESA_KEN | 1 |
PAYIN_AIRTEL_KE | pawapay | AIRTEL_KEN | 1 |
PAYIN_MTN_UG | pawapay | MTN_UGA | 1 |
PAYIN_MTN_CM | pawapay | MTN_CMR | 1 |
PAYIN_MTN_CM | hub2 | MTN | 2 |
PAYIN_ORANGE_CM | pawapay | ORANGE_CMR | 1 |
PAYIN_ORANGE_CM | hub2 | Orange | 2 |
PAYIN_MTN_BJ | pawapay | MTN_BEN | 1 |
PAYIN_MTN_BJ | hub2 | MTN | 2 |
PAYIN_CARD_GLOBAL | stripe | card | 1 |
PAYIN_PAYPAL_GLOBAL | paypal | paypal | 1 |
The pattern is consistent: for Francophone West Africa (UEMOA zone), PaiementPro is typically priority 1 because it offers the best rates for local mobile money. PawaPay covers the broadest range of countries as a secondary option. Hub2 serves as the tertiary fallback.
For East and Southern Africa, PawaPay is typically the only configured provider. For global card payments, Stripe is the single route.
How Routing Priorities Were Determined
Priority assignment was not arbitrary. Three factors determined the ranking:
- Transaction success rate. Providers with higher completion rates for a specific country and operator get higher priority.
- Fee structure. Lower-fee providers are preferred when success rates are comparable.
- Coverage depth. Providers with native integration in a country (like PaiementPro in Ivory Coast) are preferred over aggregators that route through intermediaries.
PaiementPro ranks first for Francophone West Africa because it has direct integration with Orange Money and MTN in those countries, resulting in faster processing and higher success rates. PawaPay, while covering more countries, operates through aggregation partnerships that sometimes add latency.
Performance Considerations
The routing lookup hits the database on every payment. For a high-traffic platform, this could become a bottleneck. Three optimizations were planned:
- Cache routing lookups. The routing table changes infrequently. A 5-minute TTL cache in DragonflyDB eliminates most database queries.
- Pre-load country data. At startup, load all country-to-payment-method mappings into memory.
- Skip credential check for known configs. If a merchant's provider credentials have been verified once, cache that result.
These optimizations were not implemented in Session 001. The routing engine was designed to be correct first and fast later. The database approach was adequate for early traffic, and caching was added incrementally as the platform grew.
The Routing Engine as a Business Lever
Beyond technical routing, the priority system is a business tool. If 0fee.dev negotiates a better rate with a new provider, adjusting the routing table changes which provider handles traffic -- without any code change, without any merchant intervention, and without any downtime. A single UPDATE statement shifts payment volume from one provider to another.
This is the power of a routing engine in a payment orchestrator. It transforms provider selection from a hard-coded decision into a configurable, data-driven process that can be optimized continuously.
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.