When a customer in Ivory Coast pays with Orange Money, 0fee does not send the payment to a random provider. It consults a routing table with 117 payment method entries, selects the highest-priority provider that has valid credentials configured for the merchant's app, and falls back through alternatives if the primary provider fails. This routing decision happens in milliseconds, invisible to both the customer and the developer.
This article covers the 3-tier priority system, the provider routing table with 117 methods, how the test provider is seeded at priority 100, the fallback logic, and the transition from hardcoded routing to database-driven configuration in Session 021.
The Routing Problem
Consider PAYIN_ORANGE_CI -- Orange Money in Ivory Coast. Four different providers in 0fee can process this payment:
- PaiementPro -- Local Ivorian provider, direct integration
- PawaPay -- Pan-African aggregator with correspondent code ORANGE_CIV
- Hub2 -- Francophone Africa specialist
- BUI -- West African aggregator
Each provider has different fees, different processing times, different reliability characteristics, and different API flows. The routing engine must choose the best one, and it must do so without exposing this complexity to the developer.
The 3-Tier Priority System
Provider routing in 0fee uses a simple integer priority system. Lower numbers mean higher priority. The routing engine selects the lowest-numbered provider that meets all requirements:
| Priority | Meaning | Example |
|---|---|---|
| 1 | Primary provider | PaiementPro for PAYIN_ORANGE_CI |
| 2 | Secondary provider | PawaPay for PAYIN_ORANGE_CI |
| 3 | Tertiary provider | Hub2 for PAYIN_ORANGE_CI |
| 100 | Test provider (always last) | Test for all methods |
pythonasync def get_provider_for_unified_method(
payment_method: str,
app_id: str,
environment: str = "sandbox",
) -> tuple:
"""Select the best provider for a payment method.
Returns (provider_instance, provider_method_code) tuple.
"""
# Get all routing entries for this method, ordered by priority
routing_entries = await db.fetch_all(
"""
SELECT pr.provider_id, pr.provider_method_code, pr.priority
FROM provider_routing pr
JOIN payin_methods pm ON pr.payin_method_id = pm.id
WHERE pm.code = :method
AND pr.is_active = 1
ORDER BY pr.priority ASC
""",
{"method": payment_method},
)
for entry in routing_entries:
provider_id = entry["provider_id"]
method_code = entry["provider_method_code"]
# Check if app has credentials for this provider
if await _has_credentials(app_id, provider_id, environment):
# Instantiate the provider with app's credentials
provider = await _get_provider_instance(
provider_id, app_id, environment
)
return (provider, method_code)
raise NoProviderConfiguredError(
f"No provider configured for {payment_method}. "
"Add provider credentials in your dashboard."
)The algorithm is straightforward:
- Query the routing table for all providers that support the requested method
- Order by priority (ascending -- lower number = higher priority)
- For each provider, check if the merchant has configured credentials
- Return the first provider that has valid credentials
- If no provider has credentials, raise an error
The Provider Routing Table
The routing table contains 117 entries, mapping every supported payment method to every provider that can process it:
West Africa (Sample)
| 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_ORANGE_CI | bui | 4 | orange_ci |
| PAYIN_MTN_CI | pawapay | 1 | MTN_CIV |
| PAYIN_MTN_CI | hub2 | 2 | MTN_MOMO |
| PAYIN_MTN_CI | bui | 3 | mtn_ci |
| PAYIN_WAVE_CI | paiementpro | 1 | WAVECI |
| PAYIN_WAVE_CI | hub2 | 2 | WAVE |
| PAYIN_WAVE_CI | bui | 3 | wave_ci |
| PAYIN_MOOV_CI | paiementpro | 1 | MOOVCI |
| PAYIN_MOOV_CI | hub2 | 2 | MOOV_MONEY |
| PAYIN_MOOV_CI | bui | 3 | moov_ci |
East Africa (Sample)
| Payment Method | Provider | Priority | Method Code |
|---|---|---|---|
| PAYIN_MPESA_KE | pawapay | 1 | MPESA_KEN |
| PAYIN_AIRTEL_KE | pawapay | 1 | AIRTEL_KEN |
| PAYIN_MTN_UG | pawapay | 1 | MTN_UGA |
| PAYIN_AIRTEL_UG | pawapay | 1 | AIRTEL_UGA |
| PAYIN_VODACOM_TZ | pawapay | 1 | VODACOM_TZN |
| PAYIN_MTN_GH | pawapay | 1 | MTN_GHA |
Global
| Payment Method | Provider | Priority | Method Code |
|---|---|---|---|
| PAYIN_CARD | stripe | 1 | card |
| PAYIN_PAYPAL | paypal | 1 | paypal |
Notice the pattern: West African methods have multiple providers at different priorities. East African and global methods typically have a single provider at priority 1. This reflects the reality of provider coverage -- there are many local aggregators competing in West Africa, but fewer options for East African mobile money.
The Database Schema
The routing table lives in two related database tables:
sql-- Payment methods (117 entries)
CREATE 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 TEXT NOT NULL, -- e.g., CI
currency TEXT NOT NULL, -- e.g., XOF
operator TEXT, -- e.g., Orange
type TEXT NOT NULL, -- e.g., mobile_money, card, wallet
is_active INTEGER DEFAULT 1,
created_at INTEGER DEFAULT (strftime('%s', 'now'))
);
-- Provider routing (many-to-many with priority)
CREATE TABLE provider_routing (
id TEXT PRIMARY KEY,
provider_id TEXT NOT NULL, -- e.g., paiementpro
payin_method_id TEXT NOT NULL, -- FK to payin_methods
provider_method_code TEXT NOT NULL, -- e.g., OMCIV2
priority INTEGER NOT NULL DEFAULT 1,
is_active INTEGER DEFAULT 1,
created_at INTEGER DEFAULT (strftime('%s', 'now')),
FOREIGN KEY (payin_method_id) REFERENCES payin_methods(id),
UNIQUE(provider_id, payin_method_id)
);The UNIQUE(provider_id, payin_method_id) constraint ensures each provider appears at most once per payment method. The priority field determines the selection order.
Credential Checking
The routing engine does not just check if a provider exists in the table -- it verifies that the merchant has configured valid credentials for that provider:
pythonasync def _has_credentials(
app_id: str, provider_id: str, environment: str
) -> bool:
"""Check if app has credentials configured for a provider."""
if provider_id == "test":
return True # Test provider needs no credentials
credential = await db.fetch_one(
"""
SELECT id FROM app_provider_credentials
WHERE app_id = :app_id
AND provider_id = :provider_id
AND environment = :environment
AND is_active = 1
""",
{
"app_id": app_id,
"provider_id": provider_id,
"environment": environment,
},
)
return credential is not NoneThis means the routing table defines the potential routing paths, but the actual path depends on what the merchant has set up. A merchant who only configures Stripe and PawaPay will never have payments routed to PaiementPro, even though PaiementPro has a higher priority in the global routing table.
Fallback Logic
When the primary provider fails (API error, timeout, declined), the routing engine tries the next provider:
pythonasync def route_payment_with_fallback(
payment_method: str,
app_id: str,
environment: str,
data: dict,
) -> dict:
"""Route payment with automatic fallback to next provider."""
routing_entries = await get_routing_entries(payment_method)
last_error = None
for entry in routing_entries:
provider_id = entry["provider_id"]
if not await _has_credentials(app_id, provider_id, environment):
continue
try:
provider = await _get_provider_instance(
provider_id, app_id, environment
)
result = await provider.initiate_payment(data)
if result.get("status") != "failed":
# Store which provider was used
data["actual_provider"] = provider_id
return result
last_error = result.get("error", "Provider returned failure")
except Exception as e:
last_error = str(e)
continue # Try next provider
raise AllProvidersFailedError(
f"All providers failed for {payment_method}. "
f"Last error: {last_error}"
)The fallback is transparent to the developer. They call POST /v1/payments with payment_method: "PAYIN_ORANGE_CI", and the routing engine tries PaiementPro, then PawaPay, then Hub2, then BUI -- returning the first successful result. The developer's webhook receives the payment confirmation regardless of which provider processed it.
From Hardcoded to Database-Driven
In the initial implementation (Session 001), routing was hardcoded:
python# Session 001: Hardcoded routing
PROVIDER_MAP = {
"PAYIN_ORANGE_CI": [
("paiementpro", "OMCIV2"),
("hub2", "ORANGE_MONEY"),
],
"PAYIN_CARD": [
("stripe", "card"),
],
}Session 010 introduced the unified payment method format (PAYIN_XXX_YY) and the payin_methods table. Session 021 completed the transition to fully database-driven routing with the provider_routing table and REST API endpoints for managing routes.
The database-driven approach has significant advantages:
| Feature | Hardcoded | Database-Driven |
|---|---|---|
| Adding a new provider | Code change + deploy | API call or admin UI |
| Changing priorities | Code change + deploy | Database update |
| Per-app routing | Not possible | Query includes app_id |
| A/B testing providers | Not possible | Change priority weights |
| Disabling a provider | Code change + deploy | Set is_active = 0 |
The Seed Script
Provider routing entries are populated by a seed script that runs during deployment:
pythonasync def seed_routing_table():
"""Seed the provider routing table with default priorities."""
entries = [
# Orange Money Ivory Coast
("paiementpro", "PAYIN_ORANGE_CI", "OMCIV2", 1),
("pawapay", "PAYIN_ORANGE_CI", "ORANGE_CIV", 2),
("hub2", "PAYIN_ORANGE_CI", "ORANGE_MONEY", 3),
("bui", "PAYIN_ORANGE_CI", "orange_ci", 4),
# MTN Ivory Coast
("pawapay", "PAYIN_MTN_CI", "MTN_CIV", 1),
("hub2", "PAYIN_MTN_CI", "MTN_MOMO", 2),
("bui", "PAYIN_MTN_CI", "mtn_ci", 3),
# Wave Ivory Coast
("paiementpro", "PAYIN_WAVE_CI", "WAVECI", 1),
("hub2", "PAYIN_WAVE_CI", "WAVE", 2),
("bui", "PAYIN_WAVE_CI", "wave_ci", 3),
# ... 100+ more entries
# Global
("stripe", "PAYIN_CARD", "card", 1),
("paypal", "PAYIN_PAYPAL", "paypal", 1),
# Test provider (always last)
("test", "PAYIN_ORANGE_CI", "TEST_ORANGE_CI", 100),
("test", "PAYIN_CARD", "TEST_CARD", 100),
# ... test entries for all methods
]
for provider_id, method_code, provider_method_code, priority in entries:
method = await get_payin_method_by_code(method_code)
if method:
await upsert_routing_entry(
provider_id=provider_id,
payin_method_id=method["id"],
provider_method_code=provider_method_code,
priority=priority,
)Provider Routing API
Session 021 also introduced REST endpoints for managing routing:
GET /v1/apps/{app_id}/routes -- List routing rules for app
POST /v1/apps/{app_id}/routes -- Create custom routing rule
DELETE /v1/apps/{app_id}/routes/{id} -- Delete routing rule
GET /v1/payin-methods/{code} -- Get method with providers
GET /v1/providers/{id}/methods -- Get provider's methodsThe /v1/payin-methods/PAYIN_ORANGE_CI endpoint returns the method with all its providers and priorities:
json{
"code": "PAYIN_ORANGE_CI",
"name": "Orange Money (Ivory Coast)",
"country": "CI",
"currency": "XOF",
"providers": [
{"provider_id": "paiementpro", "priority": 1, "code": "OMCIV2"},
{"provider_id": "pawapay", "priority": 2, "code": "ORANGE_CIV"},
{"provider_id": "hub2", "priority": 3, "code": "ORANGE_MONEY"},
{"provider_id": "bui", "priority": 4, "code": "orange_ci"},
{"provider_id": "test", "priority": 100, "code": "TEST_ORANGE_CI"}
]
}Country and Method Auto-Detection
The unified payment method format encodes the country and operator, enabling automatic detection:
pythondef parse_payment_method(method: str) -> dict:
"""Parse unified payment method.
PAYIN_ORANGE_CI -> {operator: "ORANGE", country: "CI"}
PAYIN_MPESA_KE -> {operator: "MPESA", country: "KE"}
PAYIN_CARD -> {operator: "CARD", country: None}
"""
parts = method.replace("PAYIN_", "").rsplit("_", 1)
if len(parts) == 2:
return {"operator": parts[0], "country": parts[1]}
else:
return {"operator": parts[0], "country": None}When a developer sends payment_method: "PAYIN_ORANGE_CI", the system automatically detects country CI, currency XOF, and routes to the appropriate provider -- the developer does not need to specify country, currency, or provider separately.
What We Learned
Building the routing system taught us three things:
- Priority-based routing is simple and effective. We considered more complex algorithms (weighted random, latency-based, cost-optimized), but a simple priority queue with credential checking and fallback handles 99% of real-world scenarios. Complexity can be added later without changing the interface.
- Database-driven routing is essential for operations. The ability to disable a provider, change priorities, or add new routing entries without a code deployment is not a nice-to-have -- it is a requirement for a production payment platform.
- The routing table is the single source of truth. Every component that needs to know "which provider handles PAYIN_ORANGE_CI" queries the same table. The API, the checkout widget, the hosted checkout, the dashboard -- they all agree because they all read from the same source.
The routing system evolved from a hardcoded dictionary in Session 001 to a fully database-driven, API-managed configuration by Session 021. With 117 methods across 6 providers, it routes every payment in 0fee's ecosystem to the optimal path.
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.