monoki docs Install Source

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/bookmarks entirely. There is no alias, deliberately: a bookmark had exactly one group_id and 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

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":"…"}'