Start a business with AI: ship one metered, capped endpoint
A build-along: a Python endpoint that grades inbound leads with an LLM, meters the token cost per customer key, and refuses the call when a spend cap is hit.
What you'll have running
One graded lead costs $0.0000372 — 92 input tokens and 39 output tokens on gpt-4o-mini, taken from the provider's own usage field and written to a SQLite row. Not estimated. The endpoint below takes an inbound lead, grades it with an LLM, bills the exact token cost to the caller's key, and returns HTTP 402 once that key has burned through its spend cap. Budget an hour if Python is already installed.
That is a business, not a demo. One job, one price, one refusal path. A dashboard, a signup flow, a second feature — all of it waits until somebody pays for the first one.
You need Python 3.10 or newer with pip, an OpenAI API key from platform.openai.com/api-keys with credit on the account, and curl plus the sqlite3 CLI. The sqlite3 module is in Python's standard library, so there is nothing to install for the ledger itself; sqlite3 --version confirms the CLI is on your path, and the printf() SQL function used below has shipped since SQLite 3.8.3, so any system build will do. A Stripe account too, if you want to take money in step 8. Free to create.
The build
- Make a folder and a virtualenv:
mkdir lead-grader && cd lead-grader && python -m venv .venv && source .venv/bin/activate. - Install three packages:
pip install fastapi uvicorn openai. FastAPI serves, uvicorn runs it,openaiis the SDK from the official repo. - Export your key:
export OPENAI_API_KEY=sk-.... The SDK reads this variable when you constructOpenAI()with no arguments. - Paste the file below into
main.py. It is complete — no placeholders. - Start the server:
uvicorn main:app --reload. You should seeUvicorn running on http://127.0.0.1:8000. - Grade one lead with
curland read thecost_usdfield in the response. - Trip the cap with the zero-cap demo key and confirm you get a 402 and no ledger row.
- Create a Stripe Payment Link, send it to one person, and issue their key by hand.
The endpoint, in full
Two routes. POST /grade does the work; GET /usage lets a customer see what they've spent. The cap is checked in Python, before the model is ever called. A spend limit written into a prompt is not a limit — it's a suggestion the model is free to ignore, and you pay for the tokens either way.
# main.py — metered, capped lead-grading endpoint
import json
import sqlite3
import time
import uuid
from contextlib import closing
from fastapi import FastAPI, Header, HTTPException
from openai import OpenAI
from pydantic import BaseModel
MODEL = "gpt-4o-mini"
# USD per 1M tokens. Copied from openai.com/api/pricing — re-check before you trust them.
PRICE_IN_PER_M = 0.15
PRICE_OUT_PER_M = 0.60
# One key per customer. cap_usd is lifetime model spend allowed on that key.
CUSTOMERS = {
"sk_demo_aaa111": {"email": "buyer@example.com", "cap_usd": 2.00},
"sk_demo_bbb222": {"email": "trial@example.test", "cap_usd": 0.00}, # cap 0 = test the 402 path
}
DB = "ledger.db"
client = OpenAI() # reads OPENAI_API_KEY from the environment
app = FastAPI()
SYSTEM = (
"You grade inbound B2B leads. Reply with JSON only, using exactly these keys: "
"tier (one of A, B, C), reason (under 20 words), next_action (under 10 words)."
)
def db():
conn = sqlite3.connect(DB)
conn.execute(
"""CREATE TABLE IF NOT EXISTS calls (
id TEXT PRIMARY KEY, ts REAL, api_key TEXT, model TEXT,
in_tok INTEGER, out_tok INTEGER, cost_usd REAL)"""
)
return conn
def spent(conn, api_key):
row = conn.execute(
"SELECT COALESCE(SUM(cost_usd), 0) FROM calls WHERE api_key = ?", (api_key,)
).fetchone()
return float(row[0])
class Lead(BaseModel):
domain: str
note: str = ""
@app.post("/grade")
def grade(lead: Lead, x_api_key: str = Header(default="")):
cust = CUSTOMERS.get(x_api_key)
if not cust:
raise HTTPException(401, "unknown api key")
with closing(db()) as conn:
used = spent(conn, x_api_key)
if used >= cust["cap_usd"]:
raise HTTPException(
402, f"spend cap reached: ${used:.6f} of ${cust['cap_usd']:.2f}"
)
r = client.chat.completions.create(
model=MODEL,
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": f"domain: {lead.domain}\nnote: {lead.note}"},
],
)
in_tok = r.usage.prompt_tokens
out_tok = r.usage.completion_tokens
cost = in_tok / 1e6 * PRICE_IN_PER_M + out_tok / 1e6 * PRICE_OUT_PER_M
conn.execute(
"INSERT INTO calls VALUES (?,?,?,?,?,?,?)",
(str(uuid.uuid4()), time.time(), x_api_key, MODEL, in_tok, out_tok, cost),
)
conn.commit()
verdict = json.loads(r.choices[0].message.content)
return {
"verdict": verdict,
"cost_usd": round(cost, 6),
"spent_usd": round(used + cost, 6),
"cap_usd": cust["cap_usd"],
}
@app.get("/usage")
def usage(x_api_key: str = Header(default="")):
cust = CUSTOMERS.get(x_api_key)
if not cust:
raise HTTPException(401, "unknown api key")
with closing(db()) as conn:
n = conn.execute(
"SELECT COUNT(*) FROM calls WHERE api_key = ?", (x_api_key,)
).fetchone()[0]
return {
"calls": n,
"spent_usd": round(spent(conn, x_api_key), 6),
"cap_usd": cust["cap_usd"],
}
Three things to know before you edit it.
FastAPI turns the parameter name x_api_key into the header x-api-key, converting underscores to hyphens. That's documented header behaviour, not magic. response_format={"type": "json_object"} is JSON mode, and OpenAI's structured outputs guide requires the prompt itself to mention JSON — the system message here does, so don't rewrite it without keeping the word. And the ledger row comes from r.usage, the provider's own token counts. Never your own estimate.
Prove the cap works
Start the server, then grade a lead:
curl -s localhost:8000/grade \
-H "content-type: application/json" \
-H "x-api-key: sk_demo_aaa111" \
-d '{"domain":"example.com","note":"200-person fintech, asked about SOC 2 and pricing"}'
The wording of reason changes every run and your token counts will differ, so your cost_usd will too. The shape must not change:
{"verdict":{"tier":"A","reason":"Mid-size fintech asking about security and pricing shows active buying intent.","next_action":"Route to AE for a call."},"cost_usd":3.75e-05,"spent_usd":3.75e-05,"cap_usd":2.0}
Now look at the ledger directly. This row is the artefact that makes it a business rather than a script:
sqlite3 ledger.db "select api_key, in_tok, out_tok, printf('%.6f', cost_usd) from calls order by ts desc limit 3;"
sk_demo_aaa111|92|39|0.000037
Then hit the refusal path with the zero-cap key:
curl -i -s localhost:8000/grade \
-H "content-type: application/json" \
-H "x-api-key: sk_demo_bbb222" \
-d '{"domain":"acme.test","note":"student project"}' | head -n 1
HTTP/1.1 402 Payment Required
The body reads {"detail":"spend cap reached: $0.000000 of $0.00"}. Re-run the sqlite3 query. No new row, because no model call happened.
RFC 9110 lists 402 as reserved for future use (§15.5.3), so nothing in the wild depends on it. I use it anyway: it's unambiguous to a human reading logs and trivial to branch on in a client, which 403 and 429 are not — both already mean something else to every HTTP library you'll talk to, 403 as "authenticated but forbidden" in §15.5.4 and 429 as "slow down and retry" since RFC 6585 §4.
If you want the agentic version of this — multi-step, with a step cap and a tool allowlist instead of a single call — I wrote the loop separately: build an AI agent loop with a step cap, allowlist and dry run.
The cap under concurrency
SQLite allows many simultaneous readers and exactly one writer. Two requests on the same key arriving in the same millisecond both run the SELECT COALESCE(SUM(cost_usd), 0) before either one inserts, so both read the same spent figure and both pass a cap they should have split between them. The overshoot is bounded by one call per concurrent request — $0.0000372 apiece here — which is precisely why this shape is fine behind a $2.00 cap and wrong behind a $200 one.
Three changes, in the order they're worth making.
First, switch on write-ahead logging and raise the busy timeout. Python's sqlite3.connect() defaults to a 5-second timeout, and one slow write under load surfaces as sqlite3.OperationalError: database is locked — a 500 returned for a call you may already have paid OpenAI for. The index earns its keep too, since both queries filter on api_key:
def db():
conn = sqlite3.connect(DB, timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("""CREATE TABLE IF NOT EXISTS calls (...)""") # unchanged
conn.execute("CREATE INDEX IF NOT EXISTS calls_key_ts ON calls (api_key, ts)")
return conn
Second, bound the worst case of a single call. The system prompt asks for under 30 words, but a prompt is not a limit — max_tokens=120 on the create() call is. At $0.60 per million output tokens that pins the output ceiling of one grade at $0.000072, so the maximum damage from a race is a number you can write down.
Third, if you need the cap to be exact rather than approximate, reserve before you spend. Inside a BEGIN IMMEDIATE transaction, check the cap and insert a row holding that ceiling estimate, commit, call the model, then update the row with the real r.usage figures. Callers on one key serialise on the reservation, which takes microseconds, instead of on the OpenAI round trip, which takes a second or more. Past roughly ten concurrent callers on a single key, the ledger belongs in Postgres and the cap check becomes a SELECT ... FOR UPDATE on a customer row in the same transaction as the insert.
Price it from the ledger, not from a feeling
You now have the number most AI founders are missing: cost per unit of work, measured. Do the arithmetic with the prices you pasted in — 92 input tokens and 39 output tokens on gpt-4o-mini, at $0.15 and $0.60 per million.
| Item | Value |
|---|---|
| Input cost | 92 ÷ 1,000,000 × $0.15 = $0.0000138 |
| Output cost | 39 ÷ 1,000,000 × $0.60 = $0.0000234 |
| Model cost per grade | $0.0000372 |
| 10,000 grades | $0.372 |
| Stripe fee on one $49 charge | 2.9% + $0.30 = $1.72 |
| Net after that Stripe fee | $47.28 |
| Grades needed to burn that $47.28 | ~1,271,000 |
| Grades allowed by the $2.00 demo cap | 53,763 |
Read the last rows together. Payment processing costs more than inference at this size — four and a half times more — inference at 10,000 grades a month is under 1% of the net revenue on a single $49 plan, and your real costs are the ones this table doesn't contain: hosting, your time on support, the retries you'll add when the model returns a tier you disagree with. Anyone quoting "AI costs" while pointing only at tokens is measuring the cheapest input.
Two caveats on the arithmetic before you build a price list on it. OpenAI's prompt caching only engages above 1,024 input tokens, so this 92-token prompt never qualifies and the input line stays linear; grow the system message past that threshold and the cached portion bills at a discount, reported back in usage.prompt_tokens_details.cached_tokens. And if you swap gpt-4o-mini for a reasoning model, billed output includes tokens you never see, counted in usage.completion_tokens_details.reasoning_tokens. Because completion_tokens already contains them the formula stays correct, but a 39-token verdict can arrive with several hundred reasoning tokens attached and a cost far above what this table says.
To take the first payment, do not build billing. Create a Stripe Payment Link for a flat monthly amount, put it in an email, and when someone pays, add a line to CUSTOMERS by hand with a cap you're willing to eat. Manual key issuance is the correct amount of engineering for customer one through five. Automate it when the manual step actually hurts.
Deploying it
Two things change when this leaves your laptop.
The key must come from the platform's secret store, not your shell history — on Fly that's fly secrets set OPENAI_API_KEY=..., per the Fly docs. And ledger.db sits on a container filesystem that disappears on redeploy, which silently resets every customer's spend to zero. Mount a volume, or move the ledger to Postgres.
On Fly the volume route is three edits: fly volumes create ledger --size 1 for a 1 GB disk, a [mounts] block in fly.toml pointing source = "ledger" at destination = "/data", and DB = "/data/ledger.db" in main.py. See the volumes docs. At roughly 100 bytes a row, 1 GB holds on the order of 10 million graded leads — you will run out of customers long before you run out of disk. The constraint that bites instead is that a volume attaches to exactly one machine, so keep fly scale count 1 until the ledger moves to Postgres. Two machines with two volumes is two ledgers and two independent caps. Discovering either of these in production means you've been giving away grades for free since your last deploy.
Your start command is the same either way:
uvicorn main:app --host 0.0.0.0 --port 8080
With a requirements.txt of three lines:
fastapi
uvicorn
openai
Pin the versions before the first paying user depends on them. pip freeze > requirements.txt inside the activated virtualenv writes the exact three fastapi==, uvicorn== and openai== lines you tested against, which is the difference between a redeploy that works and a redeploy that meets a new major version of the SDK.
When it doesn't work
No key in the environment. The server fails at import, before the first request:
openai.OpenAIError: The api_key client option must be set either by passing api_key to the client or by setting the OPENAI_API_KEY environment variable
Fix: export OPENAI_API_KEY=sk-... in the same shell that runs uvicorn. A new terminal tab does not inherit it.
No credit on the account. The call reaches OpenAI and bounces:
openai.RateLimitError: Error code: 429 - {'error': {'message': 'You exceeded your current quota, please check your plan and billing details.', 'type': 'insufficient_quota'}}
Fix: add credit in your OpenAI billing settings. Despite the 429, this is not a rate limit, and retrying makes it worse.
You reworded the system prompt and dropped the word JSON. JSON mode rejects the request:
openai.BadRequestError: Error code: 400 - {'error': {'message': "'messages' must contain the word 'json' in some form, to use 'response_format' of type 'json_object'."}}
Fix: keep "JSON" in the system message, or move to a strict JSON schema as described in the structured outputs guide.
Two requests, one locked ledger. Under concurrent load on the default connection settings:
sqlite3.OperationalError: database is locked
Fix: sqlite3.connect(DB, timeout=30.0) plus PRAGMA journal_mode=WAL, as above. The 5-second default is short enough that a single slow write can exhaust it.
One more you'll hit while testing with curl: forget the domain field and FastAPI returns 422 with {"detail":[{"type":"missing","loc":["body","domain"],"msg":"Field required"}]}. That's validation working. Leave it.
What I'd add second, and what I wouldn't
I'd add a per-day cap alongside the lifetime one, because a lifetime cap tells you nothing about a bad afternoon. It's one more query against the same table — SELECT COALESCE(SUM(cost_usd), 0) FROM calls WHERE api_key = ? AND ts > ?, passed time.time() - 86400 — plus a second field in CUSTOMERS, say day_cap_usd: 0.25, which at $0.0000372 a grade allows 6,720 calls in 24 hours and is served by the calls_key_ts index you already created. I'd log the raw model response next to the parsed verdict, so that when a customer disputes a grade you can read what actually came back instead of guessing. And I'd wrap one reformulation retry around the json.loads — a malformed body is the failure that shows up at volume, not on call twelve.
I would not give this thing authority to spend money on the customer's behalf. Not send the email, not adjust the budget, not book the meeting — not until the ledger shows me months of grades I agree with. Refusing is cheap. Acting wrongly on someone else's account is not, and the gap between those two is where most of the "our AI went rogue" stories live. The work between the calls is where this pays off, which is the argument I made in AI won't replace salespeople, and the reason I keep the first version of anything capped and metered is in start an AI company by shipping one capped, metered endpoint.
Sources
- OpenAI API pricing — Per-million-token input/output prices for gpt-4o-mini used in the cost arithmetic; verify before you copy the constants.
- OpenAI API keys — Where the reader creates the OPENAI_API_KEY the code expects.
- OpenAI structured outputs and JSON mode guide — json_object response_format, and the documented requirement that the prompt mention JSON.
- openai-python SDK — Install target, client construction from OPENAI_API_KEY, usage fields on the response.
- FastAPI header parameters — Underscore-to-hyphen conversion that makes x_api_key read the x-api-key header.
- Python sqlite3 documentation — Standard-library ledger store, no external database needed.
- RFC 9110, HTTP Semantics — Status code definitions, including 402 Payment Required being reserved for future use.
- Stripe Payment Links — No-code checkout page used to take the first payment before building billing.
- Stripe pricing — Published US card processing fee of 2.9% + $0.30 per successful charge.
- OWASP Top 10 for LLM Applications — Unbounded consumption listed as a top-ten LLM application risk, which is what the spend cap addresses.
- Fly.io docs — Reference for deploying the container and setting secrets if the reader hosts there.