MethodBackend
partial some parts checked against the code, others not yet · what the statuses mean
API reference — routes by resource
In simple terms
This is the catalogue of everything the frontend can ask the backend to do. Every
route is thin: it parses the request, calls one PtxApi method (the orchestrator),
and returns JSON. The base URL is http://localhost:8000 (set by VITE_API_BASE on
the frontend). When the app is shared on the office Wi-Fi (serve.py --share, since
2026-11-04) the same routes are also served over HTTPS under /api — https://<mac>.local:8443/api/projects
is GET /projects — beside the built app, on one origin; every path below is the same
under the prefix. The container image serves them the same way, under /api beside the
page (serve.py --behind-proxy, Run with Docker). Routes are declared in backend/api/app.py; the logic lives in
backend/api/controllers.py.
Conventions. Bad input → 422 with a specific message (domain
ValueErrormapped at the route). Missing resource → 404. Long solves are async: aPOSTstarts a job, aGETpolls it. See the backend architecture and the feasibility solve flow.The solve queue. At most
PTX_SOLVE_WORKERSengine jobs run at once (core/solve_queue.py); a job started while every worker is busy waits its turn. Its status — in the start answer, in every job status route (GET /feasibility,…/sweep,…/right-size/landscape,…/finder/search|study|surface|grid,…/book/impact,…/runs/{kind}/last, the emissions screen) and inGET /projects/{id}/runs— is then"queued"withposition(1 = the next to start) andahead(how many jobs run or wait before it, every workspace’s counted, never named), and noprogress; then"running"as before. In the runs list a queued run also hasqueued: trueand keepsrunning: true(in flight) andstoppable. Stopping a queued job takes it out of the line; starting it again while it waits keeps its place. A request that answers within itself and runs the LP (Least cost; Scale the plant and Grid on against off on the solver;POST /feasibility/solve?wait=true) waits for its turn before it solves, and its run readsqueuedmeanwhile. What it means for users: the feasibility suite, “When the server is busy”.
The sections below explain the main resources. The complete list — every route
app.py declares, with the PtxApi method it calls — is the generated table in the
section Every route, near the end.
Health & sites
| Method · Path | Does |
|---|---|
GET /health |
Liveness check. |
GET /sites |
List library sites (datasets nested as {wind, solar}), read-only. |
GET /sites/{site_id} |
One site + its datasets. |
Projects
| Method · Path | Does |
|---|---|
GET /projects |
The signed-in workspace’s projects (a flat digest of each one’s design, who last edited it, its products with their pathway); ?home=1 adds each stage’s result digest — see The projects home. |
POST /projects |
Create a project (guided essentials + optional site attach). Unknown derivative fields → 422. |
GET /projects/{id} |
The full project record. |
DELETE /projects/{id} |
Delete + full cascade (snapshots, book, both version axes, result files). |
GET /projects/{id}/inputs |
The resolved sizing/topology + effective CFs + site provenance (what the rooms render). |
PATCH /projects/{id}/config |
Update sizing (capacities, storage, derivatives, mode). |
PUT /projects/{id}/site |
Link a site + per-carrier datasets (validates carrier/site membership). |
Solve & optimise
| Method · Path | Does |
|---|---|
POST /projects/{id}/solve |
Recompute prefeasibility against the book. ?mode=annual|timeseries overrides for this solve only (not persisted). Returns the SolveBundle. |
POST /projects/{id}/right-size |
Run the right-size optimiser (body = locked components). Returns updated inputs. |
POST /projects/{id}/right-size/landscape |
Start the async LCOA-surface computation (Mode B). |
GET /projects/{id}/right-size/landscape |
Poll the landscape job → axes + grid + current/optimum points. |
Assumption book (per project)
| Method · Path | Does |
|---|---|
GET /projects/{id}/book |
The working book rows (value/unit/source/note + reference + default). |
PATCH /projects/{id}/book |
Update values and/or notes. Values range-validated → 422 on breach. |
GET /projects/{id}/book/export |
Download the book as the standard .xlsx. |
POST /projects/{id}/book/import |
STRICT .xlsx import → diff preview. Upload capped (413) + zip-bomb guarded; filename ignored. |
Version axes (book + sizing — identical shape)
| Method · Path | Does |
|---|---|
GET /projects/{id}/versions · /sizing-versions |
List versions (active flagged). |
POST …/versions/save |
Overwrite the active version with the working set. |
POST …/versions/save-as |
Branch a new version (becomes active). |
POST …/versions/{version_id}/switch |
Load a version into the working set → re-solve. |
DELETE …/versions/{version_id} |
Delete a non-active version (never the last). |
(The same five verbs exist under /sizing-versions for the sizing axis.)
| Method · Path | Role | Does |
|---|---|---|
GET /projects/{id}/sizing-versions/active/diff?stage= |
viewer | The working design against its ACTIVE sizing version: the version’s own values (saved, flat — the keys the dirty flag compares, the two technology selectors when the version holds them, each product as derivatives.<kind>.<attr>, products) and the fields that differ now (rows: field, was, now). The design panel’s “was …” marks in prefeasibility (since 2026-11-08). |
Feasibility (async LP + post-process reads)
| Method · Path | Does |
|---|---|
POST /projects/{id}/feasibility/solve |
Start the async hourly LP (?wait=true for sync, after its turn in the solve queue). Answers the job: running, or queued with position and ahead while every worker is busy. See the flow. |
GET /projects/{id}/feasibility |
Job status + persisted summary (?snapshot_id= for a frozen one): none · queued (with position, ahead) · running (with progress on a multi-year solve) · done · error. While queued or running it does not read the store, so no result. Never returns raw hours. |
GET …/feasibility/dispatch |
?res=day → 365 min/mean/max envelopes; ?res=hour&start=&end= → full-res window ≤ 21 days. |
GET …/feasibility/duration |
Descending quantile samples for one series. |
GET …/feasibility/series?name= |
One full-res series (the calendar heatmap; the approved raw-hours exception). |
GET …/feasibility/monthly |
12-bucket aggregates (reconciles to annual). |
GET …/feasibility/reliability |
Contiguous unserved episodes + seasonal histogram + ride-through margins, and promise: each product delivered against the promise saved NOW (demand_t_yr; a snapshot: the one it captured), by the finder’s own rule, with the shortfall hours by cause — production and utilization only when nothing is promised (core/promise.py, since 2026-11-09). |
POST …/feasibility/sweep |
Start a storage sweep (h2 / bess / both grid; per-axis start/step/points). Project never mutated. |
GET …/feasibility/sweep |
Poll the sweep → per-size metrics + a best pick. |
GET …/feasibility/frame?fmt=parquet|csv |
Download the raw hourly frame (snapshot-aware). |
Emissions (since 2026-11-08)
| Method · Path | Role | Does |
|---|---|---|
GET /projects/{id}/emissions?stage= |
viewer | The emissions SCREEN of that stage’s result (PtxApi.emissions, arithmetic in core/emissions/screen.py). Read-only: prefeasibility is solved on demand and stored nowhere; feasibility reads the stored solve, and a stage the project has not opened is not seeded (status: not_solved, or running while a solve runs, or queued with position and ahead while it waits for a worker). Returns rule (comparator 94, minimum saving 70 %, limit 28.2 gCO2e/MJ, the marginal band), grid (import, renewable-PPA share, carbon intensity, sources — the fossil/balancing part at the intensity and the PPA part at zero —, the total, what the PPA keeps out), products (per product: its share of the hydrogen used, the grid energy and CO2 charged to it, screen = rfnbo_check’s own dict, verdict + verdict_label, its grid budget from max_grid_mwh_for_compliance), the plant’s grid budget and the product that binds it, h2_renewable_fraction, basis (stage, engine, commitment, when solved, staleness), targets (RFNBO computed; IMO and EU Taxonomy listed as not computed) and not_modelled. The Grid tab’s RFNBO card is this screen’s largest product, so the two cannot disagree. A screen, not certification. |
Snapshots
| Method · Path | Does |
|---|---|
GET /projects/{id}/snapshots |
List snapshots for a project. |
POST /projects/{id}/snapshots |
Capture a snapshot (book + config + a fresh solve). |
GET /snapshots/{snapshot_id} |
One snapshot’s stored bundle. |
POST /snapshots/{snapshot_id}/restore |
Write the snapshot’s book + config back onto the project. |
GET /snapshots/{snapshot_id}/export/bundle |
Regenerate + download the .zip bundle. |
Exports
| Method · Path | Does |
|---|---|
GET /projects/{id}/export/results |
Regenerate + download the results workbook (.xlsx). |
GET /projects/{id}/export/bundle |
Regenerate + download the full project bundle (.zip). |
Exports are always regenerated on demand from stored inputs + results — never stored (see data model).
Default assumption book (DAB — per workspace, in Settings)
Each workspace has its own default books (since 2026-11-08). Every /dab/* route acts
on the session’s workspace: it sees that workspace’s versions and the book shipped
with the app (shipped-<book>, global, read-only); a version name of another
workspace answers 404 — in the path, in ?version=, in a body (from_version,
from_dab_version) and in the project’s book/dab-pull?version=. Changing a book is
manage_dab (admins); reading and exporting is open to every role.
| Method · Path | Does |
|---|---|
GET /dab/versions |
This workspace’s own versions, then the shipped books: version, label, is_active (what new projects here seed from — the workspace’s active book, else the current shipped one), shipped, read_only, current, book, created_by_name. No file path. |
GET /dab/book |
One book’s rows (?version=; none = what new projects seed from), with shipped / read_only. |
PATCH /dab/book |
Edit one of this workspace’s versions (range-validated; preserves file notes). A shipped book → 422 with what to do instead. The first change of a version whose file is a shipped file copies the file into the workspace’s own folder. |
GET /dab/export · POST /dab/import |
Download any visible book; import (preview, then apply=true) into one of this workspace’s own versions only — a shipped book → 422. |
POST /dab/versions {from_version?, label, make_active} |
A new version of this workspace, copied from one of its own or from a shipped book (none = what new projects here seed from). |
POST /dab/versions/{version}/activate |
Make one of this workspace’s versions the seed of its new projects; on the CURRENT shipped book, go back to seeding from it. An older shipped book → 422. |
DELETE /dab/versions/{version} |
Delete one of this workspace’s non-active versions (its file only if it lies in the workspace’s own folder). A shipped book → 422. |
Editing a default book never mutates existing projects (they were frozen at create) — only future projects of the same workspace seed from it.
Accounts — members, one-time links, the workspace switcher (since 2026-11-04)
Every /workspace… route acts on the workspace the session is in — no workspace
id appears in any path, so there is no other workspace to address. Switching
(POST /me/workspace) is what changes it. The action each route needs is in
backend/core/access.py; what only the owner may do is checked by one helper,
access.require_owner. Services: backend/core/auth/service.py (switch, password,
links) and backend/core/auth/members.py (the workspace’s members); stores:
SQLiteMemberStore and SQLiteInvitationStore in backend/db/identity.py.
| Route | Who | What |
|---|---|---|
GET /me |
anyone signed in | You, the workspace you act in (is_owner), your role, every workspace you belong to — the switcher’s source. |
POST /me/workspace {workspace_id} |
anyone signed in | Move this session into another of your workspaces; answers /me there. Not a member → 404. |
POST /me/password {current_password, new_password} |
anyone signed in | Change your password (at least 10 characters). Every OTHER session of yours ends; this one stays. Throttled like sign-in (429). |
GET /workspace |
viewer | Name, owner, your role, whether you own it. |
PATCH /workspace {name} |
the owner | Rename. Another admin → 403 naming the owner. |
POST /workspace/transfer {user_id} |
the owner | Hand the ownership to another admin member (else 422); you stay an admin. |
POST /workspaces {name} |
admin | A new workspace; you become its owner and admin; this session moves into it. |
GET /workspace/members |
viewer | Name, e-mail, role, owner flag of every member. |
POST /workspace/members {email, role, display_name?} |
admin | An account that can sign in joins at once → {added}. Anyone else → {invited, link_token, expires_at, invitation_id}: a one-time link, valid 7 days, accepted once. The token is in this answer only; the database stores its SHA-256. Already a member → 409. |
PATCH /workspace/members/{user_id} {role} |
admin | Change a role — never the owner’s, never leaving the workspace without an enabled admin (422 with the sentence). |
DELETE /workspace/members/{user_id} |
admin | Remove a member: their sessions in this workspace end with the membership; their account and their rows stay. Never the owner, never the last admin. |
POST /workspace/members/{user_id}/disable · /enable |
admin | Switch a member’s account off (every session of theirs ends at once; sign-in refused with the one sentence) or back on (since 2026-11-08). Only for a person who belongs to no other workspace — otherwise 422 with the sentence “remove them from this workspace instead”; never the owner, never yourself, never the last enabled admin (422). Not a member here → 404. Rows they made keep their author. An admin’s GET /workspace/members carries, per person, account: {can_disable, can_enable, blocked_reason, in_other_workspaces} — whether they are in another workspace, never which. |
GET /workspace/invitations |
admin | The links not used yet — never a token or its hash. |
DELETE /workspace/invitations/{invitation_ref} |
admin | Withdraw a link, by its non-secret id. |
POST /workspace/invitations/{invitation_ref}/new-link |
admin | A new token for a pending link (since 2026-11-04): the old link stops working at once; the new one is in this answer only, valid 7 days from now — {invited, link_token, expires_at, invitation_id} like Add. Not pending here (another workspace’s id included) → 404; the person has an account now → 422 with the sentence. |
GET /sharing |
admin (manage_sharing) |
Whether colleagues on this network can open the app (since 2026-11-04): state off · on · moved (the Mac’s network changed under the shared address — no urls) · stopped (closed itself; sentence says why), urls {name, ip} while on, the command that shares, this Mac’s certificate (fingerprints SHA-256 + SHA-1, end date — public facts only) and the firewall’s state. Sharing is switched on by how the server starts, never by a request. |
GET /sharing/certificate |
admin (manage_sharing) |
This Mac’s certificate authority’s public certificate (PtX-Model-local-CA.crt) for colleagues to install — never the key. Not made yet → 404 with the command. |
GET /auth/invitation?token= |
public | What a link is for (workspace, e-mail, role), or 404 with ONE sentence for a link that was used, withdrawn, expired or never existed. Throttled by client. |
POST /auth/accept {token, display_name, password} |
public | Use a link: the name and first password of its account (only if it has none), the membership, a session. Two accepts of one link: one wins. Throttled by client. |
Lists also name their authors now: book and sizing versions, snapshots and the
projects list carry created_by and created_by_name (null for a person the app
does not know — never an error). A version names who saved it last.
Workspace administration — the super admin, deleting a workspace (since 2026-11-09)
The owner deletes their own workspace; the app’s super admin — the account that
set the app up, others named only from the machine (backend/scripts/super_admin.py)
— sees every workspace’s metadata and manages any of them. The super admin
opens a workspace’s projects only by joining it, which its members see. Every
/admin/… route answers anyone but the super admin 404 {"detail": "Not Found"}
— exactly what an unknown address answers — before any id is looked at. Its rules
were decided on 2026-11-08. Services: backend/core/auth/admin.py (who), PtxApi.delete_workspace and
PtxApi.export_workspace_projects (what); store: SQLiteAdminStore in
backend/db/admin.py; the backup: backend/db/backup.py.
| Route | Who | What |
|---|---|---|
GET /me |
anyone signed in | Now also is_super_admin. |
GET /workspace/delete-preview |
the owner | What deleting this workspace removes: deletion.counts (projects, members, links, default books, sites…), members_left_without_workspace (they could no longer sign in), you_left_without_workspace, last_workspace, backup_folder (as ~/…), blocked (a sentence, or null). Another admin → 403 naming the owner. |
GET /workspace/export/bundles |
the owner | Every project as a project bundle (the bundle export, its confidential choke point included), in one zip with a manifest.json; a project that cannot be solved travels as data only and its entry says so. One that cannot even be read → 422 naming it (nothing downloaded). |
DELETE /workspace {confirm_name} |
the owner | Delete this workspace and everything in it. confirm_name must be its name (spaces around it aside) → else 422, before anything. Something running in it → 409. A backup of the whole database is written first and checked (ptx_projects-<stamp>-before-delete-workspace-<id>.db in PTX_BACKUP_DIR, its own default books copied beside it as …-default-books/) — when it cannot be: 503, nothing deleted. Then its sessions end, each project goes through the project delete cascade, its own rows go in one transaction, then its files. Answers what was removed, the backup’s name, the members left without a workspace, and session: moved (yours, to another workspace of yours — me there) · ended (you have none left) · unchanged. A stop after the backup → 500 with a sentence naming the backup. |
GET /admin/workspaces |
super admin | Every workspace: {workspace_id, name, created_at, owner {user_id, display_name, email}, member_count, project_count, last_activity_at, you {member, role, is_owner, current}} — never a project. |
GET /admin/workspaces/{workspace_id} |
super admin | The same for one, with its admins (who may become its owner) and deletion (as the owner’s preview; can_export only as a member). |
PATCH /admin/workspaces/{workspace_id} {name} |
super admin | Rename any workspace; its members are told when you are not its owner. |
POST /admin/workspaces/{workspace_id}/transfer {user_id} |
super admin | Make one of its admins its owner (else 422; not a member → 404); the old owner stays an admin; its members are told. |
POST /admin/workspaces/{workspace_id}/join {role} |
super admin | Become a member with that role (joined_as_super_admin), written with its event in one transaction — its members see it. Already one → 409. Answers me (the switcher now lists it). |
POST /admin/workspaces/{workspace_id}/leave |
super admin | Stop being a member, visibly; never its owner, never its last enabled admin (422). A session of yours acting there moves to another of your workspaces (or ends). |
GET /admin/workspaces/{workspace_id}/export/bundles |
super admin, as a member | As the owner’s export; not a member → 403 “join it first”. |
DELETE /admin/workspaces/{workspace_id} {confirm_name} |
super admin | As DELETE /workspace, for any workspace. |
A workspace admin cannot switch the super admin’s account off (POST /workspace/members/{user_id}/disable → 422 with the sentence); removing them from
the workspace works as for anyone.
The projects home (since 2026-11-06)
What the page everyone lands on after signing in reads and records. Recording your
own visit, page, the welcome you closed and the What’s new notice you dismissed is the
account action (a viewer may); reading is read. Everything acts in the session’s
current workspace — but the What’s new notice is your account’s, in every workspace.
| Route | Who | What |
|---|---|---|
GET /projects?home=1 |
viewer | The projects with each stage’s result digest (digests — the main product’s cost and unit, the CAPEX, every product’s cost and production, the capacities solved, when and by whom, and what changed since: design / assumptions, null when it cannot be told) and the headline (the furthest stage that has one). Every row also names who last edited it (updated_by_name) and its products with their pathway — without ?home=1 too. |
GET /me |
anyone signed in | Now also membership: joined_at, added_by / added_by_name (who added you; null: not recorded), last_visit_at, show_welcome (never for the workspace’s owner). And whats_new: {version, seen} — the running app’s version, and whether your account dismissed its What’s new notice (seen stays true for a later version you already dismissed). |
POST /me/visit |
anyone signed in | The home opened: {last_visit_at, first_visit, new_visit} — when your previous visit ended. A return within half an hour is the same visit (nothing the home shows changes). |
POST /me/pages {project_id, path} |
anyone signed in | The page of a project you opened (what Continue reopens): only one of that project’s own pages (/projects/<id>/…, 422 otherwise); a project of another workspace → 404. |
GET /me/recent?limit= |
viewer | Your last pages, one per project, newest first. |
POST /me/welcome |
anyone signed in | The one-time welcome was closed; the first time is kept. |
POST /me/whats-new/seen |
anyone signed in | The What’s new notice was dismissed: the running version is recorded for your account (in every workspace) until the next release; a later version already recorded is kept. Answers {version, seen}. Not in the activity log (navigation). |
GET /activity?since=&limit= |
viewer | What OTHER people did in this workspace since since (default: the end of your previous visit): book_version / sizing_version saved, snapshot taken, feasibility solve finished, project created, member joined — and, since 2026-11-09, the app’s super admin’s super_admin_joined (with the role) · super_admin_left · super_admin_renamed (detail.from/to) · super_admin_transferred (detail.from_name/to_name) — each with who, which project and stage, when; newest first, with the total. Your own actions and the automatic first versions are left out; nothing of another workspace is ever listed. A since that is not a time → 422. |
The digests are written by the solves themselves: an editor’s or an admin’s own
prefeasibility solve of the stage (POST /projects/{id}/solve without mode or
stage; a viewer’s solve still stores nothing) and every stored feasibility solve,
which also records meta.solved_by (dropped from every export). At start-up the
server fills the digests a database lacks, in the background.
Every route
Generated from the decorators in backend/api/app.py
(backend/scripts/gen_docs_numbers.py); — means the route answers without PtxApi.
| Method | Routes |
|---|---|
| GET | 78 |
| POST | 65 |
| PUT | 1 |
| PATCH | 7 |
| DELETE | 12 |
| Total | 163 |
| Method | Path | PtxApi method |
|---|---|---|
| GET | /health |
— |
| GET | /auth/state |
— |
| POST | /auth/setup |
— |
| POST | /auth/login |
— |
| POST | /auth/logout |
— |
| GET | /me |
— |
| GET | /auth/invitation |
— |
| POST | /auth/accept |
— |
| POST | /me/workspace |
— |
| POST | /me/password |
— |
| GET | /workspace |
— |
| PATCH | /workspace |
— |
| POST | /workspace/transfer |
— |
| GET | /workspace/delete-preview |
— |
| GET | /workspace/export/bundles |
— |
| DELETE | /workspace |
— |
| GET | /admin/workspaces |
— |
| GET | /admin/workspaces/{workspace_id} |
— |
| PATCH | /admin/workspaces/{workspace_id} |
— |
| POST | /admin/workspaces/{workspace_id}/transfer |
— |
| POST | /admin/workspaces/{workspace_id}/join |
— |
| POST | /admin/workspaces/{workspace_id}/leave |
— |
| GET | /admin/workspaces/{workspace_id}/export/bundles |
— |
| DELETE | /admin/workspaces/{workspace_id} |
— |
| POST | /workspaces |
— |
| GET | /workspace/members |
— |
| POST | /workspace/members |
— |
| PATCH | /workspace/members/{user_id} |
— |
| DELETE | /workspace/members/{user_id} |
— |
| POST | /workspace/members/{user_id}/disable |
— |
| POST | /workspace/members/{user_id}/enable |
— |
| GET | /workspace/invitations |
— |
| DELETE | /workspace/invitations/{invitation_ref} |
— |
| POST | /workspace/invitations/{invitation_ref}/new-link |
— |
| GET | /sharing |
— |
| GET | /sharing/certificate |
— |
| GET | /sites |
list_sites |
| GET | /sites/{site_id} |
get_site |
| GET | /sites/template/timeseries |
timeseries_template |
| GET | /sites/{site_id}/export/timeseries |
export_site_timeseries |
| POST | /sites/import/timeseries |
import_timeseries_preview |
| GET | /projects |
list_projects |
| POST | /me/visit |
record_visit |
| POST | /me/pages |
record_page |
| GET | /me/recent |
recent_pages |
| POST | /me/welcome |
mark_welcome_seen |
| POST | /me/whats-new/seen |
mark_whats_new_seen |
| GET | /activity |
activity |
| GET | /workspace/audit |
— |
| GET | /workspace/audit/export |
— |
| GET | /admin/audit |
— |
| GET | /admin/audit/export |
— |
| POST | /projects |
create_project |
| GET | /projects/examples |
list_examples |
| POST | /projects/examples/{example_id} |
create_example_project |
| GET | /projects/{project_id} |
get_project |
| DELETE | /projects/{project_id} |
delete_project |
| PATCH | /projects/{project_id}/identity |
update_identity |
| POST | /projects/{project_id}/duplicate |
duplicate_project |
| GET | /projects/{project_id}/inputs |
get_inputs |
| PATCH | /projects/{project_id}/config |
update_config |
| PUT | /projects/{project_id}/site |
set_project_site |
| POST | /projects/{project_id}/solve |
solve |
| POST | /projects/{project_id}/right-size |
right_size |
| GET | /projects/{project_id}/versions |
list_versions |
| POST | /projects/{project_id}/versions/save |
save_version |
| POST | /projects/{project_id}/versions/save-as |
save_version_as |
| POST | /projects/{project_id}/versions/{version_id}/switch |
switch_version |
| DELETE | /projects/{project_id}/versions/{version_id} |
delete_version |
| GET | /projects/{project_id}/sizing-versions |
list_sizing_versions |
| GET | /projects/{project_id}/sizing-versions/active/diff |
active_sizing_diff |
| POST | /projects/{project_id}/sizing-versions/save |
save_sizing_version |
| POST | /projects/{project_id}/sizing-versions/save-as |
save_sizing_version_as |
| POST | /projects/{project_id}/sizing-versions/{version_id}/switch |
switch_sizing_version |
| DELETE | /projects/{project_id}/sizing-versions/{version_id} |
delete_sizing_version |
| GET | /projects/{project_id}/book |
get_book |
| GET | /projects/{project_id}/book/compare |
book_compare |
| POST | /projects/{project_id}/book/impact |
start_book_impact |
| GET | /projects/{project_id}/book/impact |
book_impact_status |
| GET | /projects/{project_id}/book/dab-pull |
dab_pull_preview |
| POST | /projects/{project_id}/book/dab-pull |
pull_dab |
| PATCH | /projects/{project_id}/book |
update_book |
| GET | /projects/{project_id}/documents |
documents |
| GET | /projects/{project_id}/book/export |
export_book |
| POST | /projects/{project_id}/book/import |
import_book_preview |
| GET | /projects/{project_id}/export/design |
export_design |
| GET | /projects/{project_id}/export/timeseries |
export_timeseries |
| POST | /projects/import |
import_project_preview |
| POST | /projects/{project_id}/feasibility/solve |
solve_feasibility_waiting |
| POST | /projects/{project_id}/feasibility/optimize |
optimize_feasibility |
| POST | /projects/{project_id}/feasibility/grid-impact |
grid_impact |
| POST | /projects/{project_id}/feasibility/scale-compare |
scale_compare |
| POST | /projects/{project_id}/feasibility/scale-sweep |
scale_sweep |
| POST | /projects/{project_id}/feasibility/grid-sizing-impact |
grid_sizing_impact |
| GET | /projects/{project_id}/runs |
list_runs |
| GET | /projects/{project_id}/runs/{kind}/last |
last_run |
| POST | /projects/{project_id}/runs/{run_id:path}/stop |
stop_run |
| POST | /projects/{project_id}/feasibility/stop |
stop_feasibility_solve |
| GET | /projects/{project_id}/feasibility |
feasibility_status |
| GET | /projects/{project_id}/feasibility/flows |
flows_data |
| GET | /projects/{project_id}/emissions |
emissions |
| GET | /projects/{project_id}/resources |
— |
| GET | /projects/{project_id}/resources/lifetime |
— |
| GET | /projects/{project_id}/resources/compare |
— |
| GET | /projects/{project_id}/feasibility/h2-failures |
— |
| GET | /projects/{project_id}/feasibility/h2-failures/curve |
— |
| GET | /projects/{project_id}/feasibility/monthly |
monthly_data |
| GET | /projects/{project_id}/feasibility/dispatch |
dispatch_data |
| POST | /projects/{project_id}/feasibility/sweep |
start_storage_sweep |
| GET | /projects/{project_id}/feasibility/sweep |
storage_sweep_status |
| POST | /projects/{project_id}/feasibility/sweep/stop |
stop_storage_sweep |
| POST | /projects/{project_id}/right-size/landscape |
start_right_size_landscape |
| GET | /projects/{project_id}/right-size/landscape |
right_size_landscape_status |
| GET | /projects/{project_id}/feasibility/frame |
export_frame |
| GET | /projects/{project_id}/feasibility/lifetime/export |
export_lifetime |
| GET | /projects/{project_id}/feasibility/reliability |
reliability_data |
| GET | /projects/{project_id}/feasibility/series |
series_data |
| GET | /projects/{project_id}/feasibility/duration |
duration_data |
| GET | /projects/{project_id}/snapshots |
list_snapshots |
| POST | /projects/{project_id}/snapshots |
create_snapshot |
| GET | /projects/{project_id}/stages/diff |
stage_diff |
| POST | /projects/{project_id}/stages/pull |
stage_pull |
| POST | /projects/{project_id}/stages/acknowledge |
stage_acknowledge |
| GET | /snapshots/{snapshot_id} |
get_snapshot |
| POST | /snapshots/{snapshot_id}/restore |
restore_snapshot |
| GET | /snapshots/{snapshot_id}/export/bundle |
export_snapshot_bundle |
| GET | /projects/{project_id}/export/results |
export_results |
| POST | /projects/{project_id}/finder/size |
finder_size_to_target |
| POST | /projects/{project_id}/finder/study |
start_finder_study |
| POST | /projects/{project_id}/finder/search |
start_finder_search |
| GET | /projects/{project_id}/finder/explore |
explore_axes |
| POST | /projects/{project_id}/finder/explore |
finder_explore |
| POST | /projects/{project_id}/finder/grid-caps |
grid_cap_scan |
| GET | /projects/{project_id}/finder/search |
finder_search_status |
| GET | /projects/{project_id}/export/finder-runs |
export_finder_runs |
| DELETE | /projects/{project_id}/finder/search |
stop_finder_search |
| POST | /projects/{project_id}/finder/surface |
start_finder_surface |
| POST | /projects/{project_id}/design/apply |
apply_design |
| POST | /projects/{project_id}/finder/apply |
finder_apply |
| GET | /projects/{project_id}/finder/grid/setup |
finder_grid_setup |
| POST | /projects/{project_id}/finder/grid |
start_finder_grid |
| GET | /projects/{project_id}/finder/grid |
finder_grid_status |
| DELETE | /projects/{project_id}/finder/grid |
stop_finder_grid |
| POST | /projects/{project_id}/finder/grid/resume |
resume_finder_grid |
| GET | /projects/{project_id}/export/finder-grid |
export_finder_grid |
| GET | /projects/{project_id}/finder/surface |
finder_surface_status |
| DELETE | /projects/{project_id}/finder/surface |
stop_finder_surface |
| GET | /projects/{project_id}/finder/study |
finder_study_status |
| DELETE | /projects/{project_id}/finder/study |
stop_finder_study |
| GET | /projects/{project_id}/export/report |
export_report |
| POST | /projects/{project_id}/export/report |
— |
| GET | /projects/{project_id}/export/finder-report |
export_finder_report |
| POST | /projects/{project_id}/export/finder-report |
— |
| GET | /projects/{project_id}/export/excel-package |
export_excel_package |
| GET | /projects/{project_id}/export/bundle |
export_bundle |
| GET | /dab/versions |
list_dab_versions |
| GET | /dab/book |
dab_book |
| GET | /dab/export |
export_dab_book |
| POST | /dab/import |
import_dab_preview |
| PATCH | /dab/book |
update_dab_book |
| POST | /dab/versions |
create_dab_version |
| POST | /dab/versions/{version}/activate |
activate_dab_version |
| DELETE | /dab/versions/{version} |
delete_dab_version |
Security posture (honest)
Every route needs a signed-in session except GET /health, GET /auth/state,
POST /auth/setup (the first run only), POST /auth/login (since 2026-11-03) and the
two invitation-link routes, GET /auth/invitation and POST /auth/accept, where the
link’s token is the credential (since 2026-11-04; the access log redacts token= values).
One app-wide dependency (backend/api/auth.py) runs before every route: it turns
the ptx_session cookie (HttpOnly, SameSite=Lax) into a principal — user,
workspace, role — checks the route’s action against the role matrix
(backend/core/access.py; a route missing from the matrix is refused), makes the
principal current, and answers 404 for a project, snapshot, site or default-book
version of another workspace (the same answer as an id that exists nowhere). Below
it, every store method takes the workspace as a required argument. A state-changing
request must carry X-PtX-Request: 1 and, when it says where it comes from, an
allowed Origin — on 127.0.0.1 exactly the configured dev frontend origins
(PTX_DEV_ORIGINS, default http://localhost:5173 and http://127.0.0.1:5173;
since 2026-11-08 — no longer any localhost port). GET /projects lists the signed-in
workspace’s projects only. In front of all of it, the app checks the Host
(since 2026-11-08, backend/api/hosts.py): on plain HTTP only localhost / 127.0.0.1,
over HTTPS only this Mac’s sharing names while sharing — any other name answers
400 before a route runs, however the server was started (a plain uvicorn … too).
Failed sign-ins are counted in the database (login_failures), so a restart does not
reset the throttle.
Over the shared HTTPS address (since 2026-11-04) the same guard runs under /api, with
four differences: the cookie is __Host-ptx_session with Secure; a change must name
its page (Origin) and that page must be one of exactly this Mac’s sharing addresses;
no CORS headers are ever sent (one origin); the PTX_DEV_PRINCIPAL switch is ignored
and POST /auth/setup answers 403. Every answer on both addresses carries the
security headers (nosniff, Referrer-Policy: no-referrer, a Content-Security-Policy
with frame-ancestors 'none' and — since 2026-11-08 — no outside origin at all (the
data font ships with the app: font-src 'self'), X-Frame-Options, and HSTS over
HTTPS); a 500 answers one sentence, never a traceback. Left out on purpose
(2026-11-08, with the reasons on the security page): download tokens, e-mail
verification, and dropping 'unsafe-inline' for styles.
How to modify this safely
- New route = a thin
app.pyhandler that parses + delegates to a newPtxApimethod. No business logic in the route. - A new/changed response field is a three-place change:
core/producer →api.tstype →client.ts/room. Keep theValueError → 422convention. - Keep expensive work async (start-job + poll), and keep raw hours out of JSON
responses except the sanctioned
seriesroute.
Related topics
From the app’s documentation, docs/04-backend/api-overview.md · Hyprisma 1.0.5