Build your topology graph
Walk the hierarchy and assemble it into a graph you can store. Python and TypeScript included.
This recipe walks the Verdigris equipment hierarchy with the REST API v3 and assembles it into a graph you can keep as a digital twin.
Building
└── Panel (panels can feed other panels)
└── Breaker
└── Circuit (one per phase: A, B or C)
Endpoints you will use
| Purpose | Request | Scope needed |
|---|---|---|
| List buildings | GET /v3/buildings | read:buildings |
| One building with its panel tree | GET /v3/buildings/{buildingIdOrSlug} | read:buildings |
| List panels across buildings | GET /v3/panels | read:panels |
| List breakers (with nested circuits) | GET /v3/breakers | read:breakers |
| One breaker | GET /v3/breakers/{breakerId} | read:breakers |
There is no separate circuit endpoint in v3. Circuits are returned inside each breaker in breakers[].circuits. Read panels from the building response (GET /v3/buildings/{buildingIdOrSlug}) or from GET /v3/panels.
Identifiers
Every building, panel, breaker, and circuit carries two identifiers:
| Field | Type | Where it is used |
|---|---|---|
nanoid | string with a type prefix: building_..., panel_..., breaker_..., circuit_... | v3 paths such as /breakers/{breakerId}. Stable and opaque. |
oldid | integer | The ids parameter and {id} path of Data API v4. |
Buildings also have a slug (string, may be null). GET /v3/buildings/{buildingIdOrSlug} accepts either the NanoID or the slug; a value beginning with building_ is treated as a NanoID, anything else as a slug. Use nanoid as your primary key in your own store and keep oldid as the join key to telemetry.
Pagination
List endpoints (/buildings, /panels, /breakers) take page (starts at 1, default 1) and per-page (1 to 1000, default 10). Each response includes currentPage, pages, and total next to the array (buildings, panels, or breakers). Loop until currentPage >= pages. Use per-page=1000 to minimize request count, since every request counts toward your rate limit.
If nothing matches, or the account has no items, list endpoints return HTTP 404 with the body { "currentPage": 1, "pages": 1, "total": 0, "<array>": [] }. Treat that body (total: 0) as an empty result, not a failure. Other 404 responses are real errors.
Lists are ordered by creation date, newest first. Items created while you page can shift later pages, so deduplicate by nanoid when you assemble the graph.
Walk the hierarchy
- Page through
GET /v3/buildings?per-page=1000. Recordnanoid,oldid,slug,name,timezone,location,floorArea. - For each building, call
GET /v3/buildings/{nanoid}. Thepanelsarray holds top-level panels. Each panel may containdownstreamPanels(recursively), which gives you the panel-to-panel feed tree. Each panel also carriesoldid,name,mains(supplyVoltage,phases:1ph,1ph3w, or3ph),upstreamBreaker, anddataGateways. A panel's metering status,currentEvent.status(planned,not_metered,active,suspended,archived,deleted), is not part of the building response: read it fromGET /v3/panels. - Page through
GET /v3/breakers?per-page=1000. Each breaker haspanel.nanoid(the panel it is installed in),size,active, andcircuits[]withnanoid,oldid,phase(A,B,C) andactive. - Note that circuit
activewasnullon every circuit observed, so treat it as unknown rather than false. - Join: attach each breaker to its panel by
breaker.panel.nanoid, and each circuit to its breaker. A breaker does not carry its building.GET /v3/panelsreturns each panel with itsbuilding, so to find a breaker's building, go breaker to panel to building.
Abbreviated, illustrative response for GET /v3/buildings/{nanoid}, showing the nested panel tree:
{
"nanoid": "building_...",
"oldid": 42,
"name": "Example HQ",
"timezone": "America/Los_Angeles",
"panels": [
{
"nanoid": "panel_...",
"oldid": 7,
"name": "Main switchboard",
"mains": { "supplyVoltage": 277, "phases": "3ph" },
"upstreamBreaker": null,
"dataGateways": [],
"downstreamPanels": [
{ "nanoid": "panel_...", "oldid": 8, "name": "HVAC panel", "downstreamPanels": [] }
]
}
]
}A panel's upstreamBreaker is the breaker that feeds the panel's mains. Its panel field names the panel the breaker is installed in, which is the parent panel. If upstreamBreaker is null, or its panel is the panel itself, treat the panel as a root.
Example requests:
# Page 1 of buildings
curl -s 'https://api.verdigris.co/v3/buildings?page=1&per-page=1000' \
-H "Authorization: Bearer $TOKEN"
# A building with its panel tree
curl -s 'https://api.verdigris.co/v3/buildings/building_XXXX' \
-H "Authorization: Bearer $TOKEN"
# Breakers, page 1, with their circuits
curl -s 'https://api.verdigris.co/v3/breakers?page=1&per-page=1000' \
-H "Authorization: Bearer $TOKEN"Filtering and sorting
filter and sort are JSON values in the query string. Filtering on a key that does not exist returns the empty-page 404 above, so check spelling if you get an unexpected empty result. See Errors and conventions. For example, panels whose name contains "main":
curl -s -G 'https://api.verdigris.co/v3/panels' \
-H "Authorization: Bearer $TOKEN" \
--data-urlencode 'filter={"key":"name","operator":"ilike","type":"string","value":"main"}' \
--data-urlencode 'per-page=100'Assemble the graph
Suggested model:
| Node | Key | Properties to keep |
|---|---|---|
| Building | nanoid | oldid, name, slug, timezone, location |
| Panel | nanoid | oldid, name, mains, currentEvent.status (from GET /v3/panels) |
| Breaker | nanoid | oldid, name, size, active |
| Circuit | nanoid | oldid, phase, active |
| Edge | Source of truth |
|---|---|
building contains panel | Panel is in building.panels (top level) or nested below one |
panel feeds panel | Nesting in downstreamPanels |
panel contains breaker | breaker.panel.nanoid |
breaker has circuit | breaker.circuits[] |
The complete script, in Python and TypeScript (use the tabs):
import os
import requests
BASE = "https://api.verdigris.co/v3"
HEADERS = {"Authorization": f"Bearer {os.environ['TOKEN']}"}
def get(path, **params):
r = requests.get(f"{BASE}{path}", headers=HEADERS, params=params, timeout=30)
r.raise_for_status() # add retry/backoff for 429, see the Rate limits page
return r.json()
def pages(path, key):
page = 1
while True:
r = requests.get(
f"{BASE}{path}", headers=HEADERS,
params={"page": page, "per-page": 1000}, timeout=30,
)
body = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
if r.status_code == 404 and body.get("total") == 0: # an empty list is a 404 with an empty page
return
r.raise_for_status()
yield from body[key]
if body["currentPage"] >= body["pages"]:
return
page += 1
nodes, edges = {}, []
def add_panel(panel, building_id, parent_id=None):
nodes[panel["nanoid"]] = {"type": "panel", "oldid": panel["oldid"], "name": panel["name"]}
edges.append((parent_id or building_id, "contains" if parent_id is None else "feeds", panel["nanoid"]))
for child in panel.get("downstreamPanels") or []:
add_panel(child, building_id, panel["nanoid"])
for b in pages("/buildings", "buildings"):
nodes[b["nanoid"]] = {"type": "building", "oldid": b["oldid"], "name": b["name"]}
detail = get(f"/buildings/{b['nanoid']}")
for panel in detail["panels"]:
add_panel(panel, b["nanoid"])
for br in pages("/breakers", "breakers"):
nodes[br["nanoid"]] = {"type": "breaker", "oldid": br["oldid"], "name": br["name"]}
if br.get("panel"):
edges.append((br["panel"]["nanoid"], "contains", br["nanoid"]))
for c in br["circuits"]:
nodes[c["nanoid"]] = {"type": "circuit", "oldid": c["oldid"], "phase": c["phase"]}
edges.append((br["nanoid"], "has", c["nanoid"]))export {}; // marks this file as a module so top-level await compiles under tsc
const BASE = "https://api.verdigris.co/v3";
const headers = { Authorization: `Bearer ${process.env.TOKEN}` };
type Page<K extends string, T> = { currentPage: number; pages: number; total: number } & Record<K, T[]>;
type Panel = { nanoid: string; oldid: number; name: string; downstreamPanels?: Panel[] };
type Circuit = { nanoid: string; oldid: number; phase: "A" | "B" | "C" };
type Breaker = { nanoid: string; oldid: number; name: string; panel?: { nanoid: string }; circuits: Circuit[] };
type Building = { nanoid: string; oldid: number; name: string; panels: Panel[] };
async function get<T>(path: string, params: Record<string, string | number> = {}): Promise<T | null> {
const qs = new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)]));
const res = await fetch(`${BASE}${path}?${qs}`, { headers });
const text = await res.text();
const body = text ? JSON.parse(text) : null;
// A list with no matches is a 404 whose body is an empty page (total 0); any other 404 is a real error.
if (res.status === 404 && body?.total === 0) return null;
if (!res.ok) throw new Error(`${res.status} ${text}`); // add retry/backoff for 429
return body as T;
}
async function* pages<K extends string, T>(path: string, key: K): AsyncGenerator<T> {
for (let page = 1; ; page++) {
const body = await get<Page<K, T>>(path, { page, "per-page": 1000 });
if (!body) return;
yield* body[key];
if (body.currentPage >= body.pages) return;
}
}
const nodes = new Map<string, { type: string; oldid: number; name?: string }>();
const edges: [from: string, rel: string, to: string][] = [];
function addPanel(p: Panel, buildingId: string, parentId?: string) {
nodes.set(p.nanoid, { type: "panel", oldid: p.oldid, name: p.name });
edges.push([parentId ?? buildingId, parentId ? "feeds" : "contains", p.nanoid]);
(p.downstreamPanels ?? []).forEach((c) => addPanel(c, buildingId, p.nanoid));
}
for await (const b of pages<"buildings", Building>("/buildings", "buildings")) {
nodes.set(b.nanoid, { type: "building", oldid: b.oldid, name: b.name });
const detail = await get<Building>(`/buildings/${b.nanoid}`);
detail?.panels.forEach((p) => addPanel(p, b.nanoid));
}
for await (const br of pages<"breakers", Breaker>("/breakers", "breakers")) {
nodes.set(br.nanoid, { type: "breaker", oldid: br.oldid, name: br.name });
if (br.panel) edges.push([br.panel.nanoid, "contains", br.nanoid]);
for (const c of br.circuits) {
nodes.set(c.nanoid, { type: "circuit", oldid: c.oldid });
edges.push([br.nanoid, "has", c.nanoid]);
}
}Request budget
A full sync costs roughly ceil(buildings / 1000) + one call per building + ceil(breakers / 1000) requests. The default limit is 120 requests per 60 seconds per token, so a portfolio of a few hundred buildings should be spread over several minutes. See Rate limits. After the first sync, refresh on a schedule and use updatedAt on each entity to detect changes.
Next step
Take the oldid values of the circuits, breakers, panels, or buildings you want and query telemetry.
Updated 27 days ago

