← Django Clinic / API
Tokens

Drive Django Clinic from your own code

Everything the web page does is available over HTTP: paste a Django project in, get the same structured security audit back. The natural use is a CI job that re-audits whenever settings.py changes, or a script that runs the same twelve-control check across every service in a fleet and fails the build when one of them drifts into exposed.

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: django-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. 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="django-clinic"
TOKEN="YOUR_TOKEN"        # from https://django-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
}

2. 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. You never need to open the developer console.

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

# A guest token is enough for /me and /estimate. Running an audit is metered and
# needs a personal token: open the token page and press "Sign in".
#
#   https://django-clinic.skillsafe.ai/tokens.html
#
# That page also gives you a ready-made shell export:
#   export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead:
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" -H "X-App-Slug: django-clinic"

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 files — settings.py, urls.py, views, models, forms, serializers, middleware, templates, requirements.txt. Put a # file: settings.py marker line above each one so they can be told apart. This is the audit's only evidence. A file whose middle has been removed should say so with a [... clipped ...] comment.
posturestringThe deployment posture to audit against: production, staging, internal or unknown. It changes priority and framing, not what counts as a finding — DEBUG = True on internal is still a finding. unknown is audited against production, and the audit says so in assumptions.
focusstringgeneral, settings, authentication, injection, data-exposure or deployment. Emphasis, not exclusivity: a high-severity finding from another area is never suppressed.
contextstring, optionalFree-form notes: who the users are, whether the app is internet-facing, what sits behind a VPN, what a previous audit found, the deadline.
prescan_factsobject{resources: [{id,label}], flags: [{id,label}]} — what a deterministic client-side scan established: settings keys, middleware entries, installed apps, views, models, serializers, routes and dependencies 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 actual charge is normally far lower than the hold, because the hold prices the full output cap.

INPUT='{"files": "# file: config/settings.py\nimport os\n\nDEBUG = True\nSECRET_KEY = \"django-insecure-REDACTED\"\nALLOWED_HOSTS = [\"*\"]\n\nMIDDLEWARE = [\n    \"django.contrib.sessions.middleware.SessionMiddleware\",\n    \"django.middleware.security.SecurityMiddleware\",\n    \"django.middleware.csrf.CsrfViewMiddleware\",\n]\n\n# file: billing/views.py\ndef invoice_detail(request, pk):\n    return render(request, \"invoice.html\", {\"invoice\": Invoice.objects.get(pk=pk)})\n\n# file: requirements.txt\nDjango==3.2.18", "posture": "production", "focus": "general", "context": "Internet-facing billing app, 6 engineers, audit ahead of a SOC 2 review.", "prescan_facts": {"resources": [{"id": "res:setting/DEBUG", "label": "Setting DEBUG = True"}, {"id": "res:dependency/Django", "label": "Dependency Django==3.2.18"}], "flags": [{"id": "settings:debug-true", "label": "DEBUG = True in config/settings.py"}, {"id": "settings:allowed-hosts-wildcard", "label": "ALLOWED_HOSTS = [\"*\"]"}]}}'

call estimate "$INPUT"
# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
#   "markup_bps":1000,"hold_credits":1890,"min_credits":280,"sponsor_enabled":false}}
#
# estimate is FREE. It creates no job and charges nothing. hold_credits is what
# gets RESERVED; the charge afterwards 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 audit JSON is the string at data.output.output.

Always send an Idempotency-Key. Derive it from the input, as the web app does (django-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="django-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 and the truncated flag.

# Server-sent events. Each `delta` carries a chunk of the JSON audit; 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":"{\"audit_name\":\"billing"}
# event: delta  {"text":" — production security audit\","}
# event: done   {"status":"succeeded","charged_credits":438,"truncated":false}

7. Parse the audit 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 you established — treat that as a failed run, not a passing one, and retry with a retry_note naming the missing ids.

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 an audit: retry with a retry_note asking for fewer, denser findings rather than trying to repair the JSON.

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

printf '%s' "$AUDIT" | python3 -c '
import sys, json
a = json.load(sys.stdin)
print(a["posture"], "|", a["verdict"])
print(a["django_version"], a["deploy_target"], a["settings_module"])
for c in a["controls"]:
    print(f"  {c[\"status\"]:8} {c[\"control\"]}")
for f in a["findings"]:
    print(f"  {f[\"id\"]} {f[\"priority\"]:8} {f[\"category\"]:14} {f[\"resource\"]}")
'

# Every prescan flag id must come back exactly once in coverage_check.
printf '%s' "$AUDIT" | python3 -c '
import sys, json
seen = [c["id"] for c in json.load(sys.stdin)["coverage_check"]]
want = ["settings:debug-true", "settings:allowed-hosts-wildcard"]
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:

{
  "audit_name": "billing-api — production security audit",
  "posture":    "hardened | needs-hardening | exposed",
  "verdict":    "one sentence naming the single thing that decides the posture",
  "django_version":  "4.2.11",
  "deploy_target":   "production | staging | internal | unknown",
  "settings_module": "config/settings/production.py",
  "exec_summary":   "2-3 paragraphs separated by blank lines",
  "assumptions":    ["..."],
  "open_questions": ["..."],
  "inventory": [
    { "kind": "Setting", "name": "DEBUG", "value": "True", "role": "what it controls here" }
  ],
  "controls": [
    { "control": "DEBUG disabled", "status": "missing",
      "evidence": "DEBUG = True at settings.py line 12",
      "requirement": "DEBUG = False in every module a deployed process loads" }
  ],
  "findings": [
    {
      "id": "DJ-001",
      "category":   "settings | authentication | authorization | injection | xss | csrf | data-exposure | dependencies | deployment",
      "severity":   "low | medium | high",
      "likelihood": "low | medium | high",
      "priority":   "critical | high | medium | low",
      "resource": "Setting/DEBUG",
      "problem":  "...",
      "impact":   "...",
      "fix":      "...",
      "snippet":  "corrected Python / template / shell fragment, or \"\""
    }
  ],
  "coverage_check": [
    { "id": "settings:debug-true", "addressed": true, "note": "DJ-001." }
  ],
  "hardened_settings": "a corrected Python settings block, as a JSON string",
  "commands":    ["python manage.py check --deploy  # verify the deployment controls"],
  "quick_wins":  ["..."],
  "focus_areas": [{ "area": "...", "why": "...", "finding_ids": ["DJ-001"] }],
  "summary": "closing paragraph"
}

Every key

keytypemeaning
audit_namestringShort title naming the project and the posture audited against.
postureenumhardened, needs-hardening or exposed. The single value a CI gate should branch on.
verdictstringOne sentence justifying the posture and naming the thing that decides it.
django_versionstringThe version the paste shows, e.g. "4.2.11", or "unknown". Never guessed.
deploy_targetenumEchoes the posture audited against: production, staging, internal, unknown.
settings_modulestringThe settings file the audit is mostly about, or "(missing from the paste)".
exec_summarystringTwo to three paragraphs on the dominant themes, separated by blank lines.
assumptionsstring[]Explicit assumptions filling gaps in the paste — which module production loads, whether a proxy terminates TLS.
open_questionsstring[]Questions whose answers would change the audit or its ordering.
inventoryobject[]{kind, name, value, role}. kind is one of Setting, Middleware, App, View, Model, Serializer, Form, Template, UrlRoute, Query, Dependency.
controlsobject[]{control, status, evidence, requirement}. Always the same twelve controls 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 DJ-001, DJ-002, … Always at least one entry. snippet is a pasteable 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.
hardened_settingsstringA corrected Python settings fragment covering only the keys this project needs changed, with a comment on any line whose value the reader must decide. "" when no settings file was pasted. Never contains a real secret.
commandsstring[]Ordered shell commands, each with a trailing comment. Read-only — nothing that deletes, migrates or deploys. Always ends with python manage.py check --deploy.
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
posturehardened, needs-hardening, exposedhardened: the deployment controls are set as a set and the rest is improvement work. needs-hardening: named controls are missing or weak, but nothing is exposing data today. exposed: at least one finding means data or access is available to an attacker right now.
findings[].categorysettings, authentication, authorization, injection, xss, csrf, data-exposure, dependencies, deploymentAuthentication and authorization are separate categories on purpose: a missing @login_required and an unscoped object lookup are different findings.
findings[].severity
findings[].likelihood
low, medium, highSeverity is how bad it is if exploited; likelihood is how reachable it is from the code as pasted.
findings[].prioritycritical, high, medium, lowSeverity by likelihood, adjusted for posture. critical is reserved for something exposing data or granting access now on an internet-facing deployment: DEBUG = True in production, a committed SECRET_KEY or database password, a request-reachable injection, or an unauthenticated view returning another user's records.
controls[].statuspresent, missing, weak, unknownunknown is a legitimate answer when the paste does not show enough to decide, and is preferred over a guess. weak means the control exists but does not do its job — a short HSTS max-age without includeSubDomains, say.

The twelve controls

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

1.  DEBUG disabled                      7.  Clickjacking protection
2.  ALLOWED_HOSTS restricted            8.  Content-type sniffing disabled
3.  SECRET_KEY out of source            9.  CSRF middleware active
4.  HTTPS redirect and HSTS            10.  Password validators configured
5.  Secure session cookie              11.  Authorization enforced per object
6.  Secure CSRF cookie                 12.  Query parameterization

The audit never echoes a secret value. If the paste contains a literal SECRET_KEY, a database password or an API key, the finding names the setting and says to rotate it — the value itself does not appear in problem, snippet or hardened_settings.

Your saved audits

Every run the app completes is written to the audits collection, so an audit follows the user across devices. It is declared acl_read: owner and acl_write: user: rows are scoped to the calling subject, which means a script must reuse one token across the run and the query or it will see an empty collection. Each POST /guest mints a new guest subject, so guest tokens are not a way to share history.

fieldtypemeaning
uidstringThe app's own id for the run — stable across a re-render.
titlestringaudit_name from the reply.
posturestringhardened, needs-hardening or exposed.
verdictstringThe one-sentence verdict.
django_versionstringThe version the paste showed, or unknown.
settings_modulestringThe settings file the audit was mostly about.
input_hashstringHash of the submitted input — the cheap way to tell whether a project actually changed between runs.
findings_countnumberfindings.length.
critical_countnumberHow many findings came back priority: "critical".
ran_attimestampWhen the run completed. The natural sort key.

Those ten fields are declared, and therefore filterable and orderable. The rest of the document — the whole audit — round-trips intact but is not indexed. embed is ["title", "verdict", "posture"], so POST /collections/audits/similar with a text or a record_id finds past audits that read like this one: useful for "have we seen this failure shape before?" across a fleet of services.

Every where entry must be an operator object — eq, ne, lt, lte, gt, gte, in (up to 20 values) or contains. The bare-value shorthand {"posture": "exposed"} is rejected. Records come back wrapped: data.records[].doc holds the fields, alongside a record_id.

# Your saved audits, newest first.
call collections/audits/query '{"order_by":[{"field":"ran_at","dir":"desc"}],"limit":10}'

# Only the ones that came back exposed with something critical in them.
call collections/audits/query '{"where":{"posture":{"eq":"exposed"},"critical_count":{"gte":1}},
  "order_by":[{"field":"ran_at","dir":"desc"}],"limit":20}'

# Past audits that read like this one, using the declared embed fields.
call collections/audits/similar '{"text":"DEBUG left on in production with a committed SECRET_KEY","limit":5"}'

A CI gate

The posture value is the natural exit code. Fail the job when a service drifts into exposed, warn on needs-hardening, and pass on hardened — with the Idempotency-Key derived from the input so a re-run of the same commit replays instead of re-billing. Comparing input_hash against the last row in the audits collection tells you whether it is even worth spending the credits.

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

case "$POSTURE" in
  exposed)         echo "::error::Django audit: exposed ($CRITICAL critical)"; exit 1 ;;
  needs-hardening) echo "::warning::Django audit: needs hardening"; exit 0 ;;
  hardened)        echo "Django audit: hardened"; exit 0 ;;
esac