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:
| Audience | Primary use case |
|---|---|
| Backend developers | Create test payments, inspect transaction data, tail logs |
| Integration engineers | Forward webhooks to local development servers |
| DevOps teams | Health checks, configuration management, automated scripts |
Technology Stack
We chose four Python libraries, each selected for a specific strength:
| Library | Version | Purpose |
|---|---|---|
| Typer | 0.9+ | CLI framework built on Click, with type annotations and auto-generated help |
| Rich | 13+ | Beautiful terminal output -- tables, panels, progress spinners, syntax highlighting |
| httpx | 0.25+ | Modern async HTTP client with connection pooling and timeout handling |
| websockets | 12+ | 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 subcommandsFour 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 eventLet 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.jsonThe 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=timeoutTest 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 = jsonThe 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 component | Used for |
|---|---|
Table | Payment lists, configuration display, transaction details |
Panel | Status panels, error messages, success confirmations |
console.status() | Spinning animations during API calls |
Syntax | JSON response pretty-printing with syntax highlighting |
| Color markup | Status 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 --helpThe -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.