Back to 0fee
0fee

The Test Provider and Sandbox System

How 0fee.dev built a test provider with magic amounts, test cards, and a sandbox system for developers to test payments safely.

Thales & Claude | March 25, 2026 10 min 0fee
testingsandboxtest-providerdeveloper-experiencemagic-amounts

Every payment platform needs a way for developers to test without moving real money. Stripe has its test mode with card number 4242424242424242. PayPal has its sandbox environment with fake accounts. 0fee.dev needed something that goes further -- a test system that simulates not just card payments but also mobile money USSD push, OTP validation, redirect flows, slow processing, and failure scenarios. All without touching a single real payment provider.

This article covers the test provider architecture, magic amounts for controlling outcomes, test cards and phone numbers, the sandbox checkout page with its built-in test guide, and why we removed auto-fallback to the test provider for security reasons.

Magic Amounts: Controlling Payment Outcomes

The test provider uses "magic amounts" -- specific transaction amounts that trigger predetermined behaviors:

AmountBehaviorUse Case
10000Instant successHappy path testing
99999Immediate failureError handling testing
55555Pending foreverTimeout handling testing
77777Slow success (30s delay)Loading state testing
88888OTP requiredOTP flow testing
44444Redirect flowRedirect handling testing
Any otherSuccess (2s delay)General testing
pythonclass TestProvider(BasePayinProvider):
    """Test provider with magic amounts for sandbox testing."""

    PROVIDER_ID = "test"
    MAGIC_AMOUNTS = {
        10000: "instant_success",
        99999: "failure",
        55555: "pending_forever",
        77777: "slow_success",
        88888: "otp_required",
        44444: "redirect",
    }

    async def initiate_payment(self, data: dict) -> dict:
        amount = data["amount"]
        behavior = self.MAGIC_AMOUNTS.get(amount, "default_success")

        if behavior == "instant_success":
            return {
                "status": "completed",
                "provider_reference": f"test_{data['reference']}",
                "payment_flow": {"type": "instant"},
            }

        elif behavior == "failure":
            return {
                "status": "failed",
                "error": "Payment declined (test mode: amount 99999)",
                "provider_reference": f"test_{data['reference']}",
            }

        elif behavior == "pending_forever":
            return {
                "status": "pending",
                "provider_reference": f"test_{data['reference']}",
                "payment_flow": {
                    "type": "ussd_push",
                    "message": "Waiting for confirmation (will never complete)",
                },
            }

        elif behavior == "slow_success":
            await asyncio.sleep(30)
            return {
                "status": "completed",
                "provider_reference": f"test_{data['reference']}",
            }

        elif behavior == "otp_required":
            return {
                "status": "pending_otp",
                "provider_reference": f"test_{data['reference']}",
                "payment_flow": {
                    "type": "otp",
                    "message": "Enter OTP code 123456",
                    "otp_length": 6,
                },
            }

        elif behavior == "redirect":
            redirect_url = f"{BASE_URL}/test/checkout/{data['transaction_id']}"
            return {
                "status": "pending",
                "provider_reference": f"test_{data['reference']}",
                "redirect_url": redirect_url,
                "payment_flow": {"type": "redirect"},
            }

        else:
            # Default: success with 2-second delay
            await asyncio.sleep(2)
            return {
                "status": "completed",
                "provider_reference": f"test_{data['reference']}",
            }

The magic amounts are chosen to be distinctive values that a developer would never use accidentally in production. 10000 is a round number for testing, 99999 is clearly a test value, and 88888/77777/55555/44444 form a memorable pattern.

Test Cards

For card payment testing, the test provider recognizes Stripe-compatible test card numbers:

Card NumberBrandResult
4242424242424242VisaSuccess
5555555555554444MastercardSuccess
378282246310005American ExpressSuccess
4000000000000002VisaDecline
4000000000009995VisaInsufficient funds
4000000000000069VisaExpired card
4000002500003155Visa3D Secure required
Any other valid cardAnySuccess (2s delay)
pythonTEST_CARDS = {
    "4242424242424242": {"result": "success", "brand": "visa"},
    "5555555555554444": {"result": "success", "brand": "mastercard"},
    "378282246310005": {"result": "success", "brand": "amex"},
    "4000000000000002": {"result": "decline", "error": "Card declined"},
    "4000000000009995": {"result": "decline", "error": "Insufficient funds"},
    "4000000000000069": {"result": "decline", "error": "Expired card"},
    "4000002500003155": {"result": "3ds", "message": "3D Secure required"},
}

async def process_card_payment(self, card_number: str, data: dict) -> dict:
    """Process test card payment."""
    card_info = self.TEST_CARDS.get(card_number)

    if card_info is None:
        # Unknown card -- default success with delay
        await asyncio.sleep(2)
        return {"status": "completed"}

    if card_info["result"] == "success":
        return {"status": "completed"}
    elif card_info["result"] == "decline":
        return {"status": "failed", "error": card_info["error"]}
    elif card_info["result"] == "3ds":
        return {
            "status": "pending",
            "payment_flow": {
                "type": "redirect",
                "message": card_info["message"],
            },
        }

We deliberately used Stripe's test card numbers so that developers already familiar with Stripe's sandbox can use the same numbers in 0fee.

Test Phone Numbers

For mobile money testing, magic phone numbers control the outcome:

Phone NumberResultNotes
+11111111111Instant successUniversal test number
+22222222222Always failsDecline simulation
+33333333333Pending foreverTimeout testing
+44444444444OTP required (code: 123456)OTP flow testing
+55555555555Insufficient balanceBalance error
+{dial_code}00000001SuccessCountry-specific success
+{dial_code}00000002FailureCountry-specific failure
pythonMAGIC_PHONES = {
    "+11111111111": "instant_success",
    "+22222222222": "always_fails",
    "+33333333333": "pending_forever",
    "+44444444444": "otp_required",
    "+55555555555": "insufficient_balance",
}

def _check_phone_behavior(self, phone: str) -> str:
    """Determine behavior from phone number."""
    if phone in self.MAGIC_PHONES:
        return self.MAGIC_PHONES[phone]

    # Country-specific patterns
    if phone.endswith("00000001"):
        return "instant_success"
    elif phone.endswith("00000002"):
        return "always_fails"

    return "default_success"

The country-specific patterns (+22500000001 for Ivory Coast, +25400000001 for Kenya) let developers test with realistic phone number formats while still controlling the outcome.

The Sandbox Checkout Page

In Session 059, we built a dedicated sandbox checkout page -- a special version of the hosted checkout that includes a built-in test guide panel:

pythonasync def sandbox_checkout_page(request: Request, session_id: str):
    """Render sandbox checkout with test guide panel."""
    session_data = await get_checkout_session(session_id)

    return templates.TemplateResponse(
        "checkout.html",
        {
            "request": request,
            "session": session_data,
            "sandbox_mode": True,  # Enables test guide panel
            "default_country": "US",
            "default_phone_prefix": "+1",
            "phone_placeholder": "1111111111",
            "phone_hint": "Use +11111111111 for successful payment",
        },
    )

The sandbox checkout uses the same Jinja2 template as the live checkout, with a sandbox_mode flag that enables additional UI elements:

Test Guide Panel

A toggleable panel on the right side of the checkout page displays:

  1. Test Cards -- The complete list of test card numbers with expected results
  2. Test Phone Numbers -- Magic phone numbers for mobile money testing
  3. Magic Amounts -- Amount values that trigger specific behaviors

The panel uses a dark theme with glassmorphism effects, toggled by a button with green (open) / red (close) states. It provides developers with everything they need to test without switching to documentation.

Sandbox Visual Indicators

html{% if sandbox_mode %}
<div class="sandbox-banner">
    <span class="shimmer-text">SANDBOX MODE</span>
    <span>Test payments -- no real money will be charged</span>
</div>
{% endif %}

The sandbox banner uses a shimmer animation to clearly distinguish test from production checkout. This prevents the common mistake of confusing sandbox and live environments.

Auto-Fallback: Built, Then Removed

In Session 033, we implemented automatic fallback to the test provider. When a developer used a sandbox API key (sk_sand_*) and no real provider was configured for the requested payment method, the routing engine would silently fall back to the test provider:

python# Session 033: Auto-fallback implementation
async def get_provider_for_method(method: str, environment: str, app_id: str):
    providers = await get_routing_table(method, app_id)

    if not providers and environment == "sandbox":
        # Fall back to test provider
        return TestProvider(credentials={}, environment="sandbox")

    # ... normal routing logic

This seemed developer-friendly. No configuration needed -- just start testing.

Why We Removed It (Session 043)

In Session 043, we identified this as a critical security issue and removed it:

python# Session 043: Auto-fallback removed
async def get_provider_for_method(method: str, environment: str, app_id: str):
    providers = await get_routing_table(method, app_id)

    if not providers:
        # No fallback -- return clear error
        raise NoProviderConfiguredError(
            f"No provider configured for {method}. "
            "Add provider credentials in your dashboard."
        )

    # ... normal routing logic

The problem: if a developer accidentally used a sandbox key in production code, payments would silently route to the test provider instead of failing loudly. The test provider always succeeds (for most amounts), so the developer would see "payment completed" while no real money moved. This could go undetected for hours or days, causing reconciliation nightmares.

ScenarioWith Auto-FallbackWithout Auto-Fallback
Sandbox key, no provider configuredSilently uses test providerClear error: "No provider configured"
Sandbox key, real provider configuredUses real provider's sandboxUses real provider's sandbox
Live key, no provider configuredError (correct)Error (correct)
Live key, real provider configuredUses real provider (correct)Uses real provider (correct)

The removal was a security-first decision. Developers can still test by explicitly adding the test provider to their app through the dashboard. The test provider is seeded in the routing table at priority 100 (lowest), so it never accidentally takes precedence over real providers.

Test Provider in the Routing Table

The test provider is seeded with a routing priority of 100 for all payment methods:

python# Seed script
async def seed_test_provider():
    """Seed test provider routing entries."""
    methods = await get_all_payin_methods()

    for method in methods:
        await create_routing_entry(
            provider_id="test",
            payin_method_id=method["id"],
            provider_method_code=f"TEST_{method['code']}",
            priority=100,
            is_active=True,
        )

Priority 100 means the test provider is always the last resort. In production with real providers configured:

MethodPriority 1Priority 2Priority 3Priority 100
PAYIN_ORANGE_CIpaiementpropawapayhub2test
PAYIN_CARDstripe----test
PAYIN_PAYPALpaypal----test

The test provider only activates if the developer explicitly configures it in their app's provider settings. This provides a clear, intentional testing path without the risks of automatic fallback.

OTP Testing Flow

The test provider's OTP flow (triggered by amount 88888 or phone +44444444444) simulates the complete OTP cycle:

pythonasync def validate_otp(self, provider_reference: str, otp_code: str) -> dict:
    """Validate test OTP -- accepts 123456 as valid code."""
    if otp_code == "123456":
        return {
            "status": "completed",
            "provider_reference": provider_reference,
        }
    else:
        return {
            "status": "failed",
            "error": "Invalid OTP code. Use 123456 for test.",
        }

This lets developers test their OTP input UI, error handling for wrong codes, and the transition from OTP input to payment completion -- all without a real mobile money operator.

What We Learned

Building the test provider and sandbox system taught us three things:

  1. Developer experience and security are often in tension. Auto-fallback was the most developer-friendly feature we built -- and also the most dangerous. We chose security. Developers can still test easily, but they must explicitly opt in.
  1. Test systems must simulate every flow type. A test provider that only returns "success" is useless for testing error handling, timeouts, OTP flows, and redirect flows. The magic amounts and phone numbers ensure developers can test every branch of their integration code.
  1. Visual differentiation between sandbox and production is critical. The shimmer-animated sandbox banner, the green/red test guide toggle, and the clear "no real money" messaging prevent developers from accidentally testing in production or going live with test credentials.

The test provider was built in Session 001 and refined through Sessions 033, 040, 043, and 059. Its evolution -- from basic magic amounts to a full sandbox system with a test guide panel, then the removal of auto-fallback -- mirrors the platform's maturation from prototype to production-ready service.


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