Build a working SaaS endpoint with AI in about 30 minutes
A paid-shaped API on localhost:8000: strict JSON extraction, API-key auth, a monthly call cap, and a usage row with token counts for every call.
What you will have running
POST /v1/extract turns raw sales-call notes into four strict JSON fields. Unknown key gets 401. An account past its monthly call cap gets 429. Every successful call writes one usage row — input tokens, output tokens, timestamp. It runs on localhost:8000 and takes about 30 minutes to build if Python and an OpenAI key are already on the machine; most of that is typing.
Yes, you can build a SaaS with AI. The AI part is one function call in the middle. The parts that make it a SaaS — who asked, were they allowed to, what did it cost, did you record it — are plain code, and they are the parts people skip.
Prerequisites
- Python 3.11 or newer (
python3 --version) curland a terminal- An OpenAI API key from platform.openai.com/api-keys — this build makes real API calls and your account is billed per token at the rates on the pricing page
- Two Python packages:
fastapi,uvicorn,openai(1.x) - No database server. SQLite ships with Python.
The shape of the thing
Five hops, and three of them can refuse before you ever spend a token.
The model sits fourth. Everything upstream of it is the product.
Build it
- Make a directory and install the three packages:
mkdir notes-api && cd notes-api && python3 -m venv .venv && source .venv/bin/activate && pip install fastapi uvicorn openai. You should seeSuccessfully installed fastapi-... openai-... uvicorn-...with version numbers. Ifpipis missing, usepython3 -m pip. - Export your key in the same shell:
export OPENAI_API_KEY="sk-...". Check it took withecho ${OPENAI_API_KEY:0:7}— you should seesk-and four more characters, not an empty line. Do not put the key inapp.py. The SDK readsOPENAI_API_KEYfrom the environment on its own. - Save the file below as
app.py. It is the whole service: schema, database, auth, quota, the model call, and two endpoints. - Save this as
seed.pyand runpython seed.py. It prints one line that startssk_test_followed by 32 hex characters. Copy it — that is your first customer's API key. - Start the server:
uvicorn app:app --reload --port 8000. You should seeUvicorn running on http://127.0.0.1:8000andApplication startup complete.The first run also createssaas.dbin the directory. - Call it with a bad key first, so you know the gate works:
curl -s -X POST localhost:8000/v1/extract -H "X-API-Key: nope" -H "Content-Type: application/json" -d '{"notes":"hi"}'. Expected response:{"detail":"unknown API key"}with status 401. Add-iif you want to see the status line. - Now the real call, with the key from step 4 in
$KEY: see thecurlblock below. You should get back a JSON object withaccount, afieldsobject holdingcompany,next_step,riskandconfidence, and atokensobject with integer counts. - Read the meter:
curl -s localhost:8000/v1/usage -H "X-API-Key: $KEY". You should see"calls":1plus summed input and output tokens for the current UTC month, and"monthly_limit":50. - Drop the limit to 1 and prove the cap fires:
sqlite3 saas.db "UPDATE keys SET monthly_limit=1"(or use Python ifsqlite3is not installed), then repeat step 7. Expected:{"detail":"monthly limit of 1 calls reached"}, status 429, and no new row inusage.
app.py
import calendar
import json
import os
import sqlite3
import time
from contextlib import asynccontextmanager, closing
from fastapi import FastAPI, Header, HTTPException
from openai import OpenAI
from pydantic import BaseModel
DB_PATH = os.environ.get("NOTES_DB", "saas.db")
MODEL = "gpt-4o-mini"
MAX_NOTE_CHARS = 8000
_client = None
def client() -> OpenAI:
# Lazy so that seed.py can import this module without an API key present.
global _client
if _client is None:
_client = OpenAI()
return _client
CRM_SCHEMA = {
"name": "crm_fields",
"strict": True,
"schema": {
"type": "object",
"properties": {
"company": {"type": "string"},
"next_step": {"type": "string"},
"risk": {"type": "string", "enum": ["low", "medium", "high"]},
"confidence": {"type": "number"},
},
"required": ["company", "next_step", "risk", "confidence"],
"additionalProperties": False,
},
}
SYSTEM = (
"Extract CRM fields from raw sales-call notes. "
"If the company is not named, use the string \"unknown\". "
"next_step must be a single imperative sentence. "
"confidence is your 0-1 confidence that next_step is stated in the notes."
)
def db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db() -> None:
with closing(db()) as conn, conn:
conn.execute(
"""CREATE TABLE IF NOT EXISTS keys (
key TEXT PRIMARY KEY,
account TEXT NOT NULL,
monthly_limit INTEGER NOT NULL DEFAULT 1000)"""
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL,
ts INTEGER NOT NULL,
input_tokens INTEGER NOT NULL,
output_tokens INTEGER NOT NULL)"""
)
conn.execute("CREATE INDEX IF NOT EXISTS usage_key_ts ON usage (key, ts)")
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
yield
app = FastAPI(title="notes-to-crm", lifespan=lifespan)
class ExtractIn(BaseModel):
notes: str
def authenticate(key: str | None) -> sqlite3.Row:
if not key:
raise HTTPException(status_code=401, detail="missing X-API-Key header")
with closing(db()) as conn:
row = conn.execute("SELECT * FROM keys WHERE key = ?", (key,)).fetchone()
if row is None:
raise HTTPException(status_code=401, detail="unknown API key")
return row
def month_start_utc() -> int:
now = time.gmtime()
return calendar.timegm((now.tm_year, now.tm_mon, 1, 0, 0, 0, 0, 0, 0))
def calls_this_month(key: str) -> int:
with closing(db()) as conn:
row = conn.execute(
"SELECT COUNT(*) AS c FROM usage WHERE key = ? AND ts >= ?",
(key, month_start_utc()),
).fetchone()
return row["c"]
@app.post("/v1/extract")
def extract(body: ExtractIn, x_api_key: str | None = Header(default=None)):
account = authenticate(x_api_key)
if not body.notes.strip():
raise HTTPException(status_code=400, detail="notes is empty")
if len(body.notes) > MAX_NOTE_CHARS:
raise HTTPException(
status_code=413,
detail=f"notes is {len(body.notes)} chars, limit is {MAX_NOTE_CHARS}",
)
used = calls_this_month(account["key"])
if used >= account["monthly_limit"]:
raise HTTPException(
status_code=429,
detail=f"monthly limit of {account['monthly_limit']} calls reached",
)
response = client().chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": body.notes},
],
response_format={"type": "json_schema", "json_schema": CRM_SCHEMA},
timeout=30,
)
message = response.choices[0].message
if getattr(message, "refusal", None):
raise HTTPException(status_code=422, detail=f"model refused: {message.refusal}")
fields = json.loads(message.content)
with closing(db()) as conn, conn:
conn.execute(
"INSERT INTO usage (key, ts, input_tokens, output_tokens) VALUES (?, ?, ?, ?)",
(
account["key"],
int(time.time()),
response.usage.prompt_tokens,
response.usage.completion_tokens,
),
)
return {
"account": account["account"],
"fields": fields,
"tokens": {
"input": response.usage.prompt_tokens,
"output": response.usage.completion_tokens,
},
}
@app.get("/v1/usage")
def usage(x_api_key: str | None = Header(default=None)):
account = authenticate(x_api_key)
with closing(db()) as conn:
row = conn.execute(
"""SELECT COUNT(*) AS calls,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens
FROM usage WHERE key = ? AND ts >= ?""",
(account["key"], month_start_utc()),
).fetchone()
return {
"account": account["account"],
"month_to_date": dict(row),
"monthly_limit": account["monthly_limit"],
}
seed.py
import secrets
from app import db, init_db
init_db()
api_key = "sk_test_" + secrets.token_hex(16)
with db() as conn:
conn.execute(
"INSERT INTO keys (key, account, monthly_limit) VALUES (?, ?, ?)",
(api_key, "acme@example.com", 50),
)
print(api_key)
The call in step 7
export KEY="sk_test_...paste_from_seed..."
curl -s -X POST localhost:8000/v1/extract \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"notes":"Call with Initech. Ops lead is ops@initech.example. They like the product but legal is backed up until mid-quarter. Competitor already in a pilot. Asked us to send the security questionnaire by Friday."}'
Response shape — the keys are fixed by strict: true, the strings and the token counts vary run to run:
{
"account": "acme@example.com",
"fields": {
"company": "Initech",
"next_step": "Send the security questionnaire by Friday.",
"risk": "medium",
"confidence": 0.9
},
"tokens": { "input": 168, "output": 41 }
}
That is the whole product surface. Point the schema at a different job and the rest of the file does not change.
What one call costs
That response is 168 input tokens and 41 output tokens. At the published gpt-4o-mini rates of $0.15 per million input tokens and $0.60 per million output tokens (pricing), the input side cost $0.000025 and the output side $0.000025 — call it $0.00005 all in, or about 5 cents per thousand extractions. The seeded 50-call limit therefore caps that account's model spend at roughly a quarter of a cent per month.
Two consequences follow, and they are the reason the meter exists before the pricing does. First, at these unit costs the monthly call cap is not protecting your margin, it is protecting you from a runaway client loop; the economics only start to bite when a single request is a long document or an audio file. Second, a 1:1 split between input and output cost in this example is an artefact of a short prompt and a short answer — swap the extraction for a summariser that writes 800 tokens and output becomes 90 per cent of the bill, because the output rate is 4× the input rate. Store both columns separately, as the schema above does, or you cannot see that shift happen.
When it doesn't work
Three failures account for nearly every broken first run.
openai.OpenAIError: The api_key client option must be set on the first POST, returned as a 500. The key is not in the environment of the process running uvicorn — usually because you exported it in one terminal tab and started the server in another, or because --reload was started before the export. Stop the server, re-export, restart. The SDK only reads OPENAI_API_KEY at client construction.
Invalid schema for response_format 'crm_fields' with a message about additionalProperties or required. Strict mode has two non-negotiables: every key in properties must also appear in required, and additionalProperties must be present and false on every object. Add a field to CRM_SCHEMA and forget either rule and the API rejects the request before any generation happens. Add the field name to required too. The sibling error — Invalid parameter: 'response_format' of type 'json_schema' is not supported with this model — means the model snapshot predates Structured Outputs, which shipped in the API on 6 August 2024 and is supported from gpt-4o-mini-2024-07-18 and gpt-4o-2024-08-06 onward. Older snapshots accept only {"type": "json_object"}, which guarantees parseable JSON but not your four keys, so you are back to validating the shape with a Pydantic model and retrying on mismatch.
sqlite3.OperationalError: no such table: keys. You started uvicorn from a different directory than the one seed.py wrote to, so saas.db is somewhere else. sqlite3 creates an empty file on connect rather than erroring, which is why this reads as a missing table instead of a missing file. Run both from the project root, or set an absolute path: export NOTES_DB=/full/path/saas.db before starting either.
A fourth, once you have traffic: SQLite is a single file, and concurrent writers surface as database is locked. Python's sqlite3 waits 5 seconds for the write lock by default before raising, and PRAGMA journal_mode=WAL buys you concurrent readers alongside a single writer but never two writers. Fine for one process and a demo. Move the usage table to Postgres before you put a second worker in front of it.
A fifth is in the file above by design. The quota gate is a SELECT COUNT(*) followed, several hundred milliseconds later, by an INSERT, with no lock held in between. Two requests arriving at call 49 of a 50-call limit can both read 49, both pass, and both insert — 51 calls metered against a cap of 50. One request of overage per concurrent worker is a rounding error at $0.00005 a call; it stops being one when the unit of work is a multi-minute transcription. The fix belongs in the database rather than the application: an INSERT ... SELECT that only writes when the count is still under the limit, or SELECT ... FOR UPDATE on the key row in Postgres, so the read and the write are one transaction.
A single pass is not an agent, and that is deliberate
POST /v1/extract does one model call and returns. No loop, no tools, no retry on a judgement call. That is the right default for a first paid endpoint, because the bounded version is the one you can price: one request in, one row of usage out. Same pattern as a lead triage agent that scores inbound in one pass.
Add a loop — fetch, decide, call a tool, re-decide — and the dominant production failure is a run that will not stop. The model keeps deciding one more step is warranted and burns tokens until something external kills it. The fix is a step cap enforced in your code, outside the model, plus a hard wall-clock timeout per run — the timeout=30 in the file above covers one call, not a loop of them. A counter in the loop body that raises at N iterations. Not an instruction in the prompt telling it to be efficient; prompts are not control flow. The definitions pile up fast here, which is why I wrote what a computer agent is and the three things people mean by it.
I would not hand an agent spend authority. Reading, drafting, classifying, routing — yes, in production. Signing, purchasing, refunding, provisioning paid infrastructure — not without a human approval step, and I say that as someone building a company where agents do the work.
What to add before you charge anyone
These are the gaps between the file above and a thing you can invoice for.
| Gap | What breaks without it | Where it goes |
|---|---|---|
| Keys stored in plaintext | A read of saas.db is every customer's credential | Store a SHA-256 hash; compare hashes on auth |
| No per-minute rate limit | One client's for loop eats your whole token budget before the monthly cap notices | Counter keyed on (key, minute) in front of the quota gate |
| Usage rows never leave SQLite | You can see consumption but cannot bill it | Push to Stripe usage-based billing; the mechanics are in charge for one agent task |
| No cost column | Tokens are not money until you multiply | Multiply logged tokens by the current per-million rates at write time and store the result, since prices change |
| No eval set | You will change the system prompt and not know what you broke | 30 labelled notes in a JSON file, asserted against in CI |
| Nothing logged on failure | A 422 or a timeout leaves no trace of what the customer sent | Log request id, key, status, and note length — never the note body unless the contract allows it |
Six rows, and only the last column mentions a model. Tuning what the agent does and running the business around it are two different jobs, which is the argument in who is "the AI founder".
Two more omissions get noticed by integrators before anything about extraction quality does. The 429 carries no Retry-After header, so a well-behaved client has nothing to back off against, even though the correct value here is deterministic: seconds until 00:00 UTC on the first of next month. And there is no idempotency key, so a client that gives up at the 30-second timeout and retries gets metered twice for one logical extraction. The convention most API consumers already expect is Stripe's — an Idempotency-Key header whose response is stored and replayed for 24 hours — and it is about 20 lines against the usage table you already have.
Hashing the key is cheap because secrets.token_hex(16) already produces 16 random bytes, 128 bits of entropy printed as 32 hex characters. A single unsalted SHA-256 is sufficient for a credential with that much entropy, which is not true of a password, where a deliberately slow KDF such as bcrypt or Argon2 is the baseline. Store the 64-character digest as the primary key, show the raw string exactly once at creation, and treat a lost key as reissued rather than recovered — the same posture every major API vendor takes, surfacing only a prefix and the last four characters in the dashboard afterwards.
Start with the hash and the cost column. Those two are twenty minutes and they are the ones you will regret skipping.
Sources
- OpenAI — Structured Outputs guide — strict JSON schema support, additionalProperties/required constraints, refusal field, supported model versions
- OpenAI — API keys — where the reader gets OPENAI_API_KEY
- OpenAI — API pricing — per-million-token input and output prices used to turn logged tokens into cost
- openai-python (official SDK) — client construction from OPENAI_API_KEY, per-request timeout, usage object on the response
- FastAPI documentation — app framework, Header dependency, HTTPException status codes
- FastAPI — Lifespan events — lifespan context manager used to create tables on startup
- Python — sqlite3 — single-file database, row_factory, connection-as-context-manager commit behaviour
- Python — secrets — token_hex for generating API keys
- Stripe — Usage-based billing — where the logged usage rows go when you want to charge for them