API reference
Everything the web UI does goes through this API, so anything the UI can do,
you can script. Authentication is the session cookie — sign in with
POST /api/login and keep the cookie:
curl -c jar -X POST http://localhost:8080/api/login \
-H 'Content-Type: application/json' \
-d '{"username":"andre","password":"..."}'
curl -b jar http://localhost:8080/api/groups
Requests and responses are JSON. Unknown fields in a request body are an error rather than being ignored, so a misspelled field tells you immediately instead of silently doing nothing.
Errors are {"error": "..."} with a meaningful status.
Auth in the tables below
- public — no session needed
- session — any signed-in account
- read / write / owner — the caller's level on the group in question (see Groups & sharing)
- admin — the admin role
Setup and session
| Method | Path | Auth | Notes |
|---|---|---|---|
GET |
/api/setup-status |
public | {"needs_setup": bool} |
POST |
/api/setup |
public | Only while no account exists. {username, password} |
POST |
/api/login |
public | Rate-limited per address |
POST |
/api/logout |
session | |
GET |
/api/session |
session | The current user |
POST |
/api/password |
session | {current_password, new_password}; revokes other sessions |
Groups
| Method | Path | Auth | Notes |
|---|---|---|---|
GET |
/api/groups |
session | {"owned": [...], "shared": [...]} |
POST |
/api/groups |
session | {name, color?} |
GET |
/api/groups/{id} |
read | |
PATCH |
/api/groups/{id} |
owner | {name?, color?} |
DELETE |
/api/groups/{id} |
owner | Requires {"confirm": true} |
PUT |
/api/groups/order |
session | {ids: [...]} — exactly your own groups |
GET |
/api/groups/{id}/bookmarks |
read | |
PUT |
/api/groups/{id}/bookmarks/order |
write | {ids: [...]} — exactly this group's members |
Every group carries access: owner, editor or viewer. The client never
infers permissions; the server states them.
Sharing
| Method | Path | Auth | Notes |
|---|---|---|---|
GET |
/api/groups/{id}/access |
owner | |
POST |
/api/groups/{id}/access |
owner | {username | user_id, role} |
PATCH |
/api/groups/{id}/access/{userId} |
owner | {role} |
DELETE |
/api/groups/{id}/access/{userId} |
owner | Idempotent |
DELETE |
/api/groups/{id}/access |
owner | Stop sharing with everyone |
role is viewer or editor.
Bookmarks
| Method | Path | Auth | Notes |
|---|---|---|---|
GET |
/api/bookmarks |
session | Everything you can reach; ?group_id= to narrow |
POST |
/api/bookmarks |
write | {group_id, url, title?, plate?, icon_data_url?} |
GET |
/api/bookmarks/{id} |
read | |
PATCH |
/api/bookmarks/{id} |
write | {url?, title?, plate?, group_id?}; a new group_id moves it |
DELETE |
/api/bookmarks/{id} |
write | |
POST |
/api/bookmarks/{id}/refresh |
write | {title?: bool, force?: bool} |
GET |
/api/bookmarks/{id}/icon |
read | Image bytes, with ETag |
PUT |
/api/bookmarks/{id}/icon |
write | {data_url} |
DELETE |
/api/bookmarks/{id}/icon |
write |
Omitting title on create makes the server fetch the page itself, so the API
is fully usable without the browser's prefill step.
icon_url is present only when the bookmark has an icon; its absence is the
signal to draw a letter tile. It carries ?v=<content hash>, so the URL changes
exactly when the bytes do and can be cached indefinitely.
plate is plain, light or dark.
Metadata
| Method | Path | Auth | Notes |
|---|---|---|---|
POST |
/api/metadata |
session | {url}; rate-limited per user |
Returns {url, final_url, title, icon_url, icon_data_url, unreachable} and
persists nothing. A failed lookup is still 200, with unreachable set to
private_address_blocked, timeout or unreachable — the add form has to
stay usable when a host is simply switched off.
Users
| Method | Path | Auth | Notes |
|---|---|---|---|
GET |
/api/users |
admin | |
POST |
/api/users |
admin | {username, password, role} |
PATCH |
/api/users/{id} |
admin | {role?, disabled?, password?} |
DELETE |
/api/users/{id} |
admin | Requires {"confirm": true} |
GET |
/api/users/search?q= |
session | Prefix match, min 2 chars, max 10 results |
/api/users/search is not admin-only because a member who owns a group still
has to pick someone to share it with. It excludes the caller and disabled
accounts, and never lists everybody.
Activity
| Method | Path | Auth | Notes |
|---|---|---|---|
GET |
/api/audit-log |
admin | ?limit=, ?action=a,b |
Health
| Method | Path | Auth | Notes |
|---|---|---|---|
GET |
/healthz |
public | {"status":"ok","version":"..."} |
Example: add a bookmark from a script
GID=$(curl -s -b jar localhost:8080/api/groups | jq -r '.owned[0].id')
curl -s -b jar -X POST localhost:8080/api/bookmarks \
-H 'Content-Type: application/json' \
-d "{\"group_id\":\"$GID\",\"url\":\"https://grafana.lan:3000\"}"
The title and icon are filled in server-side.