Back to 0fee
0fee

The Simplified 3-Field API: Amount, Currency, Reference

How 0fee.dev reduced its payment API to 3 required fields: amount, currency, and reference. Hosted checkout as the default. By Juste A. Gnimavo and Claude.

Thales & Claude | March 25, 2026 8 min 0fee
api-designdeveloper-experiencesimplification

The best API is the one that gets out of your way. In Session 045, we made the most impactful API design decision in 0fee.dev's history: we reduced the required fields for creating a payment from many to three.

json{
  "amount": 5000,
  "source_currency": "XOF",
  "payment_reference": "ORDER-42"
}

That is it. Three fields. Amount, currency, reference. Everything else is optional, and the system makes intelligent defaults.

The Problem: Too Many Required Fields

The original payment creation endpoint required developers to specify everything upfront:

json{
  "amount": 5000,
  "source_currency": "XOF",
  "payment_reference": "ORDER-42",
  "payment_method": "mobile_money",
  "provider": "cinetpay",
  "provider_method_code": "OM",
  "customer_email": "[email protected]",
  "customer_phone": "+2250700000000",
  "customer_first_name": "Amadou",
  "customer_last_name": "Diallo",
  "success_url": "https://mysite.com/success",
  "cancel_url": "https://mysite.com/cancel",
  "webhook_url": "https://mysite.com/webhook"
}

This was a barrier to adoption. Developers had to:

  1. Know which payment methods were available.
  2. Know the provider and method code mapping.
  3. Collect customer details before initiating the payment.
  4. Set up webhook and redirect URLs before testing.

For a developer just exploring 0fee.dev, this wall of required fields meant 30 minutes of reading documentation before making their first API call.

The Solution: Smart Defaults and Hosted Checkout

The insight from Session 045 was: when payment_method is omitted, auto-create a checkout session and return a URL. Let the hosted checkout handle everything the developer did not specify.

pythonclass PaymentCreateRequest(BaseModel):
    # Required (3 fields)
    amount: Decimal
    source_currency: str
    payment_reference: str

    # Optional -- everything else
    payment_method: Optional[str] = None
    provider: Optional[str] = None
    customer_email: Optional[str] = None
    customer_phone: Optional[str] = None
    customer_first_name: Optional[str] = None
    customer_last_name: Optional[str] = None
    success_url: Optional[str] = None
    cancel_url: Optional[str] = None
    webhook_url: Optional[str] = None
    metadata: Optional[dict] = None

@router.post("/api/payments") async def create_payment( data: PaymentCreateRequest, app: App = Depends(get_current_app), db: AsyncSession = Depends(get_db), ): """Create a payment. If no payment_method, returns a checkout URL.""" BLANK if data.payment_method: # Direct API flow: charge immediately via specified method return await create_direct_payment(data, app, db) else: # Hosted checkout flow: create session, return URL return await create_checkout_payment(data, app, db) ```

The Checkout Flow Path

When payment_method is omitted, the API creates a checkout session and returns a URL:

pythonasync def create_checkout_payment(
    data: PaymentCreateRequest,
    app: App,
    db: AsyncSession,
) -> dict:
    """Create a checkout session for the payment."""
    # Generate invoice reference
    invoice_ref = await generate_smart_reference(
        payment_reference=data.payment_reference,
        app_slug=app.slug,
        db=db,
    )

    # Create pending transaction
    transaction = Transaction(
        app_id=app.id,
        user_id=app.user_id,
        source_amount=data.amount,
        source_currency=data.source_currency,
        payment_reference=data.payment_reference,
        invoice_reference=invoice_ref,
        status="pending",
        payment_data={
            "customer_email": data.customer_email,
            "customer_phone": data.customer_phone,
            "customer_first_name": data.customer_first_name,
            "customer_last_name": data.customer_last_name,
            "source": "api_checkout",
        },
        metadata=data.metadata,
    )

    db.add(transaction)
    await db.commit()
    await db.refresh(transaction)

    # Create checkout session
    checkout_url = (
        f"https://pay.0fee.dev/checkout/{transaction.id}"
        f"?app={app.slug}"
    )

    return {
        "id": str(transaction.id),
        "status": "pending",
        "checkout_url": checkout_url,
        "invoice_reference": invoice_ref,
        "amount": float(data.amount),
        "currency": data.source_currency,
        "expires_at": (datetime.utcnow() + timedelta(hours=24)).isoformat(),
    }

The response includes a checkout_url that the developer redirects their customer to. The hosted checkout page handles:

  • Country detection
  • Payment method selection
  • Customer data collection
  • Provider routing
  • Payment processing
  • Success/failure redirects

The developer's integration reduces to:

typescript// Create payment
const response = await fetch('https://api.0fee.dev/v1/payments', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    amount: 5000,
    source_currency: 'XOF',
    payment_reference: 'ORDER-42',
  }),
});

const { checkout_url } = await response.json();

// Redirect customer to checkout
window.location.href = checkout_url;

Five lines of meaningful code. That is the entire integration.

The Direct API Path

When payment_method is provided, the API processes the payment directly without a checkout session:

pythonasync def create_direct_payment(
    data: PaymentCreateRequest,
    app: App,
    db: AsyncSession,
) -> dict:
    """Process payment directly via specified method."""
    # Resolve provider and method code
    if not data.provider:
        # Auto-select best provider for this method
        routing = await resolve_best_provider(
            method=data.payment_method,
            currency=data.source_currency,
            amount=data.amount,
        )
        provider = routing["provider"]
        method_code = routing["method_code"]
    else:
        provider = data.provider
        method_code = data.payment_method

    # Generate invoice reference
    invoice_ref = await generate_smart_reference(
        payment_reference=data.payment_reference,
        app_slug=app.slug,
        db=db,
    )

    # Create transaction
    transaction = Transaction(
        app_id=app.id,
        user_id=app.user_id,
        source_amount=data.amount,
        source_currency=data.source_currency,
        payment_reference=data.payment_reference,
        invoice_reference=invoice_ref,
        provider=provider,
        provider_method_code=method_code,
        status="processing",
        payment_data={
            "customer_email": data.customer_email,
            "customer_phone": data.customer_phone,
            "customer_first_name": data.customer_first_name,
            "customer_last_name": data.customer_last_name,
            "source": "api_direct",
        },
        metadata=data.metadata,
    )

    db.add(transaction)
    await db.commit()

    # Initiate payment with provider
    result = await initiate_provider_payment(transaction)

    return {
        "id": str(transaction.id),
        "status": result["status"],
        "provider": provider,
        "invoice_reference": invoice_ref,
        "provider_reference": result.get("provider_reference"),
        "redirect_url": result.get("redirect_url"),
    }

The Smart Invoice Reference Generator

Session 045 also introduced an intelligent reference generator. Instead of requiring merchants to create unique references, the system generates them from whatever the merchant provides:

pythonimport re
from datetime import datetime

async def generate_smart_reference(
    payment_reference: str,
    app_slug: str,
    db: AsyncSession,
) -> str:
    """Generate a structured invoice reference.

    Input:  "ORDER-42"
    Output: "ORD42-260327-myboutique-0001"

    Format: {SANITIZED_REF}-{YYMMDD}-{APP_SLUG}-{SEQUENCE}
    """
    # Step 1: Sanitize the merchant's reference
    # Remove special chars, keep alphanumeric, truncate
    sanitized = re.sub(r'[^a-zA-Z0-9]', '', payment_reference)
    sanitized = sanitized[:10].upper()

    if not sanitized:
        sanitized = "PAY"

    # Step 2: Date component
    date_str = datetime.utcnow().strftime("%y%m%d")

    # Step 3: App slug (sanitized)
    clean_slug = re.sub(r'[^a-z0-9]', '', app_slug.lower())[:12]

    # Step 4: Daily auto-incrementing sequence per app
    today = datetime.utcnow().date()
    count_result = await db.execute(
        select(func.count(Transaction.id)).where(
            Transaction.app_id == (
                select(App.id).where(App.slug == app_slug).scalar_subquery()
            ),
            func.date(Transaction.created_at) == today,
        )
    )
    sequence = (count_result.scalar() or 0) + 1

    return f"{sanitized}-{date_str}-{clean_slug}-{sequence:04d}"

Daily Auto-Incrementing Per App

The sequence number resets daily and is scoped per app. This means:

  • App A's first transaction today: ORD42-260327-boutique-0001
  • App A's second transaction today: INV99-260327-boutique-0002
  • App B's first transaction today: PAY-260327-saas-0001
  • App A's first transaction tomorrow: ORD43-260328-boutique-0001

This design ensures:

  1. Uniqueness. The combination of date + app + sequence is globally unique.
  2. Readability. An accountant can look at ORD42-260327-boutique-0003 and immediately know: order 42, March 27, 2026, boutique app, third transaction of the day.
  3. Sortability. References sort chronologically by default.

Before and After

Here is the integration difference in practice:

Before (Session 044 and earlier)

typescriptconst payment = await zerofee.payments.create({
  amount: 5000,
  source_currency: 'XOF',
  payment_reference: 'ORDER-42',
  payment_method: 'mobile_money',
  provider: 'cinetpay',
  provider_method_code: 'OM',
  customer_email: '[email protected]',
  customer_phone: '+2250700000000',
  customer_first_name: 'Amadou',
  customer_last_name: 'Diallo',
  success_url: 'https://mysite.com/success',
  cancel_url: 'https://mysite.com/cancel',
  webhook_url: 'https://mysite.com/webhook',
});

// Developer must handle the provider response,
// check for redirect URLs, manage state...

After (Session 045+)

typescriptconst payment = await zerofee.payments.create({
  amount: 5000,
  source_currency: 'XOF',
  payment_reference: 'ORDER-42',
});

// Redirect to hosted checkout
window.location.href = payment.checkout_url;

// Done. Webhook will notify you when paid.

The reduction is not just in lines of code. It is in cognitive load. The developer no longer needs to understand providers, method codes, or customer data requirements to start accepting payments.

API Response Comparison

Checkout Flow Response (payment_method omitted)

json{
  "id": "txn_a1b2c3d4e5f6",
  "status": "pending",
  "checkout_url": "https://pay.0fee.dev/checkout/txn_a1b2c3d4e5f6?app=myboutique",
  "invoice_reference": "ORDER42-260327-myboutique-0001",
  "amount": 5000,
  "currency": "XOF",
  "expires_at": "2026-03-28T14:30:00Z"
}

Direct Flow Response (payment_method provided)

json{
  "id": "txn_x7y8z9w0v1u2",
  "status": "processing",
  "provider": "cinetpay",
  "invoice_reference": "ORDER42-260327-myboutique-0002",
  "provider_reference": "cp_987654321",
  "redirect_url": "https://checkout.cinetpay.com/payment/987654321"
}

Both paths converge to the same transaction model. The merchant's webhook receives the same payload regardless of which path the payment took. This means switching from hosted checkout to direct API integration requires zero changes to the merchant's backend processing logic.

Edge Cases

Duplicate Payment References

Merchants sometimes reuse payment references (e.g., "ORDER-42" appears twice). The system handles this gracefully because the invoice reference is always unique (it includes the date and sequence). The payment reference is the merchant's identifier; the invoice reference is ours.

Zero Amount

A zero-amount payment is rejected at validation:

python@validator("amount")
def amount_must_be_positive(cls, v):
    if v <= 0:
        raise ValueError("Amount must be greater than zero")
    return v

Unsupported Currency

If the source_currency is not in our supported list, the API returns a clear error:

json{
  "error": "invalid_currency",
  "message": "Currency 'ABC' is not supported. See /api/currencies for supported currencies.",
  "supported_currencies_url": "https://api.0fee.dev/v1/currencies"
}

Impact

The 3-field API transformed 0fee.dev's adoption metrics. Time from API key to first successful payment dropped from the range of 30 minutes to about 5 minutes. The hosted checkout absorbed all the complexity that developers previously had to handle themselves: country detection, payment method selection, customer data collection, provider routing, and error handling.

More importantly, it made 0fee.dev accessible to developers who are not payment experts. A frontend developer who has never integrated a payment API can now do it with three fields and a redirect. That is the standard we set, and we have not looked back.


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