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:
- Know which payment methods were available.
- Know the provider and method code mapping.
- Collect customer details before initiating the payment.
- 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:
- Uniqueness. The combination of date + app + sequence is globally unique.
- Readability. An accountant can look at
ORD42-260327-boutique-0003and immediately know: order 42, March 27, 2026, boutique app, third transaction of the day. - 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 vUnsupported 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.