Back to 0fee
0fee

The CLI Tool: zerofee-cli

How we built zerofee-cli with Python, Typer, Rich, and httpx -- login, payments, webhook forwarding, log streaming. By Juste A. Gnimavo and Claude.

Thales & Claude | March 25, 2026 13 min 0fee
clideveloper-experiencepythontyperrich

Stripe has stripe-cli. Paddle has its developer tools. Every payment platform that takes developer experience seriously ships a command-line interface. For 0fee.dev, we built zerofee-cli -- a Python-based terminal tool that lets developers authenticate, create payments, forward webhooks to localhost, stream event logs, and manage configuration without ever opening a browser.

This article is a deep dive into the architecture, design decisions, and implementation details of zerofee-cli. We cover the project structure, each command group, the webhook forwarding system (WebSocket with polling fallback), and the Rich-powered terminal output that makes working with payment data a pleasure.

Why a CLI Matters for Payment Developers

Payment integration is inherently a terminal-centric workflow. Developers write server-side code. They run local servers. They need to test webhooks against localhost:3000. They want to inspect transaction statuses without switching to a browser dashboard. A well-built CLI collapses the feedback loop from "write code, deploy, check dashboard, read logs" to "write code, run command, see result."

For 0fee.dev specifically, the CLI serves three audiences:

AudiencePrimary use case
Backend developersCreate test payments, inspect transaction data, tail logs
Integration engineersForward webhooks to local development servers
DevOps teamsHealth checks, configuration management, automated scripts

Technology Stack

We chose four Python libraries, each selected for a specific strength:

LibraryVersionPurpose
Typer0.9+CLI framework built on Click, with type annotations and auto-generated help
Rich13+Beautiful terminal output -- tables, panels, progress spinners, syntax highlighting
httpx0.25+Modern async HTTP client with connection pooling and timeout handling
websockets12+WebSocket client for real-time webhook event streaming

Typer was chosen over Click directly because it provides the same power with significantly less boilerplate. Type annotations on function parameters automatically become CLI options and arguments, with validation and help text generated from the type hints.

Project Structure

cli/
├── pyproject.toml            # Package metadata, dependencies, entry point
└── zerofee_cli/
    ├── __init__.py            # Version string
    ├── config.py              # Configuration file management (~/.zerofee/)
    ├── api.py                 # HTTP client wrapper with auth and error handling
    └── main.py                # All CLI commands and subcommands

Four files. That is the entire CLI. The simplicity is intentional -- a CLI tool should be easy to maintain, easy to understand, and fast to install.

pyproject.toml

toml[project]
name = "zerofee-cli"
version = "1.0.0"
description = "0fee.dev CLI - Payment orchestration from the terminal"
requires-python = ">=3.9"
dependencies = [
    "typer[all]>=0.9.0",
    "rich>=13.0.0",
    "httpx>=0.25.0",
    "websockets>=12.0",
]

[project.scripts]
zerofee = "zerofee_cli.main:app"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

The [project.scripts] entry is what makes zerofee available as a system command after pip install. The entry point maps directly to the Typer app object in main.py.

Configuration Management

All CLI state lives in ~/.zerofee/config.json. The config module handles creation, reading, updating, and validation of this file.

python# cli/zerofee_cli/config.py
import json
from pathlib import Path
from typing import Any, Optional

CONFIG_DIR = Path.home() / ".zerofee"
CONFIG_FILE = CONFIG_DIR / "config.json"

DEFAULT_CONFIG = {
    "api_key": None,
    "environment": "sandbox",
    "api_url": "https://api.0fee.dev",
    "output_format": "table",
}

def ensure_config_dir() -> None: """Create ~/.zerofee/ if it does not exist.""" CONFIG_DIR.mkdir(parents=True, exist_ok=True) BLANK

def load_config() -> dict: """Load configuration from disk, merging with defaults.""" ensure_config_dir() if CONFIG_FILE.exists(): with open(CONFIG_FILE) as f: stored = json.load(f) return {DEFAULT_CONFIG, stored} return DEFAULT_CONFIG.copy() BLANK

def save_config(config: dict) -> None: """Write configuration to disk.""" ensure_config_dir() with open(CONFIG_FILE, "w") as f: json.dump(config, f, indent=2) BLANK

def get_api_key() -> Optional[str]: """Retrieve the stored API key, or None.""" return load_config().get("api_key") BLANK

def set_value(key: str, value: Any) -> None: """Update a single configuration value.""" config = load_config() config[key] = value save_config(config) ```

The design is deliberately simple. No encryption on the API key (it follows the same pattern as AWS CLI credentials and Stripe CLI), no complex schema validation, no migration system. The config file is human-readable JSON that developers can inspect and edit manually if needed.

Environment Detection

The CLI automatically detects whether an API key is a sandbox or live key based on the prefix:

pythondef detect_environment(api_key: str) -> str:
    """Detect sandbox vs live from key prefix."""
    if api_key.startswith("sk_test_"):
        return "sandbox"
    elif api_key.startswith("sk_live_"):
        return "live"
    return "unknown"

This is the same convention used by Stripe, making the behavior immediately familiar to any payment developer.

The API Client

The api.py module wraps httpx with authentication headers, base URL resolution, and structured error handling.

python# cli/zerofee_cli/api.py
import httpx
from .config import load_config

class APIError(Exception):
    def __init__(self, status_code: int, detail: str):
        self.status_code = status_code
        self.detail = detail
        super().__init__(f"HTTP {status_code}: {detail}")

class ZeroFeeAPI: def __init__(self): config = load_config() self.base_url = config["api_url"] self.api_key = config.get("api_key") self.client = httpx.Client( base_url=f"{self.base_url}/v1", headers={ "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", "User-Agent": "zerofee-cli/1.0.0", }, timeout=30.0, ) BLANK def get(self, path: str, params: dict = None) -> dict: response = self.client.get(path, params=params) return self._handle_response(response) BLANK def post(self, path: str, data: dict = None) -> dict: response = self.client.post(path, json=data) return self._handle_response(response) BLANK def _handle_response(self, response: httpx.Response) -> dict: if response.status_code >= 400: try: detail = response.json().get("detail", response.text) except Exception: detail = response.text raise APIError(response.status_code, detail) return response.json() ```

The User-Agent header is important -- it lets the 0fee.dev backend identify CLI traffic in analytics and rate limiting decisions.

Commands: The Full Inventory

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

Let us walk through each command group.

Authentication: login, logout, status

python@app.command()
def login(
    api_key: str = typer.Option(
        ..., prompt=True, hide_input=True,
        help="Your 0fee.dev API key"
    ),
):
    """Authenticate with your API key."""
    console.print("Authenticating...", end=" ")

    # Validate by calling the health endpoint
    try:
        client = httpx.Client(
            base_url="https://api.0fee.dev/v1",
            headers={"Authorization": f"Bearer {api_key}"},
        )
        response = client.get("/health")
        response.raise_for_status()
    except httpx.HTTPError:
        console.print("[red]Failed.[/red]")
        console.print("[red]Invalid API key or server unreachable.[/red]")
        raise typer.Exit(1)

    env = detect_environment(api_key)
    save_config({
        "api_key": api_key,
        "environment": env,
        "api_url": "https://api.0fee.dev",
    })

    console.print("[green]Done.[/green]")
    console.print(f"Environment: [cyan]{env}[/cyan]")
    console.print(f"Connected to: [cyan]api.0fee.dev[/cyan]")
    console.print(f"API version: [cyan]v1[/cyan]")
    console.print(
        f"\nCredentials saved to [dim]{CONFIG_FILE}[/dim]"
    )

The login flow validates the key immediately. There is no "save now, fail later" pattern. If the key is invalid or the server is unreachable, the developer knows within seconds.

bash$ zerofee login
Enter your API key: sk_test_abc123...

Authenticating... Done.
Environment: sandbox
Connected to: api.0fee.dev
API version: v1

Credentials saved to ~/.zerofee/config.json

The logout command is equally straightforward -- it removes the API key from the config file and confirms the action. The status command calls the health endpoint and displays connection details, latency, and the current environment.

Payments: create, list, get, cancel

The payments subgroup uses Typer's subcommand pattern:

pythonpayments_app = typer.Typer(help="Payment operations")
app.add_typer(payments_app, name="payments")

@payments_app.command("create")
def payments_create(
    amount: int = typer.Option(..., help="Amount in smallest currency unit"),
    method: str = typer.Option(None, help="Payment method code"),
    phone: str = typer.Option(None, help="Customer phone number"),
    email: str = typer.Option(None, help="Customer email"),
    currency: str = typer.Option("XOF", help="Source currency"),
    reference: str = typer.Option(None, help="Payment reference"),
    provider: str = typer.Option(None, help="Force specific provider"),
    poll: bool = typer.Option(True, help="Poll for status updates"),
):
    """Create a payment."""
    api = ZeroFeeAPI()

    payload = {
        "amount": amount,
        "sourceCurrency": currency,
    }
    if method:
        payload["paymentMethod"] = method
    if phone:
        payload["customer"] = {"phone": phone}
    elif email:
        payload["customer"] = {"email": email}
    if reference:
        payload["paymentReference"] = reference
    if provider:
        payload["provider"] = provider

    with console.status("Creating payment..."):
        result = api.post("/payments", payload)

    # Display result as Rich table
    table = Table(title="Payment Created", show_header=True)
    table.add_column("Field", style="bold")
    table.add_column("Value")
    table.add_row("ID", result["id"])
    table.add_row("Status", result["status"])
    table.add_row("Amount", f"{result['amount']:,} {result['currency']}")
    table.add_row("Provider", result.get("provider", "auto"))
    table.add_row("Payment Method", result.get("paymentMethod", "N/A"))
    table.add_row("Created", result["createdAt"])
    console.print(table)

    if poll and result["status"] == "pending":
        poll_status(api, result["id"])

The terminal output uses Rich tables for structured data:

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           |
| 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 list command renders a compact table of recent transactions:

bash$ zerofee payments list --limit 5

Recent Payments
+---------------------------+-----------+------------+---------------+-----+
| ID                        | Status    | Amount     | Method        | Age |
+---------------------------+-----------+------------+---------------+-----+
| txn_749b8b1afbd846eaba95  | completed | 5,000 XOF  | PAYIN_ORANGE  | 2m  |
| txn_663a2c8fd1b94c27a8b2  | failed    | 10,000 XOF | PAYIN_MTN_CI  | 15m |
| txn_1a2b3c4d5e6f78901234  | pending   | 25,000 XOF | PAYIN_MOOV_CI | 22m |
| txn_aabb11cc22dd33ee44ff  | completed | 1,000 XOF  | PAYIN_WAVE_CI | 1h  |
| txn_9988776655443322aabb  | cancelled | 50,000 XOF | PAYIN_ORANGE  | 3h  |
+---------------------------+-----------+------------+---------------+-----+

Webhook Forwarding: listen

The listen command is the most technically interesting feature. It connects the developer's local machine to the 0fee.dev event stream, forwarding every webhook event to a local URL. This is essential for testing webhook handlers without deploying to a public server.

python@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("Listening for webhook events on your 0fee.dev account...")
    console.print(f"Ready! Forwarding to [cyan]{forward_to}[/cyan]\n")

    try:
        asyncio.run(listen_websocket(api_key, forward_to))
    except Exception:
        console.print("[yellow]WebSocket unavailable, using polling...[/yellow]")
        asyncio.run(listen_polling(api_key, forward_to))

The dual-strategy approach -- WebSocket primary, polling fallback -- ensures the CLI works in every network environment:

pythonasync def listen_websocket(api_key: str, forward_to: str):
    """Real-time event streaming via WebSocket."""
    import websockets

    uri = f"wss://api.0fee.dev/v1/events/ws?api_key={api_key}"
    async with websockets.connect(uri) as ws:
        async for message in ws:
            event = json.loads(message)
            await forward_event(event, forward_to)

async def listen_polling(api_key: str, forward_to: str): """Fallback: poll the events endpoint every 2 seconds.""" last_seen = None async with httpx.AsyncClient() as client: while True: response = await client.get( "https://api.0fee.dev/v1/events", headers={"Authorization": f"Bearer {api_key}"}, params={"after": last_seen} if last_seen else {}, ) events = response.json().get("data", []) for event in events: await forward_event(event, forward_to) last_seen = event["id"] await asyncio.sleep(2) ```

Each forwarded event includes the webhook signature header, allowing the local server to test signature verification:

pythonasync def forward_event(event: dict, forward_to: str):
    """Forward a single 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)
            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]")

The terminal output during a listen session:

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)
[14:37:22] payment.refunded       txn_882c... -> 500 Internal Server Error (12ms)

That last line -- a 500 error from the local server -- is exactly the kind of feedback that saves hours of debugging. The developer sees instantly that their refund handler is broken.

Log Streaming: logs tail

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

Test Webhook Trigger: trigger

The trigger command fires synthetic webhook events against the developer's configured webhook URL. This is useful for testing specific event types without creating real transactions:

bash$ zerofee trigger payment.completed

Triggered payment.completed event.
Event ID: evt_test_8a7b6c5d4e3f
Delivered to: https://example.com/webhooks (200 OK, 45ms)

Configuration: config show, config set

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

The config show command masks the API key, displaying only the last three characters. This prevents accidental exposure in screen recordings or shared terminal sessions.

Rich: Making Terminal Output Beautiful

The Rich library is what elevates zerofee-cli from functional to delightful. Every command uses Rich primitives:

Rich componentUsed for
TablePayment lists, configuration display, transaction details
PanelStatus panels, error messages, success confirmations
console.status()Spinning animations during API calls
SyntaxJSON response pretty-printing with syntax highlighting
Color markupStatus indicators ([green], [red], [yellow])

The result is a CLI that feels polished and professional -- comparable to the Stripe CLI that inspired it, but built in a fraction of the time.

Installation

bash# From the repository
cd cli
pip install -e .

# Verify installation
zerofee --help

The -e (editable) flag during development means changes to the source files take effect immediately without reinstalling.

Lessons Learned

Typer eliminates boilerplate. With Click, defining a command with five options requires verbose decorator chains. With Typer, you write a normal Python function with type-annotated parameters, and the framework generates the CLI interface automatically.

WebSocket with polling fallback is the right pattern. Corporate firewalls, restrictive proxies, and spotty connections in many African network environments mean WebSocket is not always available. The automatic fallback to HTTP polling ensures the CLI works everywhere.

Rich tables beat JSON dumps. Early versions output raw JSON. Switching to Rich tables for structured data made the CLI immediately more readable and reduced the cognitive load of scanning payment details.

Four files is enough. The temptation with any developer tool is to over-engineer the architecture. Configuration, API client, and commands -- three concerns, plus an init file. No plugin system, no middleware chain, no abstract base classes.


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