All endpoints under /api. JSON in/out except where noted. Parameter names accept both snake_case (canonical) and camelCase.
-
Auth: send
Authorization: Bearer <token>for any route marked "auth required". A token is one of: a regular user JWT (web/mobile), a personal access token (stohr_pat_…), or an OAuth access token. Some routes refuse OAuth tokens — those are called out below. -
Errors: every non-2xx response has the shape
{ "error": "<message>" }, sometimes with extra structured fields (retry_after,quota_bytes, etc.). The status codes Stohr uses:status meaning 400 malformed JSON / missing required field 401 missing or invalid bearer token; bad credentials 402 storage quota exceeded — { error, quota_bytes, used_bytes, attempted_bytes, breakdown }403 authenticated but no permission for this resource 404 resource doesn't exist or isn't visible to you 409 conflict (e.g. email already in use, invite already used) 410 resource gone (expired share token) 422 validation error 429 rate-limited — { error, retry_after }(seconds until you can retry)500 unexpected server error -
Rate limits: see SECURITY.md for the per-bucket numbers. The 429 body always includes
retry_after. -
OAuth scopes (when calling with an OAuth access token):
read/write/share. The guard derives the scope a request needs:GET/HEADneedread, anything under/sharesneedsshare, every other method needswrite. Tokens that lack it return 403 witherror: "Insufficient scope — '<needed>' is required, token has [<granted>]". Session JWTs and PATs are not scope-checked.
| method | path | body | returns |
|---|---|---|---|
GET |
/setup |
— | { needsSetup } (true if zero users; always false on a team host) |
POST |
/signup |
{ name, email, username, password, invite_token? } |
user + JWT |
POST |
/login |
{ identity, password } (identity = email or username) |
user + JWT, or { mfa_required, mfa_token } |
POST |
/login/mfa |
{ mfa_token, code? | backup_code? } |
user + JWT |
The first user — needsSetup === true — bypasses invite_token and is flagged is_owner: true. Login returns a 5-minute MFA challenge JWT when the account has TOTP enabled; finish via /login/mfa. The challenge is single-use (its jti is consumed on success) and is not a bearer credential — presenting it as Authorization: Bearer returns 401.
Pending collaborator invites addressed to the signup email are attached to the new account only when invite_token was itself bound to that email (i.e. was mailed there); a typed-in address proves nothing.
Login + signup are rate-limited per IP / per identity / per user (see SECURITY.md).
With ROOT_DOMAIN set (TEAMS.md), every route is served for the team the request host resolves to: an unknown subdomain is 404 {"error":"Unknown team"}, a suspended team 403. Login, MFA, password reset, passkey login and invites only match that team's users; a credential issued on one team's host is 401 on any other. Signup on a team host requires an invite for that team.
| method | path | body | returns |
|---|---|---|---|
POST |
/password/forgot |
{ email } |
always { ok: true, message } (does not reveal whether the email is on file) |
POST |
/password/reset |
{ token, new_password } |
{ ok: true } (token consumed; sessions and app credentials revoked) |
The reset token is a stohr_pwr_… value delivered by email. 1-hour TTL, atomically single-use. Successful reset revokes sessions, pending MFA challenges, PATs, S3 keys, OAuth credentials, and other reset links; app clients must reconnect. Per-email and per-IP rate-limited. The token in the email URL is the only handle — query-string interception in Referer is mitigated by the global Referrer-Policy: strict-origin-when-cross-origin and by the reset-page being on Stohr's own origin.
Passkeys can be used for either passwordless login (discoverable credentials) or as a stronger second factor.
| method | path | body | returns |
|---|---|---|---|
GET |
/me/passkeys |
— | list of registered credentials (id, name, transports, last_used_at, created_at) |
POST |
/me/passkeys/register/start |
— | WebAuthn PublicKeyCredentialCreationOptions; client passes to navigator.credentials.create() |
POST |
/me/passkeys/register/finish |
{ name?, response: <browser response> } |
the new credential row |
PATCH |
/me/passkeys/:id |
{ name } |
{ ok } |
DELETE |
/me/passkeys/:id |
— | { deleted } |
POST |
/login/passkey/discover/start |
— | PublicKeyCredentialRequestOptions for navigator.credentials.get() |
POST |
/login/passkey/discover/finish |
{ response: <browser response> } |
user + JWT (no password required) |
Server validates origin, RP ID, signature, and counter regression. Challenges live in webauthn_challenges with a 5-minute TTL.
| method | path | body | returns |
|---|---|---|---|
GET |
/me |
— | full user plus team: { id, slug, name }, team_admin, is_root |
PATCH |
/me |
{ name?, email?, username?, discoverable?, current_password? } |
fresh user; token (new JWT) only for session callers when name/email/username changed (other sessions revoked) |
POST |
/me/password |
{ current_password, new_password } |
{ ok, revoked_other_sessions } |
DELETE |
/me |
{ password } |
account + files purged |
GET |
/users/search?q= |
— | up to 10 user matches (excludes self) |
GET |
/u/:username |
— | public user record |
Profile changes, password change and account deletion reject OAuth access tokens — only first-party (web/mobile JWT or PAT) callers allowed. Changing email or username requires current_password (422 without it, 401 if wrong; rate-limited 10 / 15 min per user). A PAT caller never receives a token — the browser sessions are revoked and the PAT keeps working.
| method | path | notes |
|---|---|---|
GET |
/me/sessions |
active session list with current flag |
DELETE |
/me/sessions/:jti |
revoke one |
POST |
/me/sessions/revoke-others |
revoke every session except this one |
PATs and OAuth tokens cannot reach these routes — only first-party JWTs.
| method | path | body | returns |
|---|---|---|---|
GET |
/me/mfa |
— | { enabled, enabled_at, backup_codes_remaining } |
POST |
/me/mfa/setup |
— | { secret, otpauth_url } (show as QR; not yet enabled) |
POST |
/me/mfa/enable |
{ code } |
{ ok, backup_codes } (10 single-use codes) |
POST |
/me/mfa/disable |
{ password, code } |
{ ok } |
POST |
/me/mfa/backup-codes |
{ password } |
{ backup_codes } (regenerated; old set invalidated) |
Enable/disable revokes every other session for the user. disable and backup-codes verify the password and are rate-limited (10 / 15 min per user) like /me/password.
PATs (stohr_pat_…) are long-lived bearer tokens for SDKs and native apps. The full token is shown once at creation.
| method | path | body | returns |
|---|---|---|---|
GET |
/me/apps |
— | tokens (no secret value) |
POST |
/me/apps |
{ name, description? } |
{ id, name, description, token_prefix, token, last_used_at, created_at } |
DELETE |
/me/apps/:id |
— | { revoked } |
| method | path | body | returns |
|---|---|---|---|
GET |
/me/usage |
— | storage usage + the per-user cap: { quota_bytes, used_bytes, active_bytes, trash_bytes, version_bytes } (quota_bytes: 0 = unlimited) |
| method | path | notes |
|---|---|---|
GET |
/me/s3-keys |
list (no secret) |
POST |
/me/s3-keys |
mint; secret_key returned once |
DELETE |
/me/s3-keys/:id |
revoke |
See S3.md for using the keys.
| method | path | body | returns |
|---|---|---|---|
GET |
/folders?parent_id=<id|null> |
— | folder list |
GET |
/folders/:id |
— | folder + breadcrumb trail + role + owner |
POST |
/folders |
{ name, parent_id?, kind?, is_public? } |
folder |
PATCH |
/folders/:id |
{ name?, parent_id?, kind?, is_public? } |
updated |
DELETE |
/folders/:id |
— | soft-delete |
POST |
/folders/:id/restore |
— | restore from trash |
DELETE |
/folders/:id/purge |
— | permanent delete |
GET/POST/DELETE |
/folders/:id/collaborators[/:cid] |
— | collaborator CRUD |
kind values: standard (default), photos, screenshots. Only owners can mutate kind or is_public.
| method | path | notes |
|---|---|---|
GET |
/files?folder_id=<id|null> or ?q=<search> |
list / search |
GET |
/files/:id |
metadata |
GET |
/files/:id/download |
streams the blob; add ?inline=1 for inline disposition (only honored for image/*, video/*, audio/*, application/pdf, text/plain — anything else is forced to attachment) |
GET |
/files/:id/thumb |
streams the WebP thumbnail (images only); 404 if none |
POST |
/files |
multipart/form-data, optional folder_id field |
PATCH |
/files/:id |
{ name?, folder_id? } |
DELETE |
/files/:id |
soft-delete |
POST |
/files/:id/restore |
restore |
DELETE |
/files/:id/purge |
permanent |
GET |
/files/:id/versions?limit=&offset= |
paginated { items, total, limit, offset } — current + archived |
GET |
/files/:id/versions/:v/download |
specific version |
POST |
/files/:id/versions/:v/restore |
promote to live |
DELETE |
/files/:id/versions/:v |
delete archived |
GET/POST/DELETE |
/files/:id/collaborators[/:cid] |
collaborator CRUD |
Private downloads and thumbnails require authentication, including when used as browser image sources. With ClamAV configured, downloads, thumbnails, and archived-version downloads return 403 until the matching blob has a clean verdict. API responses use Cache-Control: no-store.
Re-uploading to the same (folder, name) archives the previous live version and increments version. Upload returns 402 if the new size would exceed the user's storage quota.
| method | path | body | returns |
|---|---|---|---|
GET |
/shares |
— | your active shares |
POST |
/shares |
{ file_id, expires_in?, password?, burn_after_view? } |
share |
DELETE |
/shares/:id |
— | revoked |
GET |
/s/:token |
— | streams blob (public, sets Content-Disposition: attachment; pass ?inline=1 to inline) |
GET |
/s/:token?meta=1 |
— | metadata for preview pages |
expires_in is required (max 30 days). Password-protected shares verify via the X-Share-Password request header — query-string passwords (?p=) are not accepted. The inline Content-Type is restricted to a safe-MIME allowlist (images, video, audio, PDF, plain text); anything else is forced to application/octet-stream + Content-Disposition: attachment. Burn-after-view shares atomically delete the row before serving — only one non-owner viewer wins.
| method | path | notes |
|---|---|---|
GET |
/trash |
files + folders where deleted_at IS NOT NULL |
DELETE |
/trash |
empty trash (purges everything in one cascade) |
Per-row restore / purge live on /files/:id and /folders/:id (see above).
Toggling is_public on a folder publishes it at /p/:owner/:folderId:
| method | path | returns |
|---|---|---|
GET |
/p/:username/:folderId |
folder + owner + file list |
GET |
/p/files/:id |
streams blob (only if file's folder is public) |
GET |
/p/files/:id/thumb |
streams thumbnail |
GET /search?q=<query>&limit=<n> returns { files, folders } ranked by pg_trgm similarity.
The q string is tokenized:
type:image|video|audio|document|textfilters by mime classext:pdffilters by extension- everything else joins into a name fragment (case-insensitive
ILIKE,%and_escaped)
Folder/file collaborators are added by username or email:
POST /folders/:id/collaborators
{ "identity": "alice@example.com", "role": "editor" }
If identity is an email and no user has it, the response includes invite_token — paste https://stohr.io/signup?invite=<token> in the email you send. Pending email invites auto-resolve into real collaborators when that user signs up.
GET /shared returns folders + files directly shared with the current user.
| method | path | notes |
|---|---|---|
GET |
/invites |
invites the current user has minted (no plaintext token returned) |
POST |
/invites |
{ email? } — bind to an email or leave open. Response includes token once; copy immediately. On a tenant host (ROOT_DOMAIN set, not the root team) only a team_admin may call this: 403 otherwise |
DELETE |
/invites/:id |
revoke if unused |
GET |
/invites/:token/check |
public — check if a token is valid |
Tokens are stored as SHA-256 hashes; the plaintext is only ever returned in the response to the create call. List/admin views show metadata only. An invite is bound to the minter's team and redeems on that team's host only.
See ACTIONS.md for the model, event list, and how to write a built-in.
| method | path | notes |
|---|---|---|
GET |
/actions/registry |
public list of available actions + their configSchema |
GET |
/folders/:id/actions |
actions attached to this folder |
POST |
/folders/:id/actions |
{ event, slug, config?, enabled? } (owner only) |
PATCH |
/folders/:id/actions/:aid |
{ event?, config?, enabled? } (owner only) |
DELETE |
/folders/:id/actions/:aid |
(owner only) |
GET |
/folders/:id/actions/runs?limit= |
recent runs for this folder (default 50, max 200) |
When an action fires during a request (e.g. a POST /files upload into an action folder), the matching run summaries are appended to each result entry as action_results.
See OAUTH.md for the full flow walk-through.
User-facing endpoints:
| method | path | notes |
|---|---|---|
GET |
/.well-known/oauth-authorization-server |
RFC 8414 discovery |
GET |
/oauth/authorize/info |
(auth required) info for the consent screen |
POST |
/oauth/authorize/approve |
(auth required) issue auth code |
POST |
/oauth/authorize/deny |
(auth required) reject |
POST |
/oauth/device/authorize |
start device flow (RFC 8628) |
GET |
/oauth/device/info |
(auth required) device-code lookup |
POST |
/oauth/device/approve |
(auth required) approve a device code |
POST |
/oauth/device/deny |
(auth required) deny a device code |
POST |
/oauth/token |
code → tokens, refresh, device → tokens |
POST |
/oauth/revoke |
revoke a refresh token |
All /admin/* routes require auth.is_owner === true.
| method | path | notes |
|---|---|---|
GET |
/admin/users |
all users (every team) + storage usage, team_id, team_admin |
POST |
/admin/users/:id/owner |
toggle is_owner (root team users only) |
POST |
/admin/users/:id/quota |
set a per-user storage cap in bytes (0 = unlimited) |
DELETE |
/admin/users/:id |
immediate hard delete: rows cascade, blobs and staged uploads are dropped (same path as /team/users/:id). 422 for yourself or a tenant team's last active admin |
GET |
/admin/invites?filter= |
system-wide invite list (unused/used/all) |
DELETE |
/admin/invites/:id |
revoke unused |
GET |
/admin/stats |
aggregate counts |
GET |
/admin/audit?event=&user_id=&limit= |
audit event log (max 500 per call) |
GET |
/admin/oauth/clients |
registered OAuth apps |
POST |
/admin/oauth/clients |
register a new client (returns client_secret once if confidential) |
PATCH |
/admin/oauth/clients/:id |
edit name / redirect URIs / scopes / is_official |
POST |
/admin/oauth/clients/:id/rotate-secret |
issue a fresh client_secret |
DELETE |
/admin/oauth/clients/:id |
revoke (existing tokens stop working) |
See TEAMS.md. quota_bytes null or 0 means unlimited.
| method | path | body | notes |
|---|---|---|---|
GET |
/admin/teams?filter=live|deleted|all |
— | teams with usage, user_count, base_url |
POST |
/admin/teams |
{ slug, name?, quota_bytes?, admin_email, admin_name?, admin_username? } |
creates the team + its first admin; returns { team, admin, set_password_url, emailed }. 422 for a reserved/malformed slug or while ROOT_DOMAIN is unset, 409 for a taken slug or email |
GET |
/admin/teams/:id |
— | one team with stats |
PATCH |
/admin/teams/:id |
{ name?, quota_bytes?, suspended? } |
root cannot be suspended |
DELETE |
/admin/teams/:id |
— | soft delete; purged with users, files and blobs after 24h |
POST |
/admin/teams/:id/restore |
— | undo inside the window |
The owner-only user tools, pinned to the team the request host resolves to. The instance owner passes on the root host. A team admin cannot see users of other teams (404), cannot touch an owner, cannot set is_owner, and the last active admin of a team cannot be demoted, suspended or deleted.
| method | path | body | notes |
|---|---|---|---|
GET |
/team |
— | own team: quota_bytes, usage, user_count, base_url |
GET |
/team/users |
— | members + storage usage |
POST |
/team/users |
{ email, name?, username?, password?, team_admin? } |
creates a member; without password returns set_password_url (one-time link on this host, also emailed). Shares the 30-per-15-min identity budget with PATCH |
GET |
/team/users/:id |
— | same shape as /admin/users/:id |
PATCH |
/team/users/:id |
{ name?, email?, username?, team_admin? } |
email/username changes are limited to 30 per 15 min per admin (429); a 409 is logged as admin.identity_conflict |
POST |
/team/users/:id/suspend |
{ reason? } |
revokes sessions |
POST |
/team/users/:id/unsuspend |
— | |
POST |
/team/users/:id/reset-password |
— | { emailed, reset_url } as the admin route |
POST |
/team/users/:id/message |
{ subject, body } |
system message |
DELETE |
/team/users/:id |
— | immediate hard delete, blobs dropped |
GET |
/team/invites?filter= |
— | this team's invites |
POST |
/team/invites |
{ email? } |
invite bound to this team; token returned once |
DELETE |
/team/invites/:id |
— | revoke unused |
GET |
/team/audit?event=&user_id=&limit= |
— | this team's audit events; rows the instance owner logged against the team carry actor: "Platform admin" with user_id, username, user_email, ip, user_agent nulled |
See S3.md — entirely separate auth path using s3_access_keys rows.
| method | path | notes |
|---|---|---|
PUT |
/s3/:bucket/<key> |
upload |
GET |
/s3/:bucket/<key> |
download |
GET |
/s3/:bucket?prefix=<p> |
list objects |
HEAD |
/s3/:bucket/<key> |
metadata |
DELETE |
/s3/:bucket/<key> |
remove |
bucket = your username; key = slash-separated stohr file path.