Inventory API (v1)
Base URL: https://inventory.metastic.ch/api/v1 (locally http://localhost:8080/api/v1).
- Auth:
Authorization: Bearer inv_…. Org admins create tokens under Settings → API tokens. A token belongs to one organization and only ever sees that organization's data. Tokens are shown once, stored hashed, and can be revoked at any time (effective immediately). - Scopes:
read,write,agent(coding agents: read + todo work) anddeploy(CI: report deploys, push manifests). A token on an endpoint its scope does not allow gets403with"code": "insufficient_scope". See Token scopes for the matrix. Tokens can expire (1, 7, 30, 90 or 365 days, or never); an expired token gets401("error": "token expired; …"). Tokens created before scopes and expiry existed keep their scope and never expire. - Ids are opaque strings with a type prefix (
app_…,srv_…,todo_…,note_…,dep_…,man_…). They are random, not sequential. An id of another organization behaves exactly like an id that does not exist (404). - Bodies are JSON. Unknown fields are rejected (
400), so typos do not go unnoticed.PATCHonly changes the fields you send. Times are RFC 3339 UTC. - Errors:
{"error": "message"}with400(bad JSON or filter),401(missing/invalid/revoked/expired token),402(plan limit, see below),403(scope does not allow it),404,409(claim conflict),422(validation, e.g. a name that exists or a non-http URL),429(rate limit).402,403and429also carry a machine-readablecode(plan_limit,insufficient_scope,rate_limited). - Plan limits: creating an app or server above the organization's plan answers
402 Payment Required:{"error": "Plan limit reached: the Free plan allows at most 5 applications. …", "code": "plan_limit", "limit": "apps", "max": 5, "plan": "free"}(limitisappsorservers). Limits: Free 5 apps / 3 servers, Team 40 / 25, Business 200 / 150; the forge sync interval is 60 / 15 / 5 minutes. Nothing is deleted when a plan is lowered; only new rows are refused.GET /meshows the plan (org.plan). Operator-created organizations (internal) have no limits. (402rather than422, so a client can tell "upgrade or delete something" from "fix your input" by the status alone.) - Rate limits: 300 requests per minute per token (fixed window;
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset) and 600 per minute per organization across all its tokens (token bucket, refills continuously). Over either limit:429withRetry-After(seconds) and"code": "rate_limited". After 30 failed authentications per minute from one IP, that IP gets429for the rest of the minute. Operators change the limits withINVENTORY_API_RATE_TOKENandINVENTORY_API_RATE_ORG. - The only
DELETEendpoint removes an uploaded manifest. Deleting apps, servers and todos is a human action in the UI; agents setstatus: cancelledinstead.
Endpoints
| Method and path | Scopes | Purpose |
|---|---|---|
GET /me |
all | Token name, scope, expiry (expires_at, null = never) and organization (with plan) |
GET /apps |
read, write, agent | List applications (with open_todos) |
POST /apps |
write | Create an application |
GET /apps/{id} |
read, write, agent | One application with its deployments |
PATCH /apps/{id} |
write | Update an application |
POST /apps/{id}/deployments |
write | Assign the app to a server in an environment |
GET /apps/{id}/todos |
read, write, agent | Todos of one application (filters below) |
POST /apps/{id}/todos |
write, agent | Create a todo (optionally with a first note) |
GET /servers |
read, write, agent | List servers |
POST /servers |
write | Create a server |
GET /servers/{id} |
read, write, agent | One server with the apps deployed on it |
PATCH /servers/{id} |
write | Update a server |
GET /todos |
read, write, agent | Todos across all applications (filters below) |
GET /todos/{id} |
read, write, agent | One todo with all notes and events (notes) |
PATCH /todos/{id} |
write, agent | Update title, status, priority, assignee, link; optional note |
PUT /apps/{id}/manifests |
write, deploy | Upload lockfiles/manifests (see Dependencies) |
GET /apps/{id}/manifests |
read, write, agent | Uploaded manifests of an app |
DELETE /apps/{id}/manifests/{manifest_id} |
write | Remove a manifest and its packages (204) |
GET /apps/{id}/dependencies |
read, write, agent | Packages with latest version, distance, vulnerabilities; runtimes with EOL status |
GET /dependencies/summary |
read, write, agent | Per-app counts (outdated, vulnerable, worst runtime EOL) and totals |
GET /dependencies/search?q= |
read, write, agent | Which apps use a package (name contains q; exact=1 for an exact name) |
POST /apps/{id}/todos/from-finding |
write, agent | Todo from a dependency finding or a forge issue, idempotent (see Findings to todos) |
GET /forges |
read, write, agent | Forge connections (never the token, only token_hint) |
GET /apps/{id}/repos |
read, write, agent | Repositories linked to an app, with sync state |
POST /apps/{id}/repos |
write | Link a repository (repo = owner/repo or a URL, optional forge, path, label) |
DELETE /apps/{id}/repos/{repo_id} |
write | Unlink (204) |
POST /apps/{id}/forge/refresh |
write | Sync the app's repositories now (≤ 25 s, 6/min per org) and return repos |
GET /apps/{id}/pulls |
read, write, agent | Open PRs/MRs of an app (see Forge) |
GET /apps/{id}/issues |
read, write, agent | Open issues of an app |
GET /pulls |
read, write, agent | Open PRs across all apps (deduplicated; stale=1) |
GET /issues |
read, write, agent | Open issues across all apps (deduplicated) |
POST /todos/{id}/claim |
write, agent | Atomically take an open todo: in_progress + assignee (default: the org's first agent assignee, drover unless changed under Settings → Agent assignees) |
POST /todos/{id}/notes |
write, agent | Add a markdown note |
PUT /todos/{id}/link |
write, agent | Set the PR/issue URL ("" clears it) |
POST /deploys |
write, deploy | Report a deploy from CI: version, commit, status (see Deploys) |
GET /overview |
read, write, agent | Server overview: servers with their apps, versions, last deploys and red/amber/green/grey status |
Todo list filters (GET /todos, GET /apps/{id}/todos): status = comma-separated list of open,
in_progress, done, cancelled, or all (default open,in_progress); assignee = a member's email or free
text such as drover (case-insensitive); app = app id (/todos only); limit (default 500, max 1000).
Order: in progress first, then open, then by priority (urgent → low), newest first.
Token scopes
Pick the smallest scope that does the job. The matrix is enforced per route and tested route by route
(internal/web/quotas_test.go fails when a new route is not in it).
| Endpoint class | read |
write |
agent |
deploy |
|---|---|---|---|---|
GET /me |
✓ | ✓ | ✓ | ✓ |
All other GET endpoints (apps, servers, todos, dependencies, forges, PRs/issues, overview) |
✓ | ✓ | ✓ | – |
Todo work: POST /apps/{id}/todos, PATCH /todos/{id}, POST /todos/{id}/claim, POST /todos/{id}/notes, PUT /todos/{id}/link, POST /apps/{id}/todos/from-finding |
– | ✓ | ✓ | – |
CI: POST /deploys (its answer is the deploy result), PUT /apps/{id}/manifests |
– | ✓ | – | ✓ |
Everything else that changes data: apps, servers, deployments, DELETE manifests, repository links, forge refresh |
– | ✓ | – | – |
agentis for coding agents such as Drover: they read everything and work on todos (claim, notes, status, PR link, create follow-up todos), but cannot create or change apps, servers, deployments, manifests or repository links. TheinvCLI needs nothing more.deployis for CI pipelines: report a deploy and push lockfiles, nothing else (it cannot even list apps; refer to the app by name or id in the body).- Forbidden:
403 {"error": "this token's scope (deploy) does not allow this request; it needs scope read or write or agent", "code": "insufficient_scope", "scope": "deploy"}.
Objects
Application
{
"id": "app_dxbh589k4kzshxdq", "name": "Cronjay",
"description": "Cron job and heartbeat monitoring (markdown).",
"repo_url": "https://forgejo.example/metastic/passive-income", "app_url": "https://cronjay.metastic.ch",
"tags": ["go", "htmx", "sqlite"], "open_todos": 4,
"url": "https://inventory.metastic.ch/apps/app_dxbh589k4kzshxdq",
"created_at": "2026-11-06T20:05:01Z", "updated_at": "2026-11-06T20:05:02Z",
"deployments": [{"id": "dep_…", "environment": "production", "app": "app_…", "app_name": "Cronjay",
"server": "srv_…", "server_name": "app1", "hostname": "2.31.173.42"}]
}
Writable: name (required, unique per org), description, repo_url, app_url (absolute http(s) URLs),
tags (array of strings; normalized to lowercase, sorted, deduplicated, max 20).
Server: id, name (required, unique per org), hostname (host name or IP), provider, location, os,
notes (markdown), url, created_at, updated_at, and deployments on GET /servers/{id}. Deployments also
carry the last reported deploy: version, commit, deployed_at (null if never reported) and deploy_status.
Deployment body: {"server": "srv_…", "environment": "production"} (environment defaults to production;
letters, digits, -, _). Answers with the application (201).
Todo
{
"id": "todo_2j0d15be3znbzxym", "app": "app_…", "app_name": "Cronjay",
"title": "Rotate the Stripe webhook secret", "status": "in_progress", "priority": "urgent",
"assignee": "drover", "link_url": "https://forgejo.example/metastic/passive-income/pulls/43",
"created_by": "ada@example.com", "note_count": 2,
"url": "https://inventory.metastic.ch/todos/todo_2j0d15be3znbzxym",
"created_at": "…", "updated_at": "…", "closed_at": null,
"notes": [
{"id": "note_…", "kind": "event", "body": "Status: Open → In progress", "author": "API token Drover", "created_at": "…"},
{"id": "note_…", "kind": "note", "body": "Rotated in Stripe; updated `secrets.env`.", "author": "API token Drover", "created_at": "…"}
]
}
notes (the timeline) is included on single-todo responses. kind: event entries are written by the server for
status, assignee, priority and link changes; kind: note entries are markdown written by people or tokens.
Writable: title (required on create, max 200), status, priority (low, normal (default), high,
urgent), assignee (a member's email links the user; anything else is kept as text, e.g. drover; ""
unassigns), link_url (http(s)), and note (markdown, added as a note in the same call; create and PATCH).
Setting done or cancelled sets closed_at; reopening clears it.
Examples
BASE=https://inventory.metastic.ch/api/v1
AUTH="Authorization: Bearer $INVENTORY_TOKEN"
curl -s -H "$AUTH" $BASE/me
curl -s -H "$AUTH" "$BASE/apps"
curl -s -H "$AUTH" -X POST $BASE/servers -H 'Content-Type: application/json' \
-d '{"name":"app1","hostname":"2.31.173.42","provider":"Hetzner","location":"fsn1","os":"Debian 13"}'
curl -s -H "$AUTH" -X POST $BASE/apps -H 'Content-Type: application/json' \
-d '{"name":"Runbook","repo_url":"https://forgejo.example/metastic/passive-income","app_url":"https://runbook.metastic.ch","tags":["go","sqlite","htmx"]}'
curl -s -H "$AUTH" -X POST $BASE/apps/app_…/deployments -H 'Content-Type: application/json' \
-d '{"server":"srv_…","environment":"production"}'
Drover agent usage
Drover agents work on todos that a human assigned to drover (see DESIGN.md §5
for why Drover pulls instead of Inventory pushing). The token lives in the Drover vars file as
INVENTORY_API_TOKEN (scope agent, one token per goal, named after the goal; write works too, but agent cannot
touch apps, servers or repository links). Never write the token into a note,
a commit or a log.
Connect your own Drover: /docs/drover walks through the setup (agent token, CLI download with sha256, vars file) and generates a ready-to-paste goal for GitHub, GitLab or Forgejo/Gitea.
Helper CLI. scripts/inv wraps the calls below (POSIX sh + curl + jq). Download it from
GET /cli/inv (public, the exact file of this repository, embedded in the binary; the response header
X-Inv-Sha256 and /docs/drover show its sha256; inv version prints its version). It reads
INVENTORY_API_BASE and INVENTORY_API_TOKEN from the environment, else from ~/.drover/secrets/passive-income.vars.env
(INV_VARS_FILE overrides the path). It passes the token to curl through a private temp file, never prints it, and
refuses to send a note that contains it. On failure it prints inv: <request>: HTTP <code>: <API error> and exits 1;
a lost claim exits 2.
inv next # open todos assigned to drover, urgent first: id, priority, status, app, title
inv show todo_… # todo + notes + app context (repo URL, monorepo path, deployments)
inv claim todo_… -m "Picked up by Drover task #57" # exit 2: someone else holds it, do not work on it
inv note todo_… notes.md # or: echo "…" | inv note todo_… -
inv pr todo_… https://…/pulls/43
inv done todo_… -m "Merged in #43, deployed, verified."
inv release todo_… -m "Blocked: …" [--to human@example.com] # back to open; unassigned unless --to
inv mine # in-progress drover todos (with PR links), e.g. to check for merges
inv from-finding app_… dep:npm/lodash # idempotent finding → todo, assigned to drover
INV_ASSIGNEE changes the assignee used by next, mine, claim and from-finding (default drover). The
test internal/web/cli_script_test.go runs every command against the test server.
The raw calls:
1. Find work (primary agent, every wake-up):
curl -s -H "$AUTH" "$BASE/todos?assignee=drover&status=open" \
| jq -r '.todos[] | "\(.id)\t\(.priority)\t\(.app_name)\t\(.title)"'
Create one Drover sub task per todo and put the todo id and URL into the task description. Read the details (description of the app, repo URL, earlier notes):
curl -s -H "$AUTH" $BASE/todos/todo_2j0d15be3znbzxym | jq '{title, app, notes: [.notes[].body]}'
curl -s -H "$AUTH" $BASE/apps/app_dxbh589k4kzshxdq | jq '{name, repo_url, description, deployments}'
2. Claim it (sub agent, first action). Exactly one claimer wins; the others get 409 and must stop:
curl -s -o /tmp/claim.json -w '%{http_code}\n' -H "$AUTH" -X POST $BASE/todos/todo_…/claim \
-H 'Content-Type: application/json' -d '{"note":"Picked up by Drover task #57. Plan: …"}'
# 200 → yours (status in_progress, assignee drover); 409 → someone else has it, do not work on it
3. Report progress (markdown; keep notes short and factual):
curl -s -H "$AUTH" -X POST $BASE/todos/todo_…/notes -H 'Content-Type: application/json' \
-d "$(jq -n --arg b "Implemented the export in \`internal/web/export.go\`; 12 tests added, all green." '{body: $b}')"
4. Attach the PR:
curl -s -H "$AUTH" -X PUT $BASE/todos/todo_…/link -H 'Content-Type: application/json' \
-d '{"url":"https://forgejo.example/metastic/passive-income/pulls/43"}'
5. Finish (done with a summary note), or hand it back if blocked:
curl -s -H "$AUTH" -X PATCH $BASE/todos/todo_… -H 'Content-Type: application/json' \
-d '{"status":"done","note":"Merged in #43 and deployed; verified on production."}'
# blocked: give it back to a human with the reason
curl -s -H "$AUTH" -X PATCH $BASE/todos/todo_… -H 'Content-Type: application/json' \
-d '{"status":"open","assignee":"hello@metastic.ch","note":"Blocked: needs the Stripe live key rotated by a human."}'
6. Report findings as new todos (e.g. something noticed while working):
curl -s -H "$AUTH" -X POST $BASE/apps/app_…/todos -H 'Content-Type: application/json' \
-d '{"title":"N+1 query in OrdersController#index","priority":"high","note":"Seen while profiling for #43."}'
Everything the agent does shows up on the todo page as notes and events attributed to "API token ", so the human can follow and correct it.
Dependencies
Upload lockfiles from CI (or the app page in the UI); Inventory parses them, then a background job looks up the
latest stable version of every package (rubygems.org, the npm registry, proxy.golang.org, PyPI), known
vulnerabilities (OSV) and runtime end-of-life dates (endoflife.date). Request handlers never call those upstreams,
so right after an upload latest_checked / vulns_checked are false for a minute or two.
Supported files (detected by the base name of path): Gemfile.lock, package-lock.json (v2/v3) and
npm-shrinkwrap.json, yarn.lock (v1 and berry), pnpm-lock.yaml (v5/v6/v9), go.mod, requirements*.txt
(pinned == lines; pip-compile # via annotations mark direct packages), poetry.lock, and the runtime files
.ruby-version, .nvmrc, .node-version, .python-version, .go-version, .tool-versions.
Direct vs transitive: Gemfile.lock (DEPENDENCIES), package-lock (root/workspace dependencies and
devDependencies), pnpm (importers), go.mod (// indirect) and pip-compile output record it; yarn.lock and
poetry.lock do not, so their packages are all reported as direct: false.
Runtimes: go.mod toolchain (else go), Gemfile.lock RUBY VERSION, a pinned engines.node (20, ^20.11;
open ranges like >=18 are ignored), runtime files, and the rails / django package versions.
Private packages (git/path sources, a non-public registry, go.mod replace with a local directory) are stored
with private: true and are never sent to a registry or OSV.
PUT /apps/{id}/manifests
Two forms. The path is a label (your repo path); uploading the same path again replaces that manifest's packages.
An identical file is a no-op (unchanged: true). Limits: 5 MB per file, 20 files per request, 50 manifests per
app, 20,000 packages per file.
# one raw file
curl -fsS -X PUT -H "Authorization: Bearer $TOKEN" --data-binary @apps/web/Gemfile.lock \
"$BASE/apps/app_…/manifests?path=apps/web/Gemfile.lock"
# several files as JSON
curl -fsS -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"manifests": [{"path": "go.mod", "content": "module …"}, {"path": "web/package-lock.json", "content": "{…}"}]}' \
"$BASE/apps/app_…/manifests"
Response 200:
{"app_id": "app_…", "manifests": [
{"id": "man_…", "path": "apps/web/Gemfile.lock", "format": "Gemfile.lock", "packages": 142, "direct": 31,
"runtimes": [{"product": "ruby", "version": "3.3.4"}, {"product": "rails", "version": "7.1.3"}], "unchanged": false}]}
Errors: 400 (no ?path=, bad JSON, too many files), 413 (file > 5 MB), 422 (unsupported file name, a file
that does not parse, too many manifests). A request with one bad file stores nothing if the problem is in a path
or a size; parse errors are reported for the first file that fails (files before it are stored).
GET /apps/{id}/dependencies
Filters (all optional, combinable): outdated=1, vulnerable=1, direct=1, dev=0 (hide dev dependencies),
ecosystem=npm|rubygems|go|pypi, q=<name substring>.
{"app_id": "app_…", "total": 142, "count": 3,
"dependencies": [
{"ecosystem": "rubygems", "name": "nokogiri", "version": "1.16.0", "latest": "1.19.7", "behind": "minor",
"direct": true, "dev": false, "private": false, "manifest": "apps/web/Gemfile.lock",
"latest_checked": true, "vulns_checked": true,
"vulnerabilities": [{"id": "GHSA-xc9x-jj77-9p9j", "summary": "…", "severity": "high", "aliases": ["CVE-…"],
"fixed_in": ["1.16.5"], "url": "https://osv.dev/vulnerability/GHSA-xc9x-jj77-9p9j"}]}],
"runtimes": [{"product": "go", "name": "Go", "version": "1.27.3", "manifest": "go.mod", "cycle": "1.27",
"latest": "1.27.4", "eol": "2027-08-11", "status": "supported", "days_left": 278}]}
behind:major,minor,patch,newer(used version is newer than the latest stable, e.g. a prerelease) or""(up to date, or not known yet). Versions compare per ecosystem: semver (npm, Go incl. pseudo-versions), RubyGems rules, PEP 440 (epochs, pre/post/dev releases).latestisnulluntil looked up, and staysnullfor packages the registry does not know (latest_checked: true).severity:critical,high,moderate,lowor""(unrated). Duplicate advisories (e.g.GO-…and itsGHSA-…alias) are listed once, preferring the GHSA id.- Runtime
status:supported,eol_soon(≤ 90 days),eol, orunknown(no matching release cycle).
GET /dependencies/summary
{"totals": {"apps": 3, "packages": 212, "outdated": 40, "outdated_major": 4, "vulnerable": 1, "vulnerabilities": 2,
"runtimes_eol": 0, "runtimes_eol_soon": 0, "pending_lookups": 0},
"apps": [{"app_id": "app_…", "app_name": "Runbook", "manifests": 2, "packages": 71, "direct": 9, "outdated": 12,
"outdated_major": 1, "vulnerable": 0, "vulnerabilities": 0, "pending_lookups": 0,
"last_upload": "2026-11-06T21:00:00Z", "runtimes": […], "worst_runtime": {…}}]}
Only apps with at least one manifest are listed.
Drover: findings to todos
A reporting goal turns findings into todos: GET /dependencies/summary, then for each app with vulnerable > 0
GET /apps/{id}/dependencies?vulnerable=1 and, per package, POST /apps/{id}/todos/from-finding with
{"source": "dep:<ecosystem>/<name>"} (see Findings to todos). The endpoint is idempotent, so
the goal does not need to search existing todos.
Forge: pull requests and issues
Connections are added in the UI (Settings → Forges). For organizations on a self-service plan the base URL must be
https on port 443 and resolve to a public address that is not one of the operator's own servers; this is checked
again on every connection (DNS rebinding), redirects are never followed, and every connection failure (blocked,
refused, timeout, DNS, TLS) reads the same: could not reach the forge. Each organization may send 2,000 forge
requests per hour, run 10 connection tests per hour and add 5 connections per day. Background syncing pauses for
organizations with no sign-in and no API call for 14 days and resumes with the next one.
Admins connect Forgejo/Gitea, GitHub (github.com or Enterprise) or GitLab (gitlab.com or self-hosted) under Settings → Forges with a read-only token. Tokens can only be entered in the UI. They are stored encrypted (AES-256-GCM) and never returned by the API. Each application links one or more repositories, and several apps can share a repository (a monorepo). A background job fetches the open PRs/MRs and issues every 15 minutes. It sends conditional requests (ETag, so an unchanged repository costs one cheap 304 per list), and it looks up CI status and changed files only when a PR's head commit changes.
Monorepo filters per link: path (only PRs that change files under it) and label (only issues with that
label; PRs with the label are also shown). Items outside the filters are kept with in_scope: false and are
returned only with ?all=1.
POST /apps/{id}/repos
curl -fsS -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"repo": "https://projects.metikular.ch/metastic/platform", "path": "apps/inventory"}' "$BASE/apps/app_…/repos"
repo is owner/repo (GitLab: group/subgroup/project) together with forge (a connection id), or a repository
URL that is matched to a connection by host. Response 201: the link (below). 422 if no connection matches, the
repository is already linked, or the app has 10 links. The first sync runs within a minute (or call forge/refresh).
{"id": "repo_…", "app": "app_…", "app_name": "Inventory", "forge": "fc_…", "forge_name": "metastic Forgejo", "kind": "forgejo",
"repo": "metastic/platform", "url": "https://projects.metikular.ch/metastic/platform", "path": "apps/inventory", "label": "",
"synced_at": "2026-11-06T21:00:00Z", "last_ok_at": "2026-11-06T21:00:00Z", "last_error": "", "truncated": false,
"next_sync_at": "2026-11-06T21:15:00Z"}
GET /apps/{id}/pulls, GET /apps/{id}/issues, GET /pulls, GET /issues
Filters: all=1 (include items outside the path/label filters), stale=1 (PRs without an update for 14 days).
The cross-app lists contain each PR/issue once, with every app that links it in apps. Newest update first.
{"pulls": [
{"kind": "pr", "repo": "metastic/platform", "number": 42, "ref": "metastic/platform#42", "title": "Inventory: forge sync",
"author": "ada", "url": "https://projects.metikular.ch/metastic/platform/pulls/42", "draft": true, "ci_status": "success",
"labels": ["inventory"], "assignees": [], "created_at": "…", "updated_at": "…", "stale": false, "in_scope": true, "apps": ["app_…"]}]}
ci_status is success, failure or pending, and is omitted when unknown (no CI, or the token may not read
it). GitLab MRs have ref group/project!12. Issues also have source_ref (for from-finding) and todo, the
id of the open todo created from the issue, when there is one.
GET /forges returns {"forges": [{"id": "fc_…", "kind", "name", "base_url", "token_hint": "••••abcd", "login", "status": "ok|error|untested", "last_error", "checked_at", "linked_repos", "needs_new_token"}]}.
needs_new_token: true means the server's encryption key changed: an admin must enter the token again.
Findings to todos
POST /apps/{id}/todos/from-finding (write) creates a todo from a finding of this app. The server builds the
title, the priority, the link and a first markdown note from its own data, so the finding must exist in the app:
source |
Finding | Todo |
|---|---|---|
dep:<ecosystem>/<name>, e.g. dep:npm/lodash |
The package is outdated or vulnerable in one of the app's manifests | "Fix 2 vulnerabilities in lodash 4.17.15" (priority from the worst severity: critical → urgent, high → high) or "Upgrade lodash 4.17.15 → 4.17.21"; the note lists the manifests, the latest version and the advisories with links and fixed versions |
issue:<issue URL> (the source_ref of an issue) |
A synced open issue of a linked repository | " (owner/repo#58)", link_url = the issue; the note links the issue and lists its labels |
Optional title, priority and assignee (e.g. "drover") replace the generated values.
Idempotent: while a todo for the same app and source is open or in progress, the call returns that todo
with 200. Otherwise it creates one and returns 201 with a Location header. After the todo is done or
cancelled, a new call creates a new todo, and reopening the old one then fails with 422. Errors: 404 (no such
package in the app, or the issue is not synced), 422 (unknown source format, or the package is up to date and has
no known vulnerabilities), 429 (more than 120 per hour and org). Todos carry their source_ref in every todo
response.
curl -fsS -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"source": "dep:npm/lodash", "assignee": "drover"}' "$BASE/apps/app_…/todos/from-finding"
Deploys and the server overview
CI reports every deploy, so the server overview (/servers in the UI, GET /overview here) shows real
versions and deploy times. A deployment is one app on one server in one environment.
POST /deploys
{"app": "app_…" | "Runbook", "server": "srv_…" | "app1", "environment": "production",
"version": "v1.42", "commit": "3f9c2a1…", "status": "ok"}
app(required): the app's id or its name (case-insensitive, per org).server: the server's id or name. You can omitserverwhen the app runs on exactly one server in that environment. Internal numeric ids are never accepted. An app or server of another org, or one that does not exist, is a404.environment: defaults toproduction. If the app has no deployment on that server in that environment yet, one is created (upsert).version: up to 64 characters.commit: a hex SHA, 7 to 40 characters. Both are optional.status:ok(default),failedorunknown. The CI wordssuccess,failureandcancelledare accepted as well, so${{ job.status }}works.- Idempotent: a report with the same version, commit and status as the last one for this deployment
records nothing and returns
200with"duplicate": true. Otherwise you get201. The last 20 deploys per deployment are kept and shown on the server page. - Answer:
{"deployment": {…, "version", "commit", "deployed_at", "deploy_status"}, "created": bool, "duplicate": bool}. Errors:400(bad JSON),403(read token),404,422(validation, orserveris missing and is needed).
curl -fsS -X POST -H "Authorization: Bearer $INVENTORY_API_TOKEN" -H "Content-Type: application/json" \
-d '{"app": "Runbook", "server": "app1", "version": "v1.42", "commit": "'"$GIT_SHA"'", "status": "ok"}' \
"$INVENTORY_API_BASE/deploys"
Forgejo Actions / GitHub Actions (the last step of the deploy job; if: always() also reports failed
deploys, and || true keeps an Inventory outage from failing your deploy):
- name: Report deploy to Inventory
if: always()
env:
INVENTORY_API_TOKEN: ${{ secrets.INVENTORY_API_TOKEN }}
INVENTORY_API_BASE: ${{ secrets.INVENTORY_API_BASE }} # e.g. https://inventory.metastic.ch/api/v1
STATUS: ${{ job.status }} # success | failure | cancelled
run: |
[ -n "$INVENTORY_API_TOKEN" ] || { echo "no INVENTORY_API_TOKEN, skipped"; exit 0; }
curl -fsS -m 15 --retry 3 --retry-all-errors -X POST \
-H "Authorization: Bearer $INVENTORY_API_TOKEN" -H "Content-Type: application/json" \
-d "{\"app\":\"my-app\",\"server\":\"app1\",\"version\":\"${GITHUB_REF_NAME}\",\"commit\":\"${GITHUB_SHA}\",\"status\":\"${STATUS}\"}" \
"${INVENTORY_API_BASE:-https://inventory.metastic.ch/api/v1}/deploys" || echo "::warning::deploy report failed"
GitLab CI:
report-deploy:
stage: .post
when: always
script:
- 'curl -fsS -m 15 -X POST -H "Authorization: Bearer $INVENTORY_API_TOKEN" -H "Content-Type: application/json"
-d "{\"app\":\"my-app\",\"server\":\"app1\",\"version\":\"$CI_COMMIT_REF_NAME\",\"commit\":\"$CI_COMMIT_SHA\",\"status\":\"$CI_JOB_STATUS\"}"
"$INVENTORY_API_BASE/deploys" || true'
GET /overview
Optional filters: env, tag (an app tag), problems=1 (only red and amber). Servers come red first, then
amber, then by name. Apps that run on no server are in not_deployed.
{
"servers": [{
"id": "srv_…", "name": "app1", "hostname": "2.31.173.42", "provider": "Hetzner", "location": "fsn1",
"os": "Debian 13", "os_eol": {"product": "debian", "cycle": "13", "eol": "2030-06-30", "status": "ok"},
"status": "amber", "app_count": 3, "open_todos": 4, "url": "…",
"apps": [{"deployment": "dep_…", "app": "app_…", "app_name": "Runbook", "environment": "production",
"version": "v1.42", "commit": "3f9c…", "deployed_at": "2026-11-07T10:00:00Z", "deploy_status": "ok",
"deployed_by": "API token ci", "status": "amber", "reasons": ["1 high vulnerability"],
"vulns_critical": 0, "vulns_high": 1, "vulns_total": 2, "outdated_direct": 3,
"runtime": null, "open_prs": 2, "failing_prs": 0, "url": "…"}]
}],
"not_deployed": [{"app": "app_…", "app_name": "Marketing site", "status": "grey", "reasons": [], …}],
"generated_at": "…"
}
Status rule (per app row; a server takes the worst of its rows and of its OS):
red: a critical vulnerability, a runtime past its end of life, or a failed last deploy;amber: a high vulnerability, a runtime less than 90 days from its end of life, an open PR with failing CI, or no deploy for more than 90 days;grey: no data (no manifest and no deploy reported);green: none of the above.
os_eol is set for Debian and Ubuntu (parsed from the server's os field, e.g. "Debian 13", "Ubuntu 24.04 LTS",
"Debian bookworm"); status is ok, soon, eol or unknown. An EOL OS makes the server red, one less than
90 days from EOL makes it amber.