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
GETorDELETE; - 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.
- 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.
- Return
jsonify(...), or a(body, status)tuple. Never return HTML. - Validate input and return
400or422with the standard error shape rather than letting an exception become a 500. - Anything that writes an assessment or an attempt must call
scoring.refresh_case_score(profile_id, case_id)afterwards. - Add a test in
tests/test_api.py. See Testing.
The console picks it up with no further work.