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

PurposeRequestScope needed
List buildingsGET /v3/buildingsread:buildings
One building with its panel treeGET /v3/buildings/{buildingIdOrSlug}read:buildings
List panels across buildingsGET /v3/panelsread:panels
List breakers (with nested circuits)GET /v3/breakersread:breakers
One breakerGET /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:

FieldTypeWhere it is used
nanoidstring with a type prefix: building_..., panel_..., breaker_..., circuit_...v3 paths such as /breakers/{breakerId}. Stable and opaque.
oldidintegerThe 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

  1. Page through GET /v3/buildings?per-page=1000. Record nanoid, oldid, slug, name, timezone, location, floorArea.
  2. For each building, call GET /v3/buildings/{nanoid}. The panels array holds top-level panels. Each panel may contain downstreamPanels (recursively), which gives you the panel-to-panel feed tree. Each panel also carries oldid, name, mains (supplyVoltage, phases: 1ph, 1ph3w, or 3ph), upstreamBreaker, and dataGateways. A panel's metering status, currentEvent.status (planned, not_metered, active, suspended, archived, deleted), is not part of the building response: read it from GET /v3/panels.
  3. Page through GET /v3/breakers?per-page=1000. Each breaker has panel.nanoid (the panel it is installed in), size, active, and circuits[] with nanoid, oldid, phase (A, B, C) and active.
  4. Note that circuit active was null on every circuit observed, so treat it as unknown rather than false.
  5. 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/panels returns each panel with its building, 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:

NodeKeyProperties to keep
Buildingnanoidoldid, name, slug, timezone, location
Panelnanoidoldid, name, mains, currentEvent.status (from GET /v3/panels)
Breakernanoidoldid, name, size, active
Circuitnanoidoldid, phase, active
EdgeSource of truth
building contains panelPanel is in building.panels (top level) or nested below one
panel feeds panelNesting in downstreamPanels
panel contains breakerbreaker.panel.nanoid
breaker has circuitbreaker.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.


Did this page help you?