Back to 0fee
0fee

Provider Routing Priorities: How 0fee Chooses the Best Path

How 0fee.dev's 3-tier provider routing with 117 methods selects the optimal payment path using database-driven priority tables.

Thales & Claude | March 25, 2026 10 min 0fee
routingprovider-selectionfallbackdatabase-drivenpayment-methods

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:

  1. PaiementPro -- Local Ivorian provider, direct integration
  2. PawaPay -- Pan-African aggregator with correspondent code ORANGE_CIV
  3. Hub2 -- Francophone Africa specialist
  4. 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:

PriorityMeaningExample
1Primary providerPaiementPro for PAYIN_ORANGE_CI
2Secondary providerPawaPay for PAYIN_ORANGE_CI
3Tertiary providerHub2 for PAYIN_ORANGE_CI
100Test 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:

  1. Query the routing table for all providers that support the requested method
  2. Order by priority (ascending -- lower number = higher priority)
  3. For each provider, check if the merchant has configured credentials
  4. Return the first provider that has valid credentials
  5. 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 MethodProviderPriorityMethod Code
PAYIN_ORANGE_CIpaiementpro1OMCIV2
PAYIN_ORANGE_CIpawapay2ORANGE_CIV
PAYIN_ORANGE_CIhub23ORANGE_MONEY
PAYIN_ORANGE_CIbui4orange_ci
PAYIN_MTN_CIpawapay1MTN_CIV
PAYIN_MTN_CIhub22MTN_MOMO
PAYIN_MTN_CIbui3mtn_ci
PAYIN_WAVE_CIpaiementpro1WAVECI
PAYIN_WAVE_CIhub22WAVE
PAYIN_WAVE_CIbui3wave_ci
PAYIN_MOOV_CIpaiementpro1MOOVCI
PAYIN_MOOV_CIhub22MOOV_MONEY
PAYIN_MOOV_CIbui3moov_ci

East Africa (Sample)

Payment MethodProviderPriorityMethod Code
PAYIN_MPESA_KEpawapay1MPESA_KEN
PAYIN_AIRTEL_KEpawapay1AIRTEL_KEN
PAYIN_MTN_UGpawapay1MTN_UGA
PAYIN_AIRTEL_UGpawapay1AIRTEL_UGA
PAYIN_VODACOM_TZpawapay1VODACOM_TZN
PAYIN_MTN_GHpawapay1MTN_GHA

Global

Payment MethodProviderPriorityMethod Code
PAYIN_CARDstripe1card
PAYIN_PAYPALpaypal1paypal

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 None

This 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:

FeatureHardcodedDatabase-Driven
Adding a new providerCode change + deployAPI call or admin UI
Changing prioritiesCode change + deployDatabase update
Per-app routingNot possibleQuery includes app_id
A/B testing providersNot possibleChange priority weights
Disabling a providerCode change + deploySet 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 methods

The /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:

  1. 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.
  1. 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.
  1. 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.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles