How to Turn Your CLI Tool Into a Recurring Revenue Product
You built a CLI tool that scratches your own itch, posted it on GitHub, and watched a few hundred people star it. Then someone emailed you asking for a feature. Then five more people did. At some point you started wondering: should I charge for this? The answer is almost certainly yes β and the path from free open-source tool to paid subscription product is shorter than you think.
This guide is not about writing a SaaS web app. It's about taking the binary you already have and wrapping a licensing layer around it so users pay monthly and you get recurring revenue.
What you'll learn
- How to structure a CLI tool for licensing without gutting the codebase
- How license key validation works in practice (including offline-tolerant approaches)
- How to integrate billing with Stripe or a similar processor without building a dashboard
- Common mistakes that break user trust or cause piracy to spike
- A realistic launch sequence so you don't over-engineer before you have paying customers
Prerequisites
This article assumes you already have a working CLI tool in Python, Go, Rust, or a similar language. You should be comfortable making HTTP requests from your code and have a basic understanding of environment variables and config files. You do not need prior experience with billing APIs.
Why Recurring Revenue Fits CLI Tools Well
A one-time sale is a cliff β great month, quiet month, repeat. Recurring revenue is a ramp. For CLI tools specifically, recurring billing makes sense because your users get ongoing value: updates, bug fixes, new subcommands, and continued access to any backend services your tool calls.
The mental model that works best is value over time, not a box you bought. If your tool saves a developer two hours a week, a $15/month subscription is a trivially easy decision for them. You just need to make that math obvious at the point of sale.
Deciding What to Put Behind the Paywall
This is where most developers get stuck. The instinct is to lock everything, which drives away new users, or to lock nothing, which earns you nothing. A tiered approach works better.
A common pattern that works well in practice:
- Free tier: Core functionality, rate-limited or capped at a low threshold (e.g., 50 uses per month, or limited to small inputs).
- Paid tier: Unlimited usage, advanced subcommands, priority support, and API access if your tool calls a backend.
The free tier serves as your demo. Do not make it so limited that users can't evaluate the tool, but make it obvious that the paid tier is where the serious work happens. If your tool generates reports, let the free tier generate three-page reports and gate anything longer.
How License Key Validation Works
The simplest architecture that actually works: when a user activates their license, your CLI calls your validation endpoint, receives a signed token, and stores it locally. On each subsequent run, the CLI checks whether the local token is still valid β and occasionally re-validates against your server.
The activation flow
The user runs a command like yourtool activate <LICENSE_KEY>. Your CLI sends that key to your backend, which verifies it against your billing provider's records, then returns a signed JWT or HMAC token with an expiry. The CLI stores this in a config file, typically at ~/.config/yourtool/license.json.
# Simplified activation handler
import requests
import json
import os
CONFIG_PATH = os.path.expanduser("~/.config/yourtool/license.json")
VALIDATION_URL = "https://api.yourtool.com/v1/activate"
def activate_license(key: str) -> None:
resp = requests.post(VALIDATION_URL, json={"license_key": key}, timeout=10)
if resp.status_code == 200:
data = resp.json()
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
with open(CONFIG_PATH, "w") as f:
json.dump({"token": data["token"], "expires_at": data["expires_at"]}, f)
print("License activated. You're good to go.")
else:
print(f"Activation failed: {resp.json().get('error', 'unknown error')}")
Checking the license on each run
Every time your tool runs, read the local token and check whether it's expired. For features behind the paywall, call a lightweight check before executing the feature. You do not need to hit the network on every single invocation β a cached token valid for 24β48 hours is a good balance between UX and enforcement.
import json
import os
from datetime import datetime, timezone
CONFIG_PATH = os.path.expanduser("~/.config/yourtool/license.json")
def is_licensed() -> bool:
if not os.path.exists(CONFIG_PATH):
return False
with open(CONFIG_PATH) as f:
data = json.load(f)
expires_at = datetime.fromisoformat(data["expires_at"])
return datetime.now(timezone.utc) < expires_at
def require_license() -> None:
if not is_licensed():
print("This feature requires an active license.")
print("Run: yourtool activate <YOUR_LICENSE_KEY>")
raise SystemExit(1)
Offline tolerance
If your tool is used in CI pipelines or air-gapped environments, a hard network check on every run will break workflows. The cached token approach handles this gracefully. Set the token TTL to something reasonable β 48 to 72 hours works for most tools. When the token expires and the machine is offline, show a clear message rather than silently failing.
Building the Backend (Without Overengineering It)
You do not need a full admin dashboard on day one. The minimum viable backend is a small HTTP API with two endpoints: POST /activate and POST /validate. You can build this in a weekend with FastAPI, Express, or any framework you're comfortable with and deploy it on a small VPS or a serverless function.
Your backend does three things: it receives a license key, it checks with your billing provider whether the associated subscription is active, and it returns a signed token if the answer is yes. When a subscription lapses, the token simply doesn't get renewed on the next validation check.
# FastAPI example β minimal activation endpoint
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import jwt
import datetime
app = FastAPI()
SECRET = "your-signing-secret" # store this in an environment variable
class ActivateRequest(BaseModel):
license_key: str
# In reality, look this up from your billing provider
def lookup_key(key: str) -> bool:
valid_keys = {"DEMO-KEY-123"} # replace with real DB/Stripe lookup
return key in valid_keys
@app.post("/v1/activate")
def activate(req: ActivateRequest):
if not lookup_key(req.license_key):
raise HTTPException(status_code=403, detail="Invalid or expired license key")
expires_at = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=48)
token = jwt.encode({"sub": req.license_key, "exp": expires_at}, SECRET, algorithm="HS256")
return {"token": token, "expires_at": expires_at.isoformat()}
Wiring Up Billing
Stripe is the default choice for most indie developers because it handles subscriptions, trials, tax, and international payments without you writing much code. The relevant piece for CLI licensing is Stripe's Customer Portal and webhook events.
The workflow looks like this: a user subscribes through your website, Stripe creates a customer and subscription, your webhook listener receives the customer.subscription.created event, your backend generates a license key and emails it to the customer, and the customer runs yourtool activate <KEY>.
When a subscription cancels or a payment fails, Stripe fires customer.subscription.deleted or invoice.payment_failed. Your webhook handler marks that key as inactive in your database. The next time the user's cached token expires and they try to re-validate, activation fails and the tool prompts them to check their billing.
Keep your webhook handler idempotent. Stripe may deliver the same event more than once, and your handler should produce the same result whether it processes the event one time or three times.
Distributing the Licensed Binary
You have a few options for how users get your tool, each with different tradeoffs:
- GitHub Releases with signed binaries: Familiar to developers, easy to automate with CI. Users download directly and activate with a key. Works well for Go and Rust tools.
- pip / npm / cargo: Package managers make installation one command. For Python tools, ship a wheel on PyPI. For Node tools, publish to npm. Mark the free tier as the default and document the activation step in your README.
- Homebrew tap: For macOS-heavy audiences, a private Homebrew tap gives a polished install experience. Users run
brew install yourtap/yourtooland then activate.
Whichever distribution method you choose, make the activation step part of the onboarding flow β ideally printed to stdout after installation so users can't miss it.
Common Pitfalls
Making the free tier invisible. If users can't try your tool before paying, your conversion rate will be close to zero. The free tier is your sales floor, not an afterthought.
Validating on every command invocation. This adds latency, breaks offline workflows, and annoys users. Cache your token locally with a reasonable TTL.
Storing secrets in plaintext without a warning. Your license token lives in a config file. Document where it is and what it contains. Don't log it. On shared machines, users may want to set restrictive file permissions on that config directory.
No grace period for payment failures. Credit cards expire. Give users a grace period β typically 7 to 14 days β before their token stops being issued. Stripe's dunning settings handle this automatically if you configure them.
Launching paid before validating demand. Before you build any of this, confirm that people actually want to pay. A waitlist with a PayPal or Stripe payment link, or even a simple email survey asking
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!