# KeepOrg HTTP API Every `/api/` endpoint takes and returns JSON and needs `Authorization: Bearer `, except the login, device and invite endpoints. Errors carry `{"code": "...", "error": "..."}`; `code` is stable and meant to be matched on, `error` is for humans. HTML is served at `/web` (sign in, list vaults, create one, change your password) and at `/device`, `/invite/{token}`, `/oidc/login` and `/oidc/callback`. `/oidc/login` with no parameters signs a browser in and ends in a session cookie; with `?code=` it approves a waiting client instead and ends in a token that client polls for. The web pages use the same session tokens in a cookie that is `HttpOnly`, `SameSite=Strict` and `Secure` behind https, and every form carries a per-session CSRF token. ## Authentication | Method | Path | Body | Answer | | --- | --- | --- | --- | | POST | `/api/v1/auth/login` | `{username, password, totp, label}` | `{token, expires_at, user}`; `401 totp_required` when a second factor is enrolled and missing, `401 totp_invalid`, `401 bad_credentials` | | POST | `/api/v1/auth/logout` | – | revokes the calling token | | GET | `/api/v1/auth/whoami` | – | the account and its group memberships | | POST | `/api/v1/auth/password` | `{current_password, new_password, totp}` | changes the account password; the current one is required, and the second factor when enrolled. Every other session of that account is revoked | | POST | `/api/v1/auth/totp/enroll` | – | `{secret, uri}`; not active until confirmed | | POST | `/api/v1/auth/totp/confirm` | `{code}` | `{totp_enabled: true}` | | POST | `/api/v1/auth/device/start` | `{label}` | `{device_code, user_code, verification_url, interval, expires_in}` | | POST | `/api/v1/auth/device/poll` | `{device_code}` | `202 {status: "pending"}`, then `200 {token, ...}` once; `410 expired` | A device code is single use: the first successful poll consumes it. ## Synchronization | Method | Path | Notes | | --- | --- | --- | | GET | `/api/v1/databases` | `{databases: [{id, name, perm, merge_strategy, version, sha256, size, updated_at, updated_by}]}` | | GET | `/api/v1/databases/{id}/blob` | bytes; `ETag: ""`, `X-KeepOrg-SHA256`; `204` when the database has no content yet; `?version=` reads a stored older version | | PUT | `/api/v1/databases/{id}/blob` | body is the KDBX file. `If-Match` is required | | GET | `/api/v1/events` | SSE. Accepts `?access_token=` because an event stream cannot always set headers | ### PUT semantics `If-Match` carries the version the edit was based on, or `*` to force. | Condition | Status | Body | | --- | --- | --- | | `If-Match` missing | `428 precondition_required` | – | | version matches, bytes differ | `200` | `{version, sha256, unchanged: false}` | | version matches, bytes identical | `200` | `{version, sha256, unchanged: true}` | | version stale, strategy is `merge` | `200` | `{version, sha256, merged: true, merge_summary, based_on}` — the two copies were folded together and the result stored as a new version | | version stale, strategy is `merge`, but merging failed | `409 merge_failed` | `{error, current_version, merge_strategy}`; the stored vault is untouched | | version stale, any other strategy | `409 conflict` | `{current_version, merge_strategy}`, plus `ETag` and `X-KeepOrg-Strategy` | | `If-Match: *`, strategy is `client-wins` | `200` | the server copy is replaced | | `If-Match: *`, any other strategy | `409 force_not_allowed` | – | | read-only access | `403 forbidden` | – | | no access | `404 not_found` | a database you cannot read does not exist to you | | larger than `max_db_size_mb` | `413 too_large` | – | `X-KeepOrg-SHA256` on a `PUT` is optional; when present it must match the body or the request is rejected with `400 checksum_mismatch`. After any accepted push, compare the `sha256` in the answer with what you sent. If they differ the server stored something else — a merge — and the local copy must be replaced by a fresh `GET`. ### Events ``` event: db-updated data: {"type":"db-updated","database_id":"...","name":"Team","version":7,"sha256":"...","by":"alice"} event: db-list-changed data: {"type":"db-list-changed","database_id":"..."} ``` `db-updated` follows an accepted push; `db-list-changed` follows a grant, rename, strategy change or deletion. A comment line (`: keepalive`) arrives every 25 seconds. Reconnect on any error and reconcile fully; events are notifications, never state. ## Vaults A vault is a database the server created and can open. Creating one is the only way the server comes to hold a KDBX key, and the only strategy that needs one is `merge`. | Method | Path | Body | Needs | | --- | --- | --- | --- | | POST | `/api/v1/vaults` | `{name, owner_group_id, merge_strategy, password}` | group admin of the owner | | POST | `/api/v1/databases/{id}/key` | `{password}` or `{forget: true}` | database admin | `POST /api/v1/vaults` builds an empty KDBX 4 database, encrypts it with `password`, stores it as version 1 and answers with the database view. The password must be at least 12 characters. For `merge_strategy: "merge"` (the default here) the password is kept, encrypted under the server's master key; for every other strategy it is used once and forgotten. A server with no master key refuses with `400`. `POST /api/v1/databases/{id}/key` stores the password of a database that already has content, after checking that it opens the stored version, which is how an uploaded database becomes mergeable. `{"forget": true}` removes it; that is refused while the strategy is still `merge`. The wrapped key is never returned by any endpoint. ## Administration Permission needed is in the last column. "Group admin" means administering the group named or any group above it. | Method | Path | Body / query | Needs | | --- | --- | --- | --- | | GET | `/api/v1/groups` | – | any account; lists what you can see | | POST | `/api/v1/groups` | `{name, parent_id}` | group admin of the parent; server admin for a root group | | DELETE | `/api/v1/groups/{id}` | – | group admin; refuses while it has children, members or databases | | POST | `/api/v1/groups/{id}/members` | `{username or user_id, role}` | group admin | | DELETE | `/api/v1/groups/{id}/members/{user}` | – | group admin | | POST | `/api/v1/databases` | `{name, owner_group_id, merge_strategy}` | group admin of the owner | | PATCH | `/api/v1/databases/{id}` | `{name?, merge_strategy?}` | database admin | | DELETE | `/api/v1/databases/{id}` | – | database admin; deletes every stored version | | POST | `/api/v1/grants` | `{group_id, database_id, perm}` | database admin | | DELETE | `/api/v1/grants` | `?group_id=&database_id=` | database admin | | POST | `/api/v1/invites` | `{group_id, role, ttl_hours, max_uses}` | group admin | | GET | `/api/v1/invites` | – | your own invites, and those of groups you administer | | GET | `/api/v1/users` | – | yourself, plus members of groups you administer | | GET | `/healthz` | – | unauthenticated liveness | `merge_strategy` is one of `merge`, `server-wins`, `client-wins`, `manual`. Switching to `merge` requires a stored key and is otherwise refused with `400`. `perm` is one of `read`, `write`, `admin`. `role` is `member` or `admin`.