Session 004, still December 10, 2025. The fourth session of the day. By this point, a working payment orchestration platform existed: backend, dashboard, checkout widget, 7 SDKs, marketing website, Docker stack. What was missing was developer tooling -- the utilities that turn a usable API into a delightful developer experience. Session 004 delivered three: a command-line tool inspired by Stripe CLI, a hosted checkout page with multi-language support, and 5 comprehensive documentation files.
The zerofee-cli
Every serious payment platform has a CLI. Stripe has stripe-cli. PayPal has its developer tools. 0fee.dev needed zerofee-cli -- a terminal tool that lets developers test payments, forward webhooks to localhost, stream event logs, and manage configuration without leaving their terminal.
Technology Stack
The CLI was built with three Python libraries chosen for maximum developer experience:
| Library | Purpose |
|---|---|
| Typer | Modern CLI framework (Click-based, with type annotations) |
| Rich | Beautiful terminal output (tables, progress bars, panels) |
| httpx | Async HTTP client for API calls |
Commands
zerofee --help
Usage: zerofee [OPTIONS] COMMAND [ARGS]...
0fee.dev CLI - Payment orchestration from the terminal.
Commands:
login Authenticate with your API key
logout Clear stored credentials
status Check API connection status
listen Forward webhooks to your local server
payments create Create a test payment
payments list List recent payments
payments get Get payment details by ID
payments cancel Cancel a pending payment
logs tail Stream real-time event logs
config show Show current configuration
config set Update a configuration value
trigger Trigger a test webhook eventLogin Flow
bash$ zerofee login
Enter your API key: sk_test_abc123...
Authenticating... Done.
Environment: sandbox
Connected to: api.0fee.dev
API version: v1
Your credentials have been saved to ~/.zerofee/config.jsonThe login command validates the API key against the health endpoint, stores it in ~/.zerofee/config.json, and displays the resolved environment.
Creating Test Payments
bash$ zerofee payments create \
--amount 5000 \
--method PAYIN_ORANGE_CI \
--phone +2250709757296
Creating payment...
Payment Created
+------------------+---------------------------+
| Field | Value |
+------------------+---------------------------+
| ID | txn_749b8b1afbd846eaba95 |
| Status | pending |
| Amount | 5,000 XOF |
| Provider | paiementpro |
| Payment Method | PAYIN_ORANGE_CI |
| Customer Phone | +2250709757296 |
| Created | 2025-12-10 14:32:08 UTC |
+------------------+---------------------------+
Waiting for status update... (Ctrl+C to stop)
[14:32:10] Status: pending
[14:32:15] Status: pending
[14:32:20] Status: completed
Payment completed successfully.The payments create command initiates a payment and optionally polls for status updates, displaying each change in real time with Rich formatting.
Webhook Forwarding
The most powerful CLI feature is webhook forwarding -- routing provider webhooks from the 0fee.dev server to a local development server:
bash$ zerofee listen --forward-to http://localhost:3000/webhooks
Listening for webhook events on your 0fee.dev account...
Ready! Forwarding to http://localhost:3000/webhooks
[14:35:02] payment.created txn_749b... -> 200 OK (42ms)
[14:35:08] payment.completed txn_749b... -> 200 OK (38ms)
[14:36:15] checkout.completed cs_abc1... -> 200 OK (51ms)The implementation uses two strategies:
- WebSocket connection (primary) -- Connects to the 0fee.dev WebSocket endpoint and receives events in real time.
- Long polling (fallback) -- If WebSocket connection fails, falls back to polling the events endpoint every 2 seconds.
python# cli/zerofee_cli/main.py (simplified)
import typer
import httpx
from rich.console import Console
from rich.table import Table
app = typer.Typer(help="0fee.dev CLI")
console = Console()
@app.command()
def listen(
forward_to: str = typer.Option(
"http://localhost:3000/webhooks",
"--forward-to", "-f",
help="Local URL to forward webhooks to"
)
):
"""Forward webhooks to your local development server."""
config = load_config()
api_key = config.get("api_key")
if not api_key:
console.print("[red]Not authenticated. Run 'zerofee login' first.[/red]")
raise typer.Exit(1)
console.print(f"Listening for webhook events...")
console.print(f"Ready! Forwarding to [cyan]{forward_to}[/cyan]\n")
try:
# Try WebSocket first
asyncio.run(listen_websocket(api_key, forward_to))
except Exception:
# Fall back to polling
console.print("[yellow]WebSocket unavailable, using polling...[/yellow]")
asyncio.run(listen_polling(api_key, forward_to))
async def listen_websocket(api_key: str, forward_to: str): import websockets BLANK uri = f"wss://api.0fee.dev/v1/events/ws?api_key={api_key}" BLANK async with websockets.connect(uri) as ws: async for message in ws: event = json.loads(message) await forward_event(event, forward_to) BLANK
async def forward_event(event: dict, forward_to: str): """Forward a webhook event to the local server.""" async with httpx.AsyncClient() as client: start = time.time() try: response = await client.post( forward_to, json=event["data"], headers={ "Content-Type": "application/json", "X-ZeroFee-Signature": event.get("signature", ""), "X-ZeroFee-Event": event["type"], }, timeout=30, ) duration_ms = int((time.time() - start) * 1000) BLANK status_color = "green" if response.status_code < 300 else "red" console.print( f"[dim]{time.strftime('%H:%M:%S')}[/dim] " f"{event['type']:<25} " f"{event.get('reference', '')[:10]}... -> " f"[{status_color}]{response.status_code} " f"{response.reason_phrase}[/{status_color}] " f"({duration_ms}ms)" ) except Exception as e: console.print( f"[red]Failed to forward: {e}[/red]" ) ```
Event Log Streaming
bash$ zerofee logs tail --limit 20
Streaming events (most recent first)...
[14:36:15] checkout.completed cs_abc123 amount=10000 XOF
[14:35:08] payment.completed txn_749b8b provider=paiementpro
[14:35:02] payment.created txn_749b8b method=PAYIN_ORANGE_CI
[14:30:45] payment.failed txn_663a2c error=timeoutConfiguration Management
bash$ zerofee config show
Configuration (~/.zerofee/config.json)
+------------------+---------------------------+
| Key | Value |
+------------------+---------------------------+
| api_key | sk_test_****abc |
| environment | sandbox |
| api_url | https://api.0fee.dev |
| output_format | table |
+------------------+---------------------------+
$ zerofee config set output_format json
Updated output_format = jsonProject Structure
cli/
├── pyproject.toml # Package config, entry point
├── README.md # CLI documentation
└── zerofee_cli/
├── __init__.py
├── config.py # Config file management
├── api.py # HTTP client wrapper
└── main.py # All CLI commandsThe pyproject.toml defines the zerofee entry point, so after pip install -e . the command is available system-wide.
Hosted Checkout Page
Not every merchant wants to embed a JavaScript widget. Some prefer a redirect flow: create a checkout session via the API, redirect the customer to a 0fee.dev-hosted page, and receive a webhook when the payment completes. The hosted checkout page handles this flow.
Features
The hosted checkout page supports:
- Multi-language: English and French, detected from the browser's
Accept-Languageheader or overridden by query parameter. - Dark/light mode: Respects system preference, toggleable by the customer.
- Multi-step flow: Payment method selection, phone number input, OTP verification (if required), processing, and confirmation.
- Responsive design: Full functionality on mobile devices.
Implementation
The hosted checkout uses Jinja2 server-side templates rendered by FastAPI:
python# backend/routes/checkout_hosted.py
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
router = APIRouter()
templates = Jinja2Templates(directory="backend/templates")
@router.get("/v1/checkout/pay/{session_id}", response_class=HTMLResponse)
async def render_checkout(request: Request, session_id: str):
"""Render the hosted checkout page."""
session = await get_checkout_session(session_id)
if not session:
return templates.TemplateResponse(
"checkout_error.html",
{"request": request, "error": "Session not found"},
status_code=404
)
if session["status"] in ("completed", "expired"):
return templates.TemplateResponse(
"checkout_error.html",
{
"request": request,
"error": f"This checkout session has {session['status']}."
}
)
# Detect language from Accept-Language header
accept_lang = request.headers.get("accept-language", "en")
lang = "fr" if "fr" in accept_lang else "en"
# Get payment methods for the session's country
methods = await get_payment_methods_for_country(
session.get("country", "CI")
)
return templates.TemplateResponse(
"checkout.html",
{
"request": request,
"session": session,
"methods": methods,
"lang": lang,
}
)
@router.post("/v1/checkout/{session_id}/pay")
async def process_checkout_payment(session_id: str, data: dict):
"""Process a payment from the hosted checkout page."""
session = await get_checkout_session(session_id)
payment = await initiate_payment(
app_id=session["app_id"],
amount=session["amount"],
currency=session["currency"],
payment_method=data["payment_method"],
customer=data["customer"],
)
return {"payment_id": payment["id"], "status": payment["status"]}
@router.get("/v1/checkout/{session_id}/status")
async def poll_checkout_status(session_id: str):
"""Poll for payment status (used by the checkout page JS)."""
session = await get_checkout_session(session_id)
return {"status": session["status"]}The Checkout Template
The HTML template is a self-contained single page with embedded CSS and JavaScript. No external dependencies beyond the fonts. The multi-step flow is handled entirely client-side:
html<!-- Simplified structure of checkout.html -->
<!DOCTYPE html>
<html lang="{{ lang }}" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ i18n.checkout_title }} - 0fee.dev</title>
<style>
/* Embedded Tailwind-like utility CSS + custom checkout styles */
/* Dark mode support via data-theme attribute */
</style>
</head>
<body>
<div id="checkout-container">
<!-- Step 1: Payment Method Selection -->
<div id="step-methods" class="checkout-step active">
<h2>{{ i18n.select_method }}</h2>
<div class="method-grid">
{% for method in methods %}
<button class="method-card" data-method="{{ method.code }}">
<span class="method-name">{{ method.name }}</span>
</button>
{% endfor %}
</div>
</div>
<!-- Step 2: Phone Number -->
<div id="step-phone" class="checkout-step">
<h2>{{ i18n.enter_phone }}</h2>
<input type="tel" id="phone-input"
placeholder="{{ i18n.phone_placeholder }}" />
<button onclick="submitPhone()">{{ i18n.continue }}</button>
</div>
<!-- Step 3: OTP (if required) -->
<div id="step-otp" class="checkout-step">
<h2>{{ i18n.enter_otp }}</h2>
<input type="text" id="otp-input" maxlength="6" />
<button onclick="submitOtp()">{{ i18n.verify }}</button>
</div>
<!-- Step 4: Processing -->
<div id="step-processing" class="checkout-step">
<div class="spinner"></div>
<p>{{ i18n.processing }}</p>
</div>
<!-- Step 5: Result -->
<div id="step-result" class="checkout-step"></div>
</div>
<script>
const SESSION_ID = "{{ session.id }}";
const API_URL = "/v1/checkout";
// Multi-step flow logic
// Payment polling
// OTP handling
// ... (embedded JavaScript)
</script>
</body>
</html>The multi-language support uses a simple translation dictionary:
pythonTRANSLATIONS = {
"en": {
"checkout_title": "Checkout",
"select_method": "Select payment method",
"enter_phone": "Enter your phone number",
"phone_placeholder": "+225 07 09 75 72 96",
"enter_otp": "Enter the code sent to your phone",
"continue": "Continue",
"verify": "Verify",
"processing": "Processing your payment...",
"success": "Payment successful!",
"failed": "Payment failed. Please try again.",
},
"fr": {
"checkout_title": "Paiement",
"select_method": "Choisissez votre moyen de paiement",
"enter_phone": "Entrez votre numero de telephone",
"phone_placeholder": "+225 07 09 75 72 96",
"enter_otp": "Entrez le code recu par SMS",
"continue": "Continuer",
"verify": "Verifier",
"processing": "Traitement de votre paiement...",
"success": "Paiement reussi !",
"failed": "Le paiement a echoue. Veuillez reessayer.",
},
}API Documentation
Five documentation files covering the complete API surface:
1. docs/api-reference.md
Complete API reference with every endpoint, request/response schemas, authentication methods, error codes, rate limits, and idempotency handling. This is the primary reference for developers building direct API integrations.
2. docs/integration-guide.md
Step-by-step integration guide covering four paths:
- Hosted checkout -- Redirect flow with session creation
- Checkout.js widget -- Embedded widget integration
- Direct API -- Server-to-server payment initiation
- Mobile money flows -- Handling OTP, USSD push, and redirect flows
3. docs/webhook-guide.md
Webhook implementation guide with signature verification examples in four languages:
python# Python verification example
import hmac
import hashlib
def verify_webhook(payload: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(f"sha256={expected}", signature)javascript// Node.js verification example
const crypto = require('crypto');
function verifyWebhook(payload, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(`sha256=${expected}`),
Buffer.from(signature)
);
}4. docs/sdk-reference.md
SDK documentation for all 7 languages: TypeScript, Python, Go, Ruby, PHP, Java, C#. Each section covers installation, initialization, payment creation, status checking, and webhook verification.
5. docs/checkout-widget.md
Checkout.js widget documentation with configuration options, event handlers, theming, and framework-specific integration examples for React and Vue.
The First Four Sessions: A Complete Platform
After four sessions on December 10, 2025, 0fee.dev had:
| Component | Status |
|---|---|
| FastAPI Backend | 42 files, 30+ endpoints, 15+ database tables |
| Payment Providers | 7 (Test, Stripe, PayPal, Hub2, PawaPay, BUI, PaiementPro) |
| SolidJS Dashboard | Login, Dashboard, Apps, Transactions, Settings |
| Checkout Widget | Embeddable JS with multi-step flow |
| Celery Tasks | Webhook retry, reconciliation, settlement |
| TypeScript SDK | Published-ready npm package |
| Python SDK | Published-ready pip package |
| Go SDK | Published-ready Go module |
| Ruby SDK | Published-ready gem |
| PHP SDK | Published-ready Composer package |
| Java SDK | Published-ready Gradle package |
| C# SDK | Published-ready NuGet package |
| CLI Tool | Login, payments, webhooks, logs, config |
| Marketing Website | 9 components, 8 pages |
| Hosted Checkout | Multi-language, dark mode, multi-step |
| Docker Stack | 7 services, production-ready |
| API Documentation | 5 comprehensive files |
Four sessions. One day. From zero to a complete payment orchestration platform. The sessions that followed over the next 80 days would refine, polish, debug, and extend this foundation -- but the core was built in a single afternoon in Abidjan.
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.