Skip to content

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.

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.

The Scalar UI rendering the live OpenAPI spec at /api/v1/docs.

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.

Method Path Notes
GET /health liveness (grows into dependency checks)
GET /docs, /reference Scalar UI
GET /openapi.json the spec
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
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.

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)
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
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)

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.

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
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.

  • 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 / -Reset on every rate-limited (non-dev-mode integration) /project/* response; 429 + Retry-After when exceeded.
  • One-time secrets — PROJECT_API_KEY and integration keys are returned once on create/rotate and stored only as SHA-256 hashes.