An internal admin view of projects, branches and snapshots
Three paged reads build the inventory page your team keeps asking for, including the orphan detection that makes it worth opening.
The database inventory page nobody wants to build is three Infrai reads: GET /v1/db/project/list, GET /v1/db/branch/list?project_id=... and GET /v1/db/snapshot/list?project_id=.... All three are cursor-paged, all three return the state and creation time of each resource, and none of them cost anything.
The version worth building isn’t a list. It’s a list that flags what shouldn’t be there.
The three reads
curl -sS "https://api.infrai.cc/v1/db/project/list" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{
"project_id": "dbp_7Uu2kQxWvR4mBn8d",
"db_id": "db_3kQ9mVzR1sXbNt",
"name": "checkout-service",
"vendor": "tencent_pg",
"region": "ap-shanghai",
"plan": "hobby",
"state": "ready",
"postgres_version": "17",
"primary_branch": "main",
"created_at": "2026-09-01T10:00:00Z"
}
],
"next_cursor": null,
"total": 1
}
}
Then per project, its branches and its snapshots:
curl -sS "https://api.infrai.cc/v1/db/snapshot/list?project_id=dbp_7Uu2kQxWvR4mBn8d" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Snapshots carry size_bytes and label, which is what makes them auditable. A snapshot with a meaningless label and 180 MB of disk is exactly the row you want on a dashboard.
Follow the cursor, always
Every one of these reads pages, and code that reads the first page and stops is code that silently under-reports as soon as you pass the page size. Write the pagination once:
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
async function* paged(path, params = {}) {
let cursor = null;
do {
const url = new URL(`${API}${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, { headers: { authorization: `Bearer ${KEY}` } });
if (!res.ok) throw new Error(`${path} answered ${res.status}`);
const { data } = await res.json();
for (const item of data.items ?? []) yield item;
cursor = data.next_cursor;
} while (cursor);
}
export async function inventory() {
const projects = [];
for await (const project of paged("/v1/db/project/list")) {
const [branches, snapshots] = await Promise.all([
collect(paged("/v1/db/branch/list", { project_id: project.project_id })),
collect(paged("/v1/db/snapshot/list", { project_id: project.project_id })),
]);
projects.push({ ...project, branches, snapshots });
}
return projects;
}
async function collect(iterator) {
const out = [];
for await (const item of iterator) out.push(item);
return out;
}
Make the page earn its place
A flat inventory gets opened once. An inventory that answers “what’s wrong” gets bookmarked.
import os
import re
from datetime import datetime, timezone
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}"})
PR_NAME = re.compile(r"^pr-(\d+)$")
def pages(path: str, **params):
cursor = None
while True:
query = dict(params)
if cursor:
query["cursor"] = cursor
resp = SESSION.get(f"{API}{path}", params=query, timeout=25)
resp.raise_for_status()
data = resp.json()["data"]
yield from data.get("items", [])
cursor = data.get("next_cursor")
if not cursor:
return
def age_days(iso: str | None) -> float:
if not iso:
return 0.0
created = datetime.fromisoformat(iso.replace("Z", "+00:00"))
return (datetime.now(timezone.utc) - created).total_seconds() / 86400
def findings(open_prs: set[int]) -> list[dict]:
"""The rows worth a colour. Everything else is just a list."""
out = []
for project in pages("/v1/db/project/list"):
pid = project["project_id"]
if project.get("state") != "ready":
out.append({"kind": "project_not_ready", "id": pid, "state": project.get("state")})
for branch in pages("/v1/db/branch/list", project_id=pid):
name = branch.get("branch_name") or ""
match = PR_NAME.match(name)
if match and int(match.group(1)) not in open_prs:
out.append({"kind": "orphan_branch", "id": f"{pid}/{name}",
"age_days": round(age_days(branch.get("created_at")), 1)})
elif not match and name not in {"main", "staging"}:
out.append({"kind": "unconventional_branch", "id": f"{pid}/{name}"})
snapshots = list(pages("/v1/db/snapshot/list", project_id=pid))
for snap in snapshots:
if age_days(snap.get("created_at")) > 30:
out.append({"kind": "stale_snapshot", "id": snap["snapshot_id"],
"label": snap.get("label"), "bytes": snap.get("size_bytes")})
if len(snapshots) > 10:
out.append({"kind": "snapshot_pileup", "id": pid, "count": len(snapshots)})
return out
if __name__ == "__main__":
for row in findings({1482, 1490}):
print(row)
Five findings, each actionable in one call. That’s a page someone will actually look at on a Monday.
What to show, in priority order
| Row | Why it matters |
|---|---|
| Branch whose PR is closed | pays rent for nothing |
Project not in ready state | something is stuck or half-provisioned |
| Snapshot older than your retention rule | disk you’re renting for an obsolete rollback |
| More than ~10 snapshots on one project | nobody is deleting them |
| Branch with an off-convention name | can’t be reconciled automatically |
Put the total cost next to it from GET /v1/account/usage and the page becomes a budget conversation instead of a trivia list.
The limitations of an inventory built this way
There’s no server-side filter or search on these endpoints: no “branches older than 14 days”, no “snapshots labelled release-*”. You page everything and filter in your own process, which is fine for hundreds of resources and a caveat at tens of thousands — cache the inventory for a few minutes rather than rebuilding it per page view.
Nor is there a per-resource cost figure. The usage breakdown is per capability, so “this branch cost us $X” isn’t available; you get creation fees and aggregate rent, and attribution to a specific branch is an estimate from size_bytes and age.
Neon’s and Supabase’s consoles show more of this out of the box, including per-branch compute and storage graphs, so if you want the dashboard rather than the endpoints they’re ahead here. The trade is that this inventory sits in your admin tool next to everything else on the same credential — the buckets from GET /v1/storage/bucket/list, the queues from GET /v1/queue/list, the schedules from GET /v1/cron/list — so one page covers the whole preview environment rather than one vendor’s slice of it, and one GET /v1/account/usage prices all of it. Those rates are live (verified 2026-09-21) and drift downward as vendor contracts improve.