Back to 0fee
0fee

Session 4: CLI Tool, Hosted Checkout, and API Documentation

How we built the zerofee-cli, hosted checkout pages, and 5 API documentation files in one session. Session 004 deep dive. By Juste A. Gnimavo and Claude.

Thales & Claude | March 25, 2026 10 min 0fee
session-004clicheckoutdocumentationdeveloper-experience

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:

LibraryPurpose
TyperModern CLI framework (Click-based, with type annotations)
RichBeautiful terminal output (tables, progress bars, panels)
httpxAsync 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 event

Login 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.json

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

  1. WebSocket connection (primary) -- Connects to the 0fee.dev WebSocket endpoint and receives events in real time.
  2. 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=timeout

Configuration 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 = json

Project 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 commands

The 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-Language header 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:

ComponentStatus
FastAPI Backend42 files, 30+ endpoints, 15+ database tables
Payment Providers7 (Test, Stripe, PayPal, Hub2, PawaPay, BUI, PaiementPro)
SolidJS DashboardLogin, Dashboard, Apps, Transactions, Settings
Checkout WidgetEmbeddable JS with multi-step flow
Celery TasksWebhook retry, reconciliation, settlement
TypeScript SDKPublished-ready npm package
Python SDKPublished-ready pip package
Go SDKPublished-ready Go module
Ruby SDKPublished-ready gem
PHP SDKPublished-ready Composer package
Java SDKPublished-ready Gradle package
C# SDKPublished-ready NuGet package
CLI ToolLogin, payments, webhooks, logs, config
Marketing Website9 components, 8 pages
Hosted CheckoutMulti-language, dark mode, multi-step
Docker Stack7 services, production-ready
API Documentation5 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.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles