← FastAPI Clinic / API
Tokens

Drive FastAPI Clinic from your own code

Everything the web page does is available over HTTP: paste a FastAPI project in, get the same structured production-readiness review back. The natural use is a CI job that re-reviews whenever main.py or the routers change, or a script that runs the same twelve-check pass across every service in a fleet and fails the build when one of them drifts into not-ready.

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same envelope, so one helper covers the whole API:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }

Send your app slug as X-App-Slug: fastapi-clinic and your token as Authorization: Bearer … on every call.

Error codes

codestatuswhat to do
unauthorized401The token is missing, malformed or expired. Get a new one from the token page.
payment_required402The balance is below min_credits. Call /estimate first and top up.
forbidden403The token is valid but not for this app, or a guest token tried a metered run. Check the X-App-Slug header.
not_found404Unknown job id, unknown collection, or the app slug does not exist.
conflict409The same Idempotency-Key was replayed with a different body. Change the key or send the original input.
validation_error422The input object is missing a required field — files is the usual one — or a field is the wrong type.
rate_limited429Too many requests. Back off and retry; do not tight-loop.
internal5xxA server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice.

1. Get a token

The easiest route is the token page: it shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. Nothing on that page needs a developer tool — it reads the same storage the app itself uses and prints the token for you.

A guest token can call /me and /estimate. Running a review is metered, so it needs a personal token from signing in.

# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
#   https://fastapi-clinic.skillsafe.ai/tokens.html
#   export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. A guest token is enough
# for /me and /estimate; running a review needs a personal token from signing in.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" -H "X-App-Slug: fastapi-clinic"
# {"ok":true,"data":{"token":"sk_guest_...","subject_type":"guest"}}

2. A tiny client

One helper that adds the headers, unwraps data and raises on error.

# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="fastapi-clinic"
TOKEN="$SKILLSAFE_TOKEN"   # from https://fastapi-clinic.skillsafe.ai/tokens.html

call() {                  # call <path> [json-body]
  if [ -n "$2" ]; then
    curl -sS -X POST "$BASE/$1" \
      -H "Authorization: Bearer $TOKEN" \
      -H "X-App-Slug: $SLUG" \
      -H "Content-Type: application/json" \
      -d "$2"
  else
    curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN" -H "X-App-Slug: $SLUG"
  fi
}

3. Check the session and the balance

GET /me tells you whether the token is a guest or a person, and what the balance is. Compare it against min_credits from the next step before you run, so a shortfall surfaces as your own clear message rather than a 402.

call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}

4. Price the run — free

The input object is exactly what the app's own form submits:

fieldtypemeaning
filesstring, requiredThe pasted project source — main.py, routers, Pydantic models, dependencies, database session code, middleware, settings, requirements.txt or pyproject.toml. Put a # file: app/main.py marker line above each one so they can be told apart. This is the review's only evidence. A file whose middle has been removed should say so with a # [... clipped ...] comment.
targetstringWhat the code is being reviewed against: production, staging, internal or unknown. It changes priority and framing, not what counts as a finding — a blocking call inside async def on internal is still a finding. unknown is reviewed against production, and the review says so in assumptions.
focusstringgeneral, async, validation, auth, database or performance. Emphasis, not exclusivity: a high-severity finding from another area is never suppressed.
contextstring, optionalFree-form notes: expected traffic, who calls the service, what it stores, what sits behind a gateway, the deadline.
prescan_factsobject{resources: [{id,label}], flags: [{id,label}]} — what the app's free in-browser static analyzer established: routes, dependencies, Pydantic models, middleware and settings in resources; checks that fired in flags. Every flags id must come back in coverage_check, which is how you hold the model to the facts.
retry_notestring, optionalSend only on a retry, when a previous reply failed to parse or came back truncated. The instruction is obeyed exactly.

/estimate creates no job and charges nothing. It returns the model binding — model, model_alias, markup_bps — and the reservation: hold_credits is what gets held, min_credits is the balance you must clear to start, and sponsor_enabled says whether the app is covering the run. The hold is a reservation, not the price. It prices the full output cap, so the charged_credits you see after settlement is usually far lower — often a small fraction of the hold. Budget against hold_credits, report against charged_credits.

INPUT='{"files": "# file: app/main.py\nimport requests\nfrom fastapi import FastAPI\n\napp = FastAPI()\n\n@app.get(\"/orders/{order_id}\")\nasync def get_order(order_id):\n    r = requests.get(f\"https://billing.internal/orders/{order_id}\")\n    return r.json()\n\n# file: requirements.txt\nfastapi==0.95.2\npydantic==1.10.13", "target": "production", "focus": "async", "context": "Public order API behind an ALB, 300 rps at peak, stores customer addresses, launch in two weeks.", "prescan_facts": {"resources": [{"id": "routes:count", "label": "14 routes declared, 9 async"}, {"id": "dep:fastapi", "label": "fastapi==0.95.2"}], "flags": [{"id": "async:blocking-call", "label": "requests.get inside an async def handler"}, {"id": "response-model:missing", "label": "7 routes declared without response_model"}]}}'

call estimate "$INPUT"
# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
#   "markup_bps":1000,"hold_credits":1920,"min_credits":290,"sponsor_enabled":false}}
#
# estimate is FREE. It creates no job and charges nothing. hold_credits is what
# gets RESERVED; charged_credits after settlement is normally much lower.

5. Run it, then poll

POST /run returns a job_id; poll GET jobs/{job_id} until status is succeeded or failed. The review JSON is the string at data.output.output.

Always send an Idempotency-Key. Derive it from the input, as the web app does (fastapi-clinic:<hash>:a<attempt>). A retried request carrying the same key returns the same job instead of billing a second run — which is what makes a CI retry safe. Replaying a key with a different body is a 409 conflict, so bump the attempt suffix whenever the input actually changed.

# Always send an Idempotency-Key derived from the input. A retried request with
# the same key returns the SAME job instead of billing a second run.
KEY="fastapi-clinic:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"

JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-App-Slug: $SLUG" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

# Poll until the job reaches a terminal status.
while :; do
  OUT=$(call "jobs/$JOB")
  STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
  [ "$STATUS" = "succeeded" ] && break
  [ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
  sleep 2
done
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])'

6. Or stream it

POST /run-stream is the same call over server-sent events. The web app uses it to advance a staged progress display as sections arrive, and to keep whatever parsed if the stream dies mid-flight. The final done event carries charged_credits — the real price, normally a fraction of the hold — and the truncated flag.

# Server-sent events. Each `delta` carries a chunk of the JSON review; the final
# `done` event carries the status, charged_credits and the truncated flag.
curl -N -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-App-Slug: $SLUG" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -H "Accept: text/event-stream" \
  -d "$INPUT"

# event: job    {"job_id":"job_..."}
# event: delta  {"text":"{\"review_name\":\"orders-api"}
# event: delta  {"text":" — production readiness review\","}
# event: done   {"status":"succeeded","charged_credits":451,"truncated":false}

7. Parse the review and check the reconciliation

Two invariants are worth enforcing on your side, because the app enforces them too: every focus_areas[].finding_ids entry must name a real finding id, and every prescan_facts.flags id must appear exactly once in coverage_check. A flag missing from the reconciliation means the model quietly skipped a fact your own static analyzer established — treat that as a failed run, not a passing one, and retry with a retry_note naming the missing ids. An entry with addressed: false is fine: that is the model deliberately setting a flag aside, with the reason in note, which is a different thing from silence.

The truncated flag on the done event (and on the finished job) means the reply hit the output cap. What you hold is a prefix, not a review: retry with a retry_note asking for fewer, denser findings rather than trying to repair the JSON.

# The review JSON is a string inside the envelope, so unwrap it twice.
REVIEW=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])')

printf '%s' "$REVIEW" | python3 -c '
import sys, json
r = json.load(sys.stdin)
print(r["readiness"], "|", r["verdict"])
print(r["fastapi_version"], "pydantic", r["pydantic_major"], "|", r["app_shape"])
for c in r["checks"]:
    print("  %-8s %s" % (c["status"], c["check"]))
for f in r["findings"]:
    print("  %s %-8s %-16s %s" % (f["id"], f["priority"], f["category"], f["resource"]))
'

# Every prescan flag id must come back exactly once in coverage_check.
printf '%s' "$REVIEW" | python3 -c '
import sys, json
seen = [c["id"] for c in json.load(sys.stdin)["coverage_check"]]
want = ["async:blocking-call", "response-model:missing"]
missing = [i for i in want if seen.count(i) != 1]
if missing:
    raise SystemExit("unreconciled prescan flags: " + ", ".join(missing))
print("coverage_check reconciles")
'

The output contract

data.output.output is a JSON string holding one object. This is exactly what the web app parses, so anything that renders here will render there:

{
  "review_name": "orders-api — production readiness review",
  "readiness":   "production-ready | needs-work | not-ready",
  "verdict":     "one sentence naming the single thing that decides the readiness",
  "fastapi_version": "0.95.2",
  "pydantic_major":  "1",
  "app_shape":       "single-module app, 14 routes, no router split",
  "reviewed_against": "production | staging | internal | unknown",
  "exec_summary":   "2-3 paragraphs separated by blank lines",
  "assumptions":    ["..."],
  "open_questions": ["..."],
  "inventory": [
    { "kind": "Route", "name": "GET /orders/{order_id}", "value": "async def get_order",
      "role": "what it does here" }
  ],
  "checks": [
    { "check": "Async correctness", "status": "fail",
      "evidence": "requests.get called inside async def get_order at app/main.py",
      "requirement": "No blocking I/O on the event loop: use httpx.AsyncClient or def" }
  ],
  "findings": [
    {
      "id": "FA-001",
      "category":   "async | dependencies | validation | response-model | error-handling | auth | database | cors | performance | configuration | testing | observability",
      "severity":   "low | medium | high",
      "likelihood": "low | medium | high",
      "priority":   "critical | high | medium | low",
      "resource": "Route/GET /orders/{order_id}",
      "problem":  "...",
      "impact":   "...",
      "fix":      "...",
      "snippet":  "corrected Python fragment, or \"\""
    }
  ],
  "coverage_check": [
    { "id": "async:blocking-call", "addressed": true, "note": "FA-001." }
  ],
  "refactored_route": "one corrected route, rewritten end to end, as a JSON string",
  "commands":    ["uvicorn app.main:app --workers 4  # run it the way production will"],
  "quick_wins":  ["..."],
  "focus_areas": [{ "area": "...", "why": "...", "finding_ids": ["FA-001"] }],
  "summary": "closing paragraph"
}

Every key

keytypemeaning
review_namestringShort title naming the service and the target it was reviewed against.
readinessenumproduction-ready, needs-work or not-ready. The single value a CI gate should branch on.
verdictstringOne sentence justifying the readiness and naming the thing that decides it.
fastapi_versionstringThe version the paste shows, e.g. "0.115.0", or "unknown". Never guessed.
pydantic_majorstring"1", "2" or "unknown". It decides which idioms the fixes are written in — @validator versus @field_validator, .dict() versus .model_dump().
app_shapestringHow the project is put together: single module, routers, factory function, how many routes, whether there is a lifespan handler.
reviewed_againstenumEchoes the target reviewed against: production, staging, internal, unknown.
exec_summarystringTwo to three paragraphs on the dominant themes, separated by blank lines.
assumptionsstring[]Explicit assumptions filling gaps in the paste — how many workers run, whether a gateway terminates TLS, which database driver is in use.
open_questionsstring[]Questions whose answers would change the review or its ordering.
inventoryobject[]{kind, name, value, role}. kind is one of Route, Router, Dependency, Model, Middleware, Setting, Session, BackgroundTask, Package.
checksobject[]{check, status, evidence, requirement}. Always the same twelve checks in the same fixed order — render by index, do not search by name.
findingsobject[]{id, category, severity, likelihood, priority, resource, problem, impact, fix, snippet}. Ids are sequential FA-001, FA-002, … Always at least one entry. snippet is a pasteable Python fragment or "".
coverage_checkobject[]{id, addressed, note}. One entry per prescan_facts.flags id, exactly once, and no ids the prescan did not send. addressed: false means deliberately set aside, with the reason in note.
refactored_routestringOne route rewritten end to end — decorator, signature, dependencies, response model, error handling — as a JSON string. The route chosen is the one carrying the most findings. "" when no route was pasted. Never contains a real secret.
commandsstring[]Ordered shell commands, each with a trailing comment. Read-only — nothing that deletes, migrates or deploys.
quick_winsstring[]One-line changes worth doing immediately.
focus_areasobject[]{area, why, finding_ids}. Every id in finding_ids must exist in findings.
summarystringClosing paragraph: what to do first and what remains after that.

The enums

fieldvaluesnotes
readinessproduction-ready, needs-work, not-readyproduction-ready: the twelve checks hold as a set and the rest is improvement work. needs-work: named checks fail or are partial, but nothing breaks under the stated traffic today. not-ready: at least one finding means this falls over, leaks data or blocks the event loop under the traffic described in context.
findings[].categoryasync, dependencies, validation, response-model, error-handling, auth, database, cors, performance, configuration, testing, observabilityvalidation and response-model are separate on purpose: an unvalidated request body and a route leaking a full ORM object are different problems with different fixes.
findings[].severity
findings[].likelihood
low, medium, highSeverity is how bad it is when it happens; likelihood is how reachable it is from the code as pasted, at the traffic described in context.
findings[].prioritycritical, high, medium, lowSeverity by likelihood, adjusted for target. critical is reserved for something that breaks or exposes the service now on a production deployment: blocking I/O on the event loop of a public route, a database session leaked per request, a route returning an ORM object with password or token fields, a committed secret, or an unauthenticated route returning another user's records.
checks[].statuspass, fail, partial, unknownunknown is a legitimate answer when the paste does not show enough to decide, and is preferred over a guess. partial means the practice is present but incomplete — response models on some routes, a global exception handler that swallows the cause.

The twelve checks

checks always carries these twelve, in this order, on every run — so a table can be rendered by index and two reviews of the same project are diffable row by row:

1.  Async correctness                   7.  Authentication and secrets
2.  Dependency injection                8.  Database session lifecycle
3.  Request validation                  9.  CORS and middleware
4.  Response models                    10.  Pagination and payload limits
5.  Status codes                       11.  Configuration
6.  Error handling                     12.  Testing and observability

The review never echoes a secret value. If the paste contains a literal API key, a database URL with a password or a JWT signing secret, the finding names the setting and says to rotate it — the value itself does not appear in problem, snippet or refactored_route.

A CI gate

The readiness value is the natural exit code. Fail the job when a service drifts into not-ready, warn on needs-work, and pass on production-ready — with the Idempotency-Key derived from the input so a re-run of the same commit replays instead of re-billing.

READINESS=$(printf '%s' "$REVIEW" | python3 -c 'import sys,json;print(json.load(sys.stdin)["readiness"])')
CRITICAL=$(printf '%s' "$REVIEW" | python3 -c 'import sys,json;print(sum(1 for f in json.load(sys.stdin)["findings"] if f["priority"]=="critical"))')

case "$READINESS" in
  not-ready)        echo "::error::FastAPI review: not ready ($CRITICAL critical)"; exit 1 ;;
  needs-work)       echo "::warning::FastAPI review: needs work"; exit 0 ;;
  production-ready) echo "FastAPI review: production ready"; exit 0 ;;
esac