JSON API#

Everything under /api/v1 speaks JSON. It exists for two consumers: the app's own JavaScript, and you, poking at it with curl.

The live console is the reference

The written reference below can drift; the API console cannot. It introspects the Flask URL map at request time and lists every route this instance actually has, with the view function's docstring and a try it panel. Read this page for the conventions, then use the console for the current surface.

Conventions#

Base path/api/v1. The version is in the path, not a header. A breaking change means /api/v2 alongside it.

Content type — requests that carry a body send Content-Type: application/json; every response is application/json. Form encoding is not accepted.

Profile — there is no authentication and no API key. The API acts as the profile in your session cookie, exactly like the HTML pages. A curl with no cookie jar therefore acts as the default profile. To act as another profile, drive a session:

curl -s -c jar.txt http://localhost:8080/ > /dev/null          # get a session
curl -s -b jar.txt -c jar.txt -X POST http://localhost:8080/profiles/2/select
curl -s -b jar.txt http://localhost:8080/api/v1/ping

Status codes

Code Means
200 Fine
201 Created; the body carries the new resource
204 Fine, nothing to say
400 Your JSON was malformed or a field was invalid
404 No such thing
422 Well-formed but semantically wrong (an unknown case key, a score out of range)
500 Our fault

Errors are a consistent shape. The 404 and 500 handlers in app/__init__.py branch on the path, so a bad URL under /api/ never returns an HTML error page:

{"error": "not_found", "path": "/api/v1/nope"}

Times are integer milliseconds, everywhere, in both directions. Formatting is the client's job.

Timestamps are ISO 8601 with a UTC offset: 2026-08-12T18:24:03+00:00.

Identifiers — cases are addressed by their stable key (oll-21, pll-t), not by their database id. Ids change when the library is rebuilt; keys do not.

Resource shapes#

The nouns the API deals in, matching the data model:

Case

{
  "key": "oll-21",
  "phase": "oll",
  "name": "OCLL7 — Double Cross",
  "number": 21,
  "group": "all-edges-oriented",
  "probability": "1/108",
  "setup_moves": "R U2 R' U' R U R' U' R U' R'",
  "algorithms": [
    {"moves": "R U2 R' U' R U R' U' R U' R'", "is_primary": true, "move_count": 11}
  ],
  "score": 62.5,
  "starred": false
}

score is null for a case with no assessment and no attempts — it is not 0. See Scoring; treating null as 0 in a client is the single easiest way to break the "unknown work sorts first" behaviour.

Attempt

{"case": "oll-21", "algorithm_id": 12, "ms": 2340, "is_dnf": false,
 "source": "drill", "scramble": "y R U2 R' U' R U R' U' R U' R'",
 "created_at": "2026-08-12T18:24:03+00:00"}

Solve

{"total_ms": 19420, "cross_ms": 2380, "f2l_ms": 9110,
 "oll_ms": 3900, "pll_ms": 4030, "penalty": "none",
 "scramble": "D2 F' L2 B U R2 F' D B2 R L' U2 F R2 D' B' U L2 F2 D"}

Splits are individually nullable; a solve timed without them is valid.

Track — the solve-track document, unchanged. POST /api/v1/tracks is what the viewer falls back to when a track is too long to fit in a permalink; it stores the document and returns a short id usable as /viewer/?id=<id>.

The routes#

The set of routes is still growing, and the console is authoritative for what exists right now. The shape they follow:

Method and path Does
GET /api/v1/ping Liveness for the API itself. Returns {"ok": true}
GET /api/v1/cases The library. Filter by phase, group, starred, q
GET /api/v1/cases/<key> One case with its algorithms and your score
POST /api/v1/cases/<key>/star Toggle the star
POST /api/v1/cases/<key>/assess {"score": 0..5} — a self-assessment
POST /api/v1/attempts Record one timed rep
GET /api/v1/scores Every case score for the active profile
GET /api/v1/scores/summary Per-phase scores, for the spider graph
POST /api/v1/solves Record a full solve with optional splits
GET /api/v1/scramble A fresh scramble; ?case=<key> for a case scramble, ?seed= to reproduce one
POST /api/v1/tracks Store a track, get a short id back
GET /api/v1/tracks/<id> Retrieve a stored track

GET /healthz sits outside the API prefix and is what the Compose health check calls: {"status": "ok", "version": "1.0.0"}.

Using the console#

Open /docs/api. Every registered route is listed with its methods, its rule, its endpoint, the blueprint it belongs to and its docstring — including the HTML routes, so it doubles as a site map. Filter by path, method or blueprint, or untick Only /api/v1 to see everything.

Routes under /api/v1 get a try it panel:

  • editable path parameters, with the URL rebuilt live as you type;
  • an editable JSON body for anything that is not GET or DELETE;
  • a query-string field;
  • Send, which fires the request from your browser and shows the status, the elapsed time, the response size and the pretty-printed body.

These are real requests against real data

The console uses your current session, so a POST writes to the profile you are actually using. It asks you to confirm before any non-GET request, but the safe habit is to create a scratch profile and switch to it before experimenting.

Because the console reads the live URL map, a route added by any module shows up as soon as it is registered — nothing needs updating here for it to appear.

Calling it from a script#

BASE=http://localhost:8080

curl -s "$BASE/api/v1/ping" | python3 -m json.tool

curl -s "$BASE/api/v1/cases?phase=oll&starred=1" | python3 -m json.tool

curl -s -X POST "$BASE/api/v1/attempts" \
  -H 'Content-Type: application/json' \
  -d '{"case": "oll-21", "ms": 2340, "source": "external"}'

Remember the session-cookie caveat above: without a cookie jar you are the default profile.

Adding a route#

app/blueprints/api.py owns the whole API surface.

  1. Add the view to that module. Write a docstring — its first line is the summary the console shows, and a route with no docstring is rendered with a visible "No docstring on the view function" note, which is deliberate shaming.
  2. Return jsonify(...), or a (body, status) tuple. Never return HTML.
  3. Validate input and return 400 or 422 with the standard error shape rather than letting an exception become a 500.
  4. Anything that writes an assessment or an attempt must call scoring.refresh_case_score(profile_id, case_id) afterwards.
  5. Add a test in tests/test_api.py. See Testing.

The console picks it up with no further work.

Source: docs/developer/api.md