Shipping an LLM app
Deploy, secure keys, rate-limit and handle failures.
Create a free account Try part of it here
Why it matters
A script on your laptop becomes a product the moment other people can use it. And every one of them can now spend your money. Shipping an AI app is mostly ordinary web engineering: keep secrets on the server, check inputs, limit abuse and handle failure. The difference is that the thing you depend on is slow, charged per use and sometimes overloaded. Get these right, and a small team can run an AI feature safely. Skip them, and one leaked key or one runaway script can run up a four-figure bill overnight.
What you'll be able to do
- Put the model call behind your own backend so the API key never reaches a browser
- Protect the endpoint with input limits, per-user rate limits and spending caps
- Fail gracefully: friendly errors, no leaked internals, a fallback when the model is busy
- Walk through a launch checklist for a small LLM feature
What it covers
- The shape of an AI app
- Protect the endpoint
- A rate limit in a few lines
- Fail gracefully
- Two checks before you spend anything
The shape of an AI app
Think of a bank teller. Customers never get the vault key. They ask at the counter, and the teller, who holds the key, checks the request and fetches what's allowed.
Your backend is the teller. It's the part of your app that runs on your own server:
browser or app ──► your backend ──► model API
(no secrets) (holds the key, (Anthropic)
checks, limits,
logs)
The API key lives only on your server. It's read from an environment variable (a setting the server is given when it starts) or a secrets manager. Anything you ship to a browser or a phone can be read by anyone who downloads it. That includes JavaScript and app files. So a key in front-end code is a public key. This academy works exactly this way: lesson code calls a web address, and the academy's server adds the key.
Here's a minimal backend in Python, using the FastAPI web framework and the Anthropic SDK:
import anthropic from fastapi import FastAPI, HTTPException from pydantic import BaseModel client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from the environment app = FastAPI() SYSTEM = "You answer questions about our outdoor clothing shop. ..." class Question(BaseModel): text: str @app.post("/api/answer") def answer(question: Question): if not question.text.strip() or len(question.text) > 2000: raise HTTPException(400, "Questions must be 1 to 2,000 characters.") message = client.messages.create( model="claude-opus-5-5", max_tokens=500, system=SYSTEM, messages=[{"role": "user", "content": question.text}], ) return {"answer": "".join(b.text for b in message.content if b.type == "text")}
What it does:
@app.post("/api/answer")makes the function answer requests sent to that address.Questiondescribes the expected input: one piece of text.- The
ifrefuses empty questions and anything over 2,000 characters, with a 400 error. - Then it calls the model and returns just the text of the answer.
The front end sends the question to /api/answer. It never talks to Anthropic directly. The SDK also retries short-lived errors for you.
Show the answer
So the API key stays on the server, out of anyone's browser
Anything sent to a browser can be read. The key stays on your server, and the page only ever talks to your own endpoint.
Protect the endpoint
Your endpoint spends money every time it's called. Treat it like a payment form:
- Know who's calling. Require sign-in, or at least an access code for internal tools. An open endpoint will be found and used.
- Limit input size. Cap characters, files and conversation length. Long inputs cost money and give attackers room for injection.
- Rate-limit each user. A common method is the token bucket. Each user has a bucket of, say, 5 requests. It refills at one request every 10 seconds. Short bursts are fine, but steady hammering gets a 429 Too Many Requests.
- Cap spending. Set monthly spending limits in your provider's console, with alerts well below them. A daily budget per user catches the one account that's running a script on your app.
- Set
max_tokensand timeouts on every call, so no single request can run away.
A rate limit in a few lines
Your endpoint spends money on each call, so limit how often one person can call it. This keeps a count per user for each minute.
from collections import defaultdict LIMIT = 3 counts = defaultdict(int) def allowed(user, minute): key = (user, minute) counts[key] += 1 return counts[key] <= LIMIT for n in range(5): print("ann", "minute 1:", allowed("ann", 1)) print("ben", "minute 1:", allowed("ben", 1)) print("ann", "minute 2:", allowed("ann", 2))
Ann's fourth and fifth calls get refused, Ben isn't affected, and Ann's count starts fresh next minute. In production you'd store the counts somewhere shared, like Redis, since one server's memory can't see the others. You'd answer refused calls with a 429.
import os os.environ["API_KEY"] = "secret" print("secret" in "fetch('/api/chat')")
Show the answer
False
The in checks only the text fetch('/api/chat'), which doesn't contain the word secret, so it prints False. The browser only calls your server. The key stays on the server, so it never appears in page code.
Three quick requests empty the bucket, the fourth is refused, and two seconds later one token has refilled. It prints ['ok', 'ok', 'ok', '429', 'ok'].
Show the answer
The first three used up the bucket; two seconds later it had refilled enough for one more
The bucket holds 3 and refills half a request per second. After three quick requests it's nearly empty. Two seconds later, one more request's worth has refilled.
Fail gracefully
The model API will sometimes be slow, rate-limited or overloaded. Plan for it:
- Friendly errors, no internals. Show "The assistant is busy, please try again", and log the details for yourself. Never show users the raw error messages from your code or the API. They leak information and confuse people.
- Fallbacks. If the main model is still overloaded after retries, try a smaller model, a saved answer to a common question, or a link to the help center.
- Stream long answers, so users see progress, and let them cancel.
- Timeouts that match your hosting. Some hosts cut requests off after 10 to 60 seconds, which is shorter than a long answer takes. Streaming, or running the work in the background, avoids that.
Deploying it
Hosting services such as Railway, Render, Fly.io and Vercel run a small backend straight from a Git repository. Push to the main branch, and they build and deploy it. You set ANTHROPIC_API_KEY in the host's environment settings, never in the repository. Add a health check, a simple address the host can call to check the app is running. And keep a staging copy of the app, where you try changes before they reach real users.
Launch checklist
| Area | Check |
|---|---|
| Secrets | Key only in server environment; .env files in .gitignore; key replaced if ever exposed |
| Access | Sign-in or access code; rate limits per user; input size limits |
| Cost | max_tokens on every call; caching where prompts repeat; spending limits and alerts |
| Quality | Eval passes on the release candidate; grounded prompts; tested against injection examples |
| Failure | Retries with backoff; friendly errors; fallback path; timeouts |
| Operations | Logging with personal data removed; dashboards for cost, latency, errors; a way to roll back |
| People | Users know they're talking to AI; a path to a person for anything important |
For marketers: before launching a customer-facing AI feature, go through the checklist with your developer and your legal or compliance contact. "What happens when it's wrong?" deserves an answer before launch, not after.
Two checks before you spend anything
Before calling the model, the server checks the input and the user's budget for the day. Each user may spend $1.00 a day. Alex has spent 10 cents so far, and Sam has spent 99 cents. A request costs about 2 cents.
MAX_CHARS = 2000 DAILY_BUDGET = 1.00 spent_today = {"sam": 0.99, "alex": 0.10} def allowed(user, text, cost=0.02): if not text.strip() or len(text) > MAX_CHARS: return "400: too long or empty" if spent_today.get(user, 0) + cost > DAILY_BUDGET: return "429: daily budget used" return "ok" print(allowed("alex", "Do gift cards expire?")) print(allowed("sam", "Do gift cards expire?")) print(allowed("alex", "x" * 3000))
It prints ok, then 429: daily budget used, then 400: too long or empty. Sam's request would bring Sam's total to $1.01, over the $1.00 budget. Alex's last message is 3,000 characters, over the 2,000 limit. Neither call reaches the model, so neither costs a cent.
Show the answer
Anyone can read the API key from the page and spend money on your account
Front-end code is public. The key must stay on a server you control.
Show the answer
A friendly message like "The assistant is busy, please try again", with a fallback such as a help-center link
Keep the details in your logs. Users get a clear, kind message and somewhere else to go.
Practice what you just read
- 3 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 4 review cards for spaced repetition
Try a quiz question
Where should your Anthropic API key live in a web app?