# GET /api/query/stats

> GET /api/query/stats: graph-wide node and edge counts, the threat-intel summary, and per-layer freshness and coverage in one call that needs no key.

*Source: https://www.whisper.security/docs/cypher-api/reference/stats*

---
`GET /api/query/stats` returns graph-wide counts, a threat-intel summary, and the state of every computed layer. It is the cheapest way to learn the shape of the graph, and the place to check that the layer a result depends on is healthy before you trust the result.

Use it instead of a global Cypher count. A query like `MATCH ()-[r]->() RETURN count(r)` counts edges over unanchored endpoints, and the engine refuses it: HTTP 400, `query-unservable` with `reason: "global_edge_count"`, and this endpoint named in the `suggestions` array. For per-edge-type counts, use `CALL db.relationshipTypes() YIELD type, count`; this endpoint returns totals, not per-type counts.

No key is needed: the body is the same signed in or out. The response carries `Cache-Control: max-age=60`; honor it and reuse the last body for that long rather than polling.

Base URL: `https://graph.whisper.security`. No request body and no parameters.

## Call it

```whisper-code-tabs
{
  "curl": "curl -s -A \"whisper-client/1.0\" \\\n  \"https://graph.whisper.security/api/query/stats\"",
  "python": "import requests\n\nres = requests.get(\n    \"https://graph.whisper.security/api/query/stats\",\n    headers={\"User-Agent\": \"whisper-client/1.0\"},\n)\nstats = res.json()\nprint(stats[\"total\"])",
  "node": "const res = await fetch(\"https://graph.whisper.security/api/query/stats\", {\n  headers: { \"User-Agent\": \"whisper-client/1.0\" },\n});\nconst stats = await res.json();\nconsole.log(stats.total);",
  "go": "package main\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n)\n\nfunc main() {\n\treq, _ := http.NewRequest(\"GET\", \"https://graph.whisper.security/api/query/stats\", nil)\n\treq.Header.Set(\"User-Agent\", \"whisper-client/1.0\")\n\tres, _ := http.DefaultClient.Do(req)\n\tdefer res.Body.Close()\n\tout, _ := io.ReadAll(res.Body)\n\tfmt.Println(string(out))\n}",
  "ruby": "require \"net/http\"\nrequire \"json\"\nrequire \"uri\"\n\nuri = URI(\"https://graph.whisper.security/api/query/stats\")\nreq = Net::HTTP::Get.new(uri, { \"User-Agent\" => \"whisper-client/1.0\" })\nres = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }\nputs JSON.parse(res.body)[\"total\"]"
}
```

## Response

Captured 2026-09-02 and abridged: one entry is shown under `vdp.layers`, and `rebuild` is left out. The numbers move, so read them from the live response rather than from this page.

```json
{
  "physical": {"nodeCount": 3800146986, "edgeCount": 31526259743},
  "virtual": {"nodeCount": 3682439526, "edgeCount": 8023477096},
  "total": {"nodeCount": 7482586512, "edgeCount": 39549736839},
  "objectCount": 47032323351,
  "threatIntel": {
    "threatIntelLoaded": true,
    "hasTaxonomy": true,
    "available": true,
    "feedSourceCount": 134,
    "categoryCount": 32,
    "totalListedInEdges": 10239820,
    "asnEnrichmentLoaded": true,
    "prefixBgpEnrichmentLoaded": true,
    "prefixBgpEnrichmentMatchesLive": true,
    "prefixBgpEnrichmentUsable": true,
    "dominantCityAnswerable": true
  },
  "vdp": {
    "total_layers": 20,
    "ready_layers": 20,
    "degraded_layers": 1,
    "virtual_nodes_by_layer": {"dns-resolves": 0},
    "virtual_edges_by_layer": {"dns-resolves": {"RESOLVES_TO": 1174924}},
    "layers": [
      {
        "name": "dns-resolves",
        "ready": true,
        "claimed_labels": [],
        "claimed_edge_types": [],
        "node_count": 0,
        "edge_count": 1174924,
        "edge_count_by_type": {"RESOLVES_TO": 1174924},
        "last_refresh_epoch_millis": 1788362774048,
        "refresh_in_progress": false,
        "coverage": "OK",
        "coverage_by_edge_type": {"RESOLVES_TO": "OK"}
      }
    ]
  },
  "timestamp": "2026-09-02T16:01:47Z"
}
```

| Field | What it holds |
|-------|---------------|
| `physical` | Nodes and edges stored on disk. |
| `virtual` | Objects computed at query time from live routing, DNS and threat-intelligence data, such as `ANNOUNCED_BY` and `LISTED_IN` edges. |
| `total` | The sum of physical and virtual. |
| `objectCount` | All nodes and edges added together. |
| `threatIntel` | Whether the threat-intel layer and its taxonomy are loaded (`threatIntelLoaded`, `hasTaxonomy`, `available`), the size of the feed catalogue (`feedSourceCount`, `categoryCount`; 134 feeds in 32 categories as of the last census), the number of `LISTED_IN` edges (`totalListedInEdges`), and the enrichment flags described below. |
| `vdp` | The computed layers: how many exist (`total_layers`), how many are ready or degraded, per-layer object counts (`virtual_nodes_by_layer`, `virtual_edges_by_layer`), and one entry per layer under `layers`. |
| `rebuild` | Whether a background rebuild of the enrichment indexes is running (`rebuildInProgress`) and their readiness flags. |
| `timestamp` | When the snapshot was taken (UTC). |

### Read the enrichment flags

Four booleans in `threatIntel` say whether an answer that depends on enrichment is being served from current data. `asnEnrichmentLoaded` and the three `prefixBgpEnrichment*` flags cover network attribution (IP to announced prefix to ASN); `dominantCityAnswerable` covers city-level geolocation. When one of them is `false`, hold the corresponding conclusion until it returns to `true`.

### Read a layer before you trust a result

Every entry in `vdp.layers` describes one computed layer:

| Field | What it holds |
|-------|---------------|
| `name` | The layer's name. |
| `ready` | Whether the layer is loaded and serving. |
| `claimed_labels`, `claimed_edge_types` | The node labels and edge types this layer supplies. Match them against the labels and edges in your query. |
| `node_count`, `edge_count`, `edge_count_by_type` | What the layer holds right now. |
| `last_refresh_epoch_millis` | When the layer last refreshed, as a Unix time in milliseconds. |
| `refresh_in_progress` | Whether a refresh is running. |
| `coverage` | `OK`, `DEGRADED` or `EMPTY`. `coverage_by_edge_type` gives the same value per edge type. |

Before you rely on a result, find the layer that supplies the edge type or label you traversed and check two things: `coverage` is `OK`, and `last_refresh_epoch_millis` is as recent as your use case needs. A `DEGRADED` layer answers, but with less than it normally holds, so a thin or empty result drawn from it is not evidence of absence. An `EMPTY` layer holds nothing, and a query through it returns no rows. A `vdp.degraded_layers` value above zero is the quick signal that one layer needs this check, and a response that carries a `vdp-plane-empty` or `vdp-anchor-empty` advisory is telling you the same thing about the query you just ran; see [Advisories](/docs/cypher-api/reference#advisories).

For running actual queries, see [POST /api/query](/docs/cypher-api/reference/query-post).
