# Shipping the Dodo Payments CLI: A Terminal-First Workflow for Payments

> We rebuilt the Dodo Payments CLI from the ground up - an interactive TUI, a local AI assistant over MCP, encrypted credentials, and offline webhook testing. Here's why we built it, and how it works.
- **Author**: Ayush Agarwal
- **Published**: 2026-05-19
- **Category**: Open Source
- **URL**: https://dodopayments.com/blogs/dodo-payments-cli-release

---

It started with a Slack message from one of our developer-experience engineers.

"I just spent 40 minutes clicking through the dashboard to reproduce a failed webhook for a merchant. There has to be a better way."

There was. We had built one years ago, sort of. A thin wrapper around our SDK with a handful of subcommands, mostly used for internal smoke tests. It worked. Until it didn't. The merchants we serve are building payment integrations from terminals - Cursor, Claude Code, Zed, tmux sessions, SSH'd into staging boxes - and our existing CLI couldn't keep up. Every meaningful workflow forced them back to the dashboard.

Today we're shipping the rewritten Dodo Payments CLI: an interactive TUI, an AI assistant that runs locally over MCP, encrypted credentials at rest, and offline webhook testing that works even when you're logged out. This post is the story of why we rebuilt it and the decisions we made along the way.

Here's a quick look at it in action:

<div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden; margin: 2rem 0; border-radius: 0.5rem;">
  <iframe
    src="https://www.youtube.com/embed/gwtvQsANbW4"
    title="Dodo Payments CLI demo"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen
    style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
  ></iframe>
</div>

## The Problem

Our previous CLI was a stateless `commander.js`-style tool. Run `dodo payments list`, get JSON back, parse it yourself. It was fine for scripts. It was painful for everything else.

We watched merchants hit the same friction points over and over:

### Problem #1: Webhook Testing Required Production

To test a webhook handler, you had to trigger a real event. Real events meant real test-mode charges, real subscription lifecycle transitions, real dispute flows in a sandbox. For most events that was tolerable. For others - `dispute.lost`, `subscription.on_hold`, `subscription.failed` - the only way to reproduce them was to wait for a test scenario to age into the right state, or to ask us on Discord to fire one manually.

The result: webhook handlers shipped to production untested for the failure paths that mattered most.

### Problem #2: The Dashboard Round-Trip Tax

A merchant building a new integration would constantly switch between their editor and the dashboard. Find a product ID. Copy it. Paste it into code. Run the code. Check what the API actually returned. Open the dashboard again to verify. Back to the editor.

We measured this in a session with one design partner. In a 35-minute integration session, they alt-tabbed to the dashboard 47 times.

### Problem #3: AI Agents Couldn't See Their Own Data

The MCP servers we'd already shipped (we wrote about [Code Mode](https://dodopayments.com/engineering/mcp-server-code-mode-upgrade) earlier this year) let agents in Cursor and Claude Code execute against the SDK. But the moment a developer wanted to ask a question from a plain terminal - "how much revenue did I make this week?", "find my last failed payment" - they were back to the dashboard, or writing throwaway scripts.

The AI was already there. We just hadn't given it a front door from the shell.

### Problem #4: Credentials Lived in Plaintext

The old CLI stored API keys in `~/.dodopayments/api-key` as a plain JSON file. We told ourselves filesystem permissions were enough. They weren't. Plaintext credentials on disk is the kind of thing that's fine until a merchant's laptop ends up in someone else's hands - and then it's an incident.

## The Turning Point

The Slack message wasn't really the turning point. The turning point was the realization that all four problems pointed at the same root cause: **we'd built a CLI as a thin RPC layer over the API, when what merchants actually needed was a workspace**.

A workspace has state. It remembers your active environment. It can hold credentials, history, in-flight conversations with the AI, an open webhook listener. It can be both interactive when you want to explore, and scriptable when you want to automate. It can run offline for the things that don't require the network.

We needed a fundamentally different approach.

## The Mental Shift

The old CLI:

```
You ── (one command) ──► CLI ── (one request) ──► API
                          │
                          ▼
                       JSON out
                       Process exits
```

Every invocation was atomic. No state. No memory. No room for anything richer than "run this, print that."

The new CLI:

```
                ┌────────────────────────────────────┐
                │            dodo (TUI)              │
                │                                    │
You ◄──── live ─┤  ┌──────────┐  ┌──────────┐        │
                │  │ Command  │  │   AI     │        │
                │  │ Palette  │  │ Assistant│        │
                │  └──────────┘  └──────────┘        │
                │                                    │
                │  ┌──────────┐  ┌──────────┐        │
                │  │ Webhook  │  │  Auth    │        │
                │  │ Listener │  │  (AES)   │        │
                │  └──────────┘  └──────────┘        │
                └─────────┬────────────────┬─────────┘
                          │                │
                          ▼                ▼
                    Dodo Payments API    Local sandbox
                                        (MCP server)
```

The CLI is a persistent process. State lives inside it. Everything the merchant does in a session - choosing an environment, asking the AI a question, listening for webhooks, scrolling through history - happens inside one running program. Subcommands still work for scripts and CI, but they're the special case now, not the default.

That mental shift drove every implementation decision that follows.

## The Architecture

### Rendering: Why OpenTUI + Solid

We initially prototyped with Ink (React for the terminal). It worked for the first few screens, then started to wobble. Ink's reconciler runs the full virtual DOM diff on every state change, and once we added a live webhook log, a streaming AI response, and a command palette with fuzzy ranking, render latency was visible. Keystrokes felt sticky.

We rewrote on [OpenTUI](https://github.com/sst/opentui) with Solid's fine-grained reactivity. Solid doesn't have a virtual DOM. Signals push updates directly to the cells that depend on them. The same screens that lagged under Ink render at our 30 FPS target without breaking a sweat.

```
Render Cost: Ink vs OpenTUI + Solid (live webhook log, 50 events/sec)
══════════════════════════════════════════════════════════════════════

Ink (React reconciler):
  Diff + render:    ████████████████████████  ~22ms per event
  Perceived input:  laggy, dropped keystrokes

OpenTUI + Solid (signals):
  Targeted update:  ██                        ~1.8ms per event
  Perceived input:  immediate
```

Solid's mental model fits a terminal cleanly. Every visible block - spinner, table, markdown, error - is a signal. When the signal updates, only that block re-renders. Nothing else moves.

### Credentials: Encrypted at Rest, Machine-Bound

Plaintext credentials on disk were non-negotiable. Here's what we landed on:

```
┌──────────────────────────────────────────────────────────────────┐
│  machine ID (node-machine-id)                                    │
│  └─► PBKDF2-SHA256, 100k iterations, salted                      │
│      └─► 256-bit key (never written to disk)                     │
│          └─► AES-256-GCM encrypt({ test_mode, live_mode })       │
│              └─► ~/.dodopayments/config.json (0600)              │
└──────────────────────────────────────────────────────────────────┘
```

A few specific choices worth calling out:

- **Machine-derived key, not user-provided.** We considered a passphrase. We rejected it. A passphrase the user types every session is a passphrase the user writes on a sticky note. We bind the key to the machine instead, derived from `machineIdSync()`. If the disk is exfiltrated without the host, the credentials are useless.
- **AES-GCM, not AES-CBC.** GCM gives us authenticated encryption out of the box. If the ciphertext on disk has been tampered with, decryption fails loudly rather than returning corrupted bytes.
- **File mode 0600, enforced after every write.** `chmod` runs after `writeFileSync` because on some systems the existing file's mode wins over the new file's mode. We belt-and-brace it.
- **Test mode and live mode are independent slots.** A single config can hold both. Switching environments doesn't require re-authenticating, and a `dodo logout test` doesn't touch your live key.

There's also a quiet migration path. The old plaintext `~/.dodopayments/api-key` file is detected, read, re-encrypted to `config.json`, and the original is removed - all on the first run of the new CLI. Merchants don't see it happen. They just get the security upgrade.

### The AI Assistant: Two MCPs, One Sandbox

We didn't want to build a chat UI. We wanted to build a shell that happened to understand English.

In the new TUI, anything you type that isn't a slash command goes straight to the AI assistant. No prefix needed. `how much revenue did I make this week?` is a valid command.

Under the hood, every AI invocation spins up two MCP clients side-by-side:

```
Your question ──► AI assistant
                     │
                     ├──► Knowledge MCP (remote)
                     │    └─► semantic search over Dodo Payments docs
                     │        No API key required
                     │
                     └──► Execution MCP (local subprocess)
                          └─► dodopayments-mcp via stdio
                              ├─► docs_search (SDK reference)
                              └─► execute    (sandboxed TypeScript
                                              with an authenticated
                                              SDK client)
```

The split matters. The knowledge MCP answers "how does this work?" without ever needing your credentials. The execution MCP answers "what is happening in my account?" using a TypeScript sandbox with an SDK client that's already authenticated to your active environment. The credentials are injected into the subprocess environment - they never enter the model's context, never appear in tool parameters, never show up in logs.

If two tools across the two servers collide on a name, we prefix the knowledge server's version with `knowledge_` to keep them unambiguous. Small detail, but the kind of thing that silently breaks tool selection if you don't handle it.

And because all of this runs locally, the merchant's AI traffic stays on their machine except for the LLM call itself. The execution sandbox is local. The data is local. The API calls go directly from the user's box to our API - not via any intermediate AI provider.

### Multi-Step Loop with Real Timeouts

The model gets up to 20 tool-call iterations per question, with a 60-second timeout per LLM step and a 60-second timeout for MCP subprocess initialization. The numbers are deliberate:

- **20 steps.** Enough for genuinely complex questions ("reconcile last week's payouts against the refunds I issued") without giving the model infinite room to wander.
- **60s per step.** Long enough for a slow LLM response or a chain of API calls. Short enough that a hung MCP subprocess gets killed before the merchant assumes the CLI is broken.
- **Per-phase error classification.** Auth failures, MCP startup failures, network errors, rate limits, and model errors all get distinct user-facing messages with the failing phase named. "Couldn't reach the assistant. Check your network and try again. (MCP Knowledge Init: DNS lookup failed)" is more useful than "Error: ENOTFOUND."

### Offline Webhook Testing

This is the feature we're most excited about, and the one with the most engineering nuance.

`dodo wh trigger payment.success http://localhost:3000/webhook` generates a realistic webhook payload locally and POSTs it to your handler. No network call to our API. No login required. You can be on a plane.

```
                ┌─────────────────────────────────────┐
                │  dodo wh trigger <event> <url>      │
                └───────────────┬─────────────────────┘
                                │
                                ▼
                ┌─────────────────────────────────────┐
                │  Local event-generator (22 events)  │
                │  ─ subscription.{active, on_hold,    │
                │     renewed, plan_changed, ...}     │
                │  ─ payment.{success, failed, ...}   │
                │  ─ refund.{success, failed}         │
                │  ─ dispute.{opened, won, lost, ...} │
                │  ─ licence.created                  │
                └───────────────┬─────────────────────┘
                                │
                                ▼
                ┌─────────────────────────────────────┐
                │  POST <your endpoint>               │
                │  Content-Type: application/json     │
                │  body: realistic shape, fake IDs    │
                └─────────────────────────────────────┘
```

The interactive form lets you override the business ID, product ID, customer ID, customer email, and arbitrary JSON metadata - the fields that most often need to match your local fixtures. The defaults (`bus_test`, `pdt_test`, `cus_test`, `john.doe@example.com`) are deliberately obvious placeholders so you never confuse a triggered payload with a real one in your logs.

`/wh listen <url>`, on the other hand, is the inverse: it forwards live events from your account into your local handler over a WebSocket. The CLI registers a webhook endpoint pointing at our relay (`wsserver.dodopayments.tech`), opens a `wss://` connection, and proxies each inbound event to your local URL - capturing your handler's status code and body and reporting both back over the same socket. This is the path we use ourselves to debug merchant integrations in real time.

One important constraint we hit: `/wh listen` requires a test-mode API key. Live mode is intentionally blocked. A buggy local handler that ACKs a live event prematurely is a real way to lose money. We'd rather force merchants to opt into a separate path for live debugging.

## Results

What the rewrite actually got us, in concrete terms:

- **Webhook turnaround dropped from minutes to seconds.** The same merchant who was waiting on test data to age into a `subscription.on_hold` state can now trigger one offline in a single command. Internally, the most common debug session - "reproduce this merchant's webhook handler bug" - went from ~12 minutes to under 30 seconds.
- **Credentials are no longer plaintext on disk.** Zero plaintext API keys at rest across the install base after the silent migration. AES-256-GCM with a machine-bound key. File mode 0600.
- **Render latency under load dropped ~12x.** From ~22ms per event under Ink to ~1.8ms under OpenTUI + Solid for the live webhook log. Keystrokes feel immediate again.
- **The AI is always one keystroke away.** No `/ai` prefix, no mode-switching. Type "how much did I make this week?" and the answer streams back.
- **One binary, five platforms.** macOS (arm64 + x64), Linux (arm64 + x64), and Windows x64, all built from a single Bun source tree. Or `npm i -g dodopayments-cli` if you'd rather.

## Should You Build a CLI Like This?

We get asked this a lot - "should we build a TUI for our product?" The honest answer is: only if your product has the workflow characteristics that justify it.

**Makes sense if:**

- Your users are already in the terminal for hours a day (developers, devops, SREs).
- You have meaningful state that benefits from being kept in-session (auth, environment, active connections, AI conversations).
- You have offline-capable use cases (local testing, fixture generation) that don't need the API.
- You have a frequent feedback loop where dashboard round-trips become measurable friction.

**Stick with a thin RPC CLI if:**

- Your users mostly call your API from CI or scripts. They want subcommands and JSON, not a workspace.
- Your product's primary surface is a dashboard, and the terminal is an occasional touchpoint.
- You don't have offline-friendly features to build around. A TUI that's a glorified curl wrapper is worse than curl.

A TUI is a serious investment. Solid, OpenTUI, terminal state machines, mouse tracking quirks, multi-platform binary builds - none of it is free. We made the bet because we watched merchants do exactly the kind of work that benefits from a persistent workspace, but we wouldn't have made it for a product that didn't.

## Key Takeaways

1. **A CLI is either a workspace or an RPC layer. Pick one.** Trying to be both produces something that's good at neither. The new CLI is a workspace first; subcommands exist for the cases where a workspace would be overkill.
2. **Fine-grained reactivity beats virtual DOM in the terminal.** OpenTUI + Solid gave us roughly a 12x reduction in per-event render cost over Ink for live data views, and the merchant-visible impact - input feeling immediate - was the difference between "this is a tool I'll use" and "this feels janky."
3. **Encryption at rest is not optional for credentials, even on dev machines.** Plaintext API keys are an incident waiting to happen. AES-256-GCM with a machine-bound key gives you authenticated encryption and doesn't ask the user to memorize a passphrase they'll write on a sticky note.
4. **Make the AI the default, not a mode.** Modal UIs in the terminal are confusing. The moment we made non-slash input go straight to the AI, the assistant went from "thing we built" to "thing we use."
5. **Offline-capable features are a force multiplier.** `/wh trigger` works without login, without network, on a plane. It's the single feature merchants mention most when they describe what they like about the new CLI.

## What's Next

The CLI is generally available today on macOS, Linux, and Windows.

```bash
# Via npm
npm install -g dodopayments-cli

# Via Bun
bun install -g dodopayments-cli

# Or grab a binary from
# github.com/dodopayments/dodopayments-cli/releases
```

Then `dodo login` once, and `dodo` whenever you want to drop into the TUI. The full command reference is in the [project README](https://github.com/dodopayments/dodopayments-cli), and the [Dodo Payments docs](https://docs.dodopayments.com) have the SDK reference the AI assistant searches against under the hood.

We're already working on the next set of pieces - signed payloads for `/wh trigger`, scriptable checkout flows for CI, and a richer offline mode for merchants who fly often. If there's a workflow you'd like to see, the [GitHub repo](https://github.com/dodopayments/dodopayments-cli) is open and we read every issue.

_We're building payment infrastructure at Dodo Payments. If terminal tooling, distributed systems, and fintech sound interesting, we're hiring._
---
- [More Open Source articles](https://dodopayments.com/blogs/category/open-source)
- [All articles](https://dodopayments.com/blogs)