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/board
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.
0.2.0 removed
/api/bookmarksentirely. There is no alias, deliberately: a bookmark had exactly onegroup_idand a board item has zero or many, so an alias would have to answer with a field that no longer has an honest value. A 0.1.0 script gets a 404 and finds out immediately. See Apps, resources & groups for the new model.
Auth in the tables below
- public — no session needed
- session — any signed-in account
- owner — you own the group / resource / board item in question
- app-edit — the app's creator, one of its responsible users, or an admin
- app-owner — the app's creator, or an admin
- admin — the admin role
404 vs 403. Anything private (a group, a resource, a board item) answers
404 when it is not yours, so no id can be probed by watching which ones answer
differently. The shared catalog is the only place that produces a 403: you can
already see every app, so a refusal to edit one has nothing left to hide.
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 |
The catalog
| Method | Path | Auth | Notes |
|---|---|---|---|
GET |
/api/apps |
session | ?q= name or host, ?on_board=true|false, ?limit= |
GET |
/api/apps/lookup |
session | ?url=<raw pasted url> → {host, matches} |
POST |
/api/apps |
session | See below |
GET |
/api/apps/{id} |
session | |
PATCH |
/api/apps/{id} |
app-edit | {url?, title?, description?, plate?, link_target?, confirm?} |
DELETE |
/api/apps/{id} |
app-edit | Requires {"confirm": true}; removes it from every board |
POST |
/api/apps/{id}/refresh |
app-edit | {title?: bool, force?: bool} |
GET |
/api/apps/{id}/icon |
session | Image bytes, with ETag |
PUT |
/api/apps/{id}/icon |
app-edit | {data_url} |
DELETE |
/api/apps/{id}/icon |
app-edit | |
GET |
/api/apps/{id}/responsibles |
session | |
POST |
/api/apps/{id}/responsibles |
app-owner | {username | user_id} |
DELETE |
/api/apps/{id}/responsibles/{userId} |
app-owner, or yourself | Idempotent |
POST /api/apps body:
{
"url": "grafana.lan:3000",
"title": "Grafana", // optional — omit and the server reads the page
"description": "…",
"plate": "plain", // plain | light | dark
"link_target": "reuse", // reuse | new | self
"icon_data_url": "data:…", // optional; revalidated server-side regardless
"group_ids": ["…"], // your own groups only
"add_to_board": true, // default true
"allow_duplicate": false // see below
}
It answers 201 {"app": {...}, "item": {...}} — the catalog entry, and the board
row it created (null when add_to_board is false).
The duplicate check
Adding an app on a host the catalog already knows answers 409:
{
"error": "an app on grafana.lan already exists — resend with allow_duplicate: true to add another",
"reason": "duplicate_host",
"host": "grafana.lan",
"existing": [ { …app… } ]
}
Resend with "allow_duplicate": true to create it anyway. This is the same kind
of guard as {"confirm": true} on the destructive endpoints: a stray script
cannot make a duplicate by accident, and a person who means it says so once.
GET /api/apps/lookup answers the same question without writing anything, and
takes the raw pasted URL rather than a host — so a client never reimplements
the normalisation rules and cannot drift from them.
Changing an app's url when board_count > 1 needs {"confirm": true} too.
App fields
can_edit and on_board are computed per request and per caller; the client
never infers them. board_item_id is set when on_board is true.
window_target is the exact string to hand window.open and <a target>. It is
server-computed so that every surface showing one app agrees on it — see
Security.
icon_url is present only when the app 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.
Resources
Private deep links. Owner-only throughout, so every failure here is a 404.
| Method | Path | Auth | Notes |
|---|---|---|---|
GET |
/api/resources |
session | Yours only |
POST |
/api/resources |
session | Like POST /api/apps minus add_to_board and allow_duplicate; always lands on your board |
GET |
/api/resources/{id} |
owner | |
PATCH |
/api/resources/{id} |
owner | |
DELETE |
/api/resources/{id} |
owner | Requires {"confirm": true} |
POST |
/api/resources/{id}/refresh |
owner | |
GET/PUT/DELETE |
/api/resources/{id}/icon |
owner | As apps |
Your board
| Method | Path | Auth | Notes |
|---|---|---|---|
GET |
/api/board |
session | ?kind=app|resource |
POST |
/api/board |
session | {app_id, group_ids?} → 201 added / 200 already there |
GET |
/api/board/{id} |
owner | |
DELETE |
/api/board/{id} |
owner | Takes an app off your board. Refused for resources |
PUT |
/api/board/order |
session | {ids: [...]} |
PUT |
/api/board/{id}/groups |
owner | {group_ids: [...]} — replaces the whole tag set |
DELETE /api/board/{id} on a resource item answers 400 pointing at
DELETE /api/resources/{id}. A resource is reachable only through its board
item, so "remove from board" would silently mean "destroy forever".
Groups
Private per-user tags. Every route here is owner-only, and "not yours" is 404.
| Method | Path | Auth | Notes |
|---|---|---|---|
GET |
/api/groups |
session | A flat array (0.1.0 returned {owned, shared}) |
POST |
/api/groups |
session | {name, color?} |
GET |
/api/groups/{id} |
owner | |
PATCH |
/api/groups/{id} |
owner | {name?, color?} |
DELETE |
/api/groups/{id} |
owner | {"confirm": true}. Removes the tag; the apps stay on your board |
PUT |
/api/groups/order |
session | {ids: [...]} — exactly your own groups |
GET |
/api/groups/{id}/items |
owner | In this group's own order |
POST |
/api/groups/{id}/items |
owner | {board_item_id} or {app_id} (adds to your board first) |
DELETE |
/api/groups/{id}/items/{itemId} |
owner | Remove from group. The item stays on your board |
PUT |
/api/groups/{id}/items/order |
owner | {ids: [...]} |
Ordering
There are two independent orderings: your board's, and one per group. A drag inside a group does not reshuffle your board.
Both board and group reorder endpoints accept a subset. The listed ids are
reassigned to the positions they already occupied, sorted ascending; unlisted ids
never move. That is what makes the kind-filtered views and the search filter
draggable at all. An id that is not in that scope, or a duplicate, is a 400.
PUT /api/groups/order is the exception and still demands exactly your own
groups: the sidebar always renders every one, so a partial list means the client
is confused rather than filtered.
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.
Kept separate from /api/apps/lookup on purpose: this one reaches out across the
network and is rate-limited, and the duplicate hint has to keep working when the
far end is down or the limiter has already fired.
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 has to be able to pick who
is responsible for an app they added. 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 |
Actions worth filtering on: app.create, app.create_duplicate (somebody
deliberately made a second app on a known host), app.update (records
url: old -> new when the address changes), app.responsible_add,
board.add, board.remove.
Databases upgraded from 0.1.0 still hold bookmark.* and group.share rows.
Nothing rewrites history.
Health
| Method | Path | Auth | Notes |
|---|---|---|---|
GET |
/healthz |
public | {"status":"ok","version":"..."} |
Example: add an app from a script
curl -s -b jar -X POST localhost:8080/api/apps \
-H 'Content-Type: application/json' \
-d '{"url":"https://grafana.lan:3000"}'
The title and icon are filled in server-side, and it lands on your board. If the
catalog already has that host you get a 409 — add "allow_duplicate": true if
you meant it, or take the existing[0].id and put that on your board instead:
curl -s -b jar -X POST localhost:8080/api/board \
-H 'Content-Type: application/json' -d '{"app_id":"…"}'