API reference
The wrapper ships a live, auto-generated API reference. Route definitions are
colocated with their handlers (@hono/zod-openapi), aggregated into one OpenAPI
3.1 document, and rendered with Scalar. That document is the source of truth for
request/response shapes, parameters, and status codes — this page is a map, not
a duplicate of every field.
The live reference
Section titled “The live reference”| Resource | Path |
|---|---|
| Scalar interactive UI | /api/v1/docs (alias /api/v1/reference) |
| Raw OpenAPI 3.1 JSON | /api/v1/openapi.json |
On the running stack: http://api.wsw.local/api/v1/docs.
For an LLM- and agent-oriented single-file guide to the whole API, the wrapper
also publishes /llms.txt at the site root — see the
llms.txt reference.
All routes are versioned under /api/v1. Authenticated routes use
Authorization: Bearer <token-or-key> (or, for the portal, an HttpOnly session
cookie). The security scheme is registered as BearerAuth in the spec.

Endpoint groups
Section titled “Endpoint groups”The tables below map the surface by area. Consult the Scalar UI for exact bodies
and responses. Endpoints marked (+ ext) also have an
/ext/:externalId/… variant that addresses a user by integrator-supplied id.
Health & docs
Section titled “Health & docs”| Method | Path | Notes |
|---|---|---|
| GET | /health |
liveness (grows into dependency checks) |
| GET | /docs, /reference |
Scalar UI |
| GET | /openapi.json |
the spec |
Auth (portal)
Section titled “Auth (portal)”| Method | Path | Notes |
|---|---|---|
| POST | /auth/login |
JWT in HttpOnly cookie — or { two_factor_required, challenge_token } when 2FA is on |
| POST | /auth/logout |
blocklists the token’s jti |
| GET | /auth/me |
current session (cookie-first, Bearer fallback) |
| POST | /auth/forgot-password |
anti-enumeration; emails a reset token |
| POST | /auth/reset-password |
single-use token; sets a new password |
| POST | /auth/2fa/enroll · /activate · /disable · GET /status |
TOTP management — see Two-factor auth |
| POST | /auth/2fa/login |
complete a 2FA challenge (TOTP or recovery code) → session |
Admin (super admin)
Section titled “Admin (super admin)”| Method | Path | Notes |
|---|---|---|
| GET / POST | /admin/projects |
list / create — body {name,email,password}; create returns the pak_ key once (201), 409 on duplicate email |
| GET / PATCH | /admin/projects/:id |
detail / update — {name?,email?} (422 if neither given, 409 on email clash) |
| PATCH | /admin/projects/:id/status |
enable / disable — {active:boolean} |
| POST | /admin/projects/:id/rotate-key |
replace all keys with one new key (plain key once) |
| DELETE | /admin/projects/:id |
hard delete + FK cascade; R2 objects reaped async → 204 |
| GET | /admin/stats/global |
aggregate 30-day + storage across all projects |
| GET | /admin/stats/projects |
per-project breakdown |
| GET / PUT | /admin/settings/rate-limit |
global rate-limit fallback — see Admin settings |
| GET / PUT | /admin/settings/audit-retention |
audit retention window — see Admin settings |
Every /admin/* route is admin-session-gated: 401 with no session, 403
when the session role is not admin.
Deleting a project — the async orphan reaper
Section titled “Deleting a project — the async orphan reaper”DELETE /admin/projects/:id is a hard delete. One FK cascade removes the
tenant’s whole footprint — integrations, keys, plans, users, files, spaces,
daily_stats, and (through files) compression_jobs. Audit rows survive the
cascade: they are retained with a null project_id, so the delete itself is
still auditable after the tenant is gone.
What the cascade does not do inline is delete the project’s R2 objects. A
large tenant can hold thousands of objects; deleting them one-by-one in-request
would blow past the HTTP timeout and, worse, if the loop died halfway it would
leave objects deleted but the project row intact — or objects orphaned with no
row to retry them. Instead the handler queues every object for the async
orphan reaper (lib/orphaned-objects.ts).
The queue-and-cascade is a single transaction that starts by locking the project
row FOR UPDATE. That lock serializes against the FOR KEY SHARE lock an
in-flight upload holds while inserting its files row, so there is no window in
which a concurrent upload leaks an object: any row that commits does so before
the lock and is captured by the enqueue (a DB-side INSERT…SELECT, so keys never
touch app memory); any upload starting after cannot insert once the project is
gone. Because the enqueue and the cascade commit together, the reaper never sees
a key whose files row still lives. The reaper then retries the deletes on a
bounded, capped schedule. Returns 204.
Project — keys & integrations (owner-only)
Section titled “Project — keys & integrations (owner-only)”| Method | Path | Notes |
|---|---|---|
| GET / POST | /project/keys |
list / create PROJECT_API_KEYs (max 10 active) |
| DELETE | /project/keys/:keyId |
soft-deactivate |
| GET / POST | /project/integrations |
list / create integrations (declare scopes here) |
| GET / PUT / DELETE | /project/integrations/:id |
manage an integration |
| POST | /project/integrations/:id/rotate-key |
rotate the integration key |
These are require-owner: an integration key can never reach them.
Project — plans
Section titled “Project — plans”| Method | Path | Notes |
|---|---|---|
| GET | /project/plans, /project/plans/:id |
plans:read |
| POST / PUT / DELETE | /project/plans, /project/plans/:id |
owner-only (definitions) |
| PUT | /project/plans/:id/default |
exactly one default per project (atomic swap) |
Project — users
Section titled “Project — users”| Method | Path | Notes |
|---|---|---|
| GET / POST | /project/users |
list / create — require-provisioner for mutations |
| GET / PUT / DELETE | /project/users/:id (+ ext) |
manage a user |
| PUT | /project/users/:id/plan (+ ext) |
assign a plan (plans:assign) |
| GET | /project/users/:id/storage (+ ext) |
quota: used / available / percent / over-quota |
| POST | /project/users/:id/tokens (+ ext) |
mint a user token (users:read) |
| POST | /project/users/:id/tokens/revoke (+ ext) |
revoke by jti |
Project — files
Section titled “Project — files”| Method | Path | Notes |
|---|---|---|
| GET | /project/users/:id/files (+ ext) |
list; filters: folder, tags, classifier, mime_type, visibility, space_id, cursor |
| GET | /project/users/:id/files/folders (+ ext) |
directory-style browse: ?prefix= → child folders + direct files |
| POST | /project/users/:id/files (+ ext) |
multipart upload (files:write) |
| GET | /project/users/:id/files/:fileId (+ ext) |
detail (+ url_expires_at for private) |
| PUT | /project/users/:id/files/:fileId (+ ext) |
update metadata / visibility |
| DELETE | /project/users/:id/files/:fileId (+ ext) |
hard delete (storage + DB + quota) |
Folder browsing
Section titled “Folder browsing”GET /project/users/:id/files/folders (+ ext) turns the flat folder field
into a directory-style browse. Given ?prefix= (default root) it returns the
immediate child folder names under that prefix plus the files that live directly
in it: { prefix, folders: [names], files: [...], next_cursor }, with limit
(1–100, default 20) and cursor for the file page.
The child-folder names are derived in the app from the user’s distinct
folder values — no user string is ever interpolated into a SQL LIKE, so the
browse cannot be turned into an injection or a cross-user scan. The route is
registered before /files/{fileId} so its static folders segment wins over
the {fileId} param. It requires files:read, and a space-bound token is
confined to its own space: the space_id filter is appended to both the
folder-derivation query and the file query, so a space token never sees a sibling
space’s folders.
Project — images & jobs (async compression)
Section titled “Project — images & jobs (async compression)”| Method | Path | Notes |
|---|---|---|
| POST | /project/users/:id/images (+ ext) |
upload → 202 { job_id, file_id } (files:write) |
| GET | /project/jobs/:jobId |
poll job status (files:read); 404 on ownership mismatch |
See the image compression guide.
Project — spaces (Rutas)
Section titled “Project — spaces (Rutas)”| Method | Path | Notes |
|---|---|---|
| GET / POST | /project/users/:id/spaces (+ ext) |
list / create — require-space-manager |
| GET / PUT / DELETE | /project/users/:id/spaces/:spaceId (+ ext) |
manage a space |
| PUT | /project/users/:id/spaces/:spaceId/credentials (+ ext) |
set / rotate the space password |
| POST | /project/spaces/login |
space login (spaces:login) → space token |
| POST | /project/spaces/token/revoke |
revoke a space token by jti |
Project — stats
Section titled “Project — stats”| Method | Path | Notes |
|---|---|---|
| GET | /project/stats/overview |
today + 30 days + storage |
| GET | /project/stats/files |
on-demand MIME / classifier distribution |
| GET | /project/stats/storage |
users, files, over-quota, public/private split |
| GET | /project/stats/requests |
daily request series |
| GET | /project/users/:id/stats (+ ext) |
subject-pinned per-user stats |
All stats routes require stats:read.
Notes on conventions
Section titled “Notes on conventions”- 404 over 403 for existence — a subject/space-bound token that targets a
resource it does not own gets
404, so existence is never leaked across tenants or subjects (job status, cross-space files). - Rate-limit headers —
X-RateLimit-Limit/-Remaining/-Reseton every rate-limited (non-dev-mode integration)/project/*response;429+Retry-Afterwhen exceeded. - One-time secrets —
PROJECT_API_KEYand integration keys are returned once on create/rotate and stored only as SHA-256 hashes.