Skip to content

Querying impact data

Lab has two ways to read, matching the two questions a portfolio holder asks: “show me the evidence across everyone” (table reads) and “show me this organization’s model” (tree reads). All the graph joins happen server-side — you receive ready-to-render JSON.

GET /rows/indicator-reports
?theme=<IRI> repeatable — OR within the parameter
&fund=<funder name> repeatable — OR within the parameter
&org=<org id>
&period[from]=2025-01-01
&period[to]=2025-12-31
&limit=50&cursor=<opaque>

Different parameters AND together; repeating one parameter ORs its values. Each row is fully denormalized:

{
"data": [{
"object": "indicator_report", "id": "…",
"value": 94, "value_text": "94", "unit": "%",
"period_start": "2025-01-01", "period_end": "2025-12-31",
"themes": ["https://metadata.un.org/sdg/1"],
"indicator": {"id": "…", "name": "Tenant retention rate (%)"},
"outcome": {"id": "…", "name": "Housing stability"},
"org": {"id": "…", "name": "Greenfield Housing Co-op"}
}],
"meta": {"row_count": 1, "cursor": null, "cids_version": "cids-3.0", "generated_at": "…"}
}

Pagination: pass meta.cursor back as ?cursor=; null means done. Loop on the cursor, never on row_count. For a scheduled full pull use the snapshot export instead of paging.

Every row also carries the caller’s own uri for itself and for the indicator, outcome, and org it references, plus capsule_id and ingested_at naming the capsule it came from (null for entities created through CRUD calls). meta.version is the namespace’s data version — it changes on every write — and meta.schema_version changes only when a field is removed or renamed.

The same envelope, paging, and uri/capsule_id columns are available for every core type, so a warehouse can load dimension tables without walking organizations one at a time:

Path Row Filters
/rows/organizations one organization —
/rows/outcomes one outcome with its themes and org org
/rows/indicators one indicator with unit, outcome, org org
/rows/funding-statuses one sff:FundingStatus: funder, state, org org
GET /rows/{table}/export
If-None-Match: "<meta.version from the last pull>" # optional

The whole table as one file, built once per data version and served from storage, so a scheduled pull never queries the graph:

  • 200 {"url": "…", "version": "…", "row_count": N, "generated_at": "…", "schema_version": "1", "expires_in": 900} — download url (valid 15 minutes). The file is the paged envelope with every row in data.
  • 304 — nothing changed since the version you sent.
  • 202 {"status": "building", "retry_after": 15} — something changed and the snapshot is being rebuilt; retry after Retry-After seconds.

Snapshots refresh as soon as a capsule is accepted or deleted; after CRUD edits the first pull triggers the rebuild.

Period semantics — overlap. period[from]/period[to] match any report whose period intersects the range, so an annual report spanning a year-end appears in both years’ queries. That matches the “what happened during FY2025” intuition.

Theme semantics. A row’s themes collects themes attached to the indicator’s outcome and directly to the indicator (both are legal CIDS). Three consequences worth knowing before building a dashboard:

  • An outcome with two themes puts its reports under both — theme-group totals can legitimately exceed the report total.
  • Reports whose indicator links to no outcome (and carries no theme of its own) have themes: [] and are unreachable by any theme= filter. The themes facet tells you how many exist.
  • Theme filter values are IRIs, URL-encoded. Nobody types them — copy them from the facet endpoint.

Fund semantics. fund= matches reports from organizations that have an sff:FundingStatus naming this funder. See modelling funds.

Small endpoints that answer “what can I filter by?” — the feed for any dashboard’s filter pickers, with values copy-paste ready for the corresponding filter:

Terminal window
GET /facets/themes
# → {"data": [{"theme": "https://metadata.un.org/sdg/11", "report_count": 39}, …],
# "meta": {"total_reports": 2030, "unthemed_reports": 12}}
GET /facets/funds
# → {"data": [{"fund": "Horizon Impact Fund", "org_count": 11, "report_count": 143}, …]}

meta.unthemed_reports counts the reports no theme filter can reach. The older paths /rows/themes and /rows/funds still answer the same way.

One organization’s full theory of change as a nested document:

GET /orgs/{id}?expand=outcomes.indicators.reports
GET /orgs/{id}/everything # shorthand for the full expansion

expand takes comma-separated dotted paths (a path implies its prefixes): outcomes, outcomes.indicators, outcomes.indicators.reports, indicators, indicators.reports. Without expand, GET /orgs/{id} returns the plain organization.

{
"object": "organization", "id": "…", "uri": "…",
"name": "Greenfield Housing Co-op",
"outcomes": [{
"object": "outcome", "name": "Housing stability",
"themes": ["https://metadata.un.org/sdg/1"],
"indicators": [{
"object": "indicator", "name": "Tenant retention rate (%)", "unit": "%",
"reports": [{"value": 94, "value_text": "94",
"period_start": "2025-01-01", "period_end": "2025-12-31"}]
}]
}],
"indicators": []
}

An indicator appears exactly once per tree. Indicators linked to an outcome nest under it; indicators with no outcome link (optional in CIDS) surface in the organization-level indicators array rather than being dropped. Every object self-identifies via "object".

Rows are small and fully denormalized on purpose: group-by is a few lines client-side, and qualitative reports (value: null) stay visible instead of skewing sums. For example, numeric totals per theme:

import collections, requests
rows = requests.get(f"{BASE}/rows/indicator-reports?limit=500",
headers=headers).json()["data"]
totals = collections.defaultdict(float)
for r in rows:
if r["value"] is not None:
for theme in r["themes"]:
totals[theme] += r["value"]

Server-side aggregation is planned.

GET /export?format=jsonld|nquads # everything in the namespace
GET /capsules/{id}/export?format=… # one capsule's triples, as ingested
GET /orgs/{id}/export?format=… # one organization's subgraph

The organization-scoped export selects the org, its outcomes and indicators, their reports and measure values, plus anything pointing at the org via forOrganization (profiles, funding statuses). Exports return the exact identifiers that were submitted.

Export endpoints do not return the serialization inline. They answer with a short-lived download link:

{"url": "https://…/exports/…", "expires_in": 900,
"content_type": "application/ld+json", "size_bytes": 123456}

Fetch the payload from url within 15 minutes — no credentials needed:

Terminal window
curl -s -H "X-API-Key: $KEY" -H "X-Namespace: $NS" \
"$BASE/export?format=jsonld" | jq -r .url | xargs curl -s > export.jsonld

JSON-LD exports use the CIDS interchange shape — one {"@context": […], "@graph": […]} document compacted against the published contexts — so an export can be re-posted as a capsule.

For a filtered export (chosen organizations, a reporting period) use a background job: the result is one CIDS document, kept under the job for re-download.