Every dashboard and metric, as data.
Discover the metric catalog, run a query, read a dashboard's definition and its computed tiles, or pull one tile into Excel, Power BI or Google Sheets as CSV. Everything runs as the token's user, with the same access they have in the app.
Authentication and scopes
Use a bearer token from /oauth/token. A query always runs as the token's user: project membership, permission templates and row-level privacy apply. A metric that user can't read comes back in unavailable with a null value. Never a 0.
- analytics:readCatalog, GET and POST query, query/batch, query/detail, dashboards and their /data, packs, explorations, exports.
- analytics:writeCreate and change dashboards, explorations, saved views, alerts, schedules, goals and settings.
- <tool>:readThe read scope of every tool whose metrics you query. The catalog lists them in sources[].scopes (rfis:read, budget:read, …).
401 means the token is missing or expired. 403 means another company or a missing scope. 404 means not found or not visible to you: private dashboards are never revealed.
curl -X POST "https://app.os.construction/oauth/token" \
-H "Content-Type: application/json" \
-d '{"grant_type":"password","username":"you@example.com","password":"…",
"scope":"analytics:read rfis:read budget:read"}'Discover the catalog
GET /catalog lists every metric and dimension you can read, each with a label and a description, the sources behind them, and which dimensions each metric can be grouped or filtered by (compat). Add ?project_id= for one project.
curl "https://app.os.construction/rest/v1.0/companies/{company_id}/analytics/catalog" \
-H "Authorization: Bearer $OS_ACCESS_TOKEN"{
"metrics": [
{ "key": "pm.rfis_open", "label": "Open rfis", "description": "RFIs not yet closed.",
"unit": "count", "direction": "lower_better", "source": "rfis", "domain": "pm", … }
],
"dimensions": [
{ "key": "status", "label": "Status", "type": "category", … },
{ "key": "created", "label": "Created", "type": "time", "grains": ["day", "week", "month", "quarter", "year"], … }
],
"sources": [
{ "key": "rfis", "label": "RFIs", "tool": "rfis", "scopes": ["rfis:read"], "defaultTime": "created", … }
],
"compat": { "pm.rfis_open": ["project", "status", "created", …] }
}Query metrics
A query names up to 8 metrics, up to 3 dimensions (time dimensions take a grain), filters, a time window and an optional sort, topN and limit. Rows come back in columns order.
GET /query?q= takes the same spec as base64url JSON, answers with an ETag (send it back in If-None-Match for a 304), and returns CSV with &format=csv. POST /query/batch runs up to 24 queries in one call. POST /query/detail lists the records behind a number.
curl -X POST "https://app.os.construction/rest/v1.0/companies/{company_id}/analytics/query" \
-H "Authorization: Bearer $OS_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"query": {
"metrics": ["pm.rfis_open", "pm.rfis_total"],
"dimensions": [{ "key": "status" }],
"time": { "preset": "last_90d" }
}}'const base = "https://app.os.construction/rest/v1.0/companies/1/analytics";
const headers = {
Authorization: `Bearer ${process.env.OS_ACCESS_TOKEN}`,
"Content-Type": "application/json",
};
const res = await fetch(`${base}/query`, {
method: "POST",
headers,
body: JSON.stringify({ query: {
metrics: ["pm.rfis_open"],
dimensions: [{ key: "project" }],
time: { preset: "last_90d" },
} }),
});
const { columns, rows, unavailable } = await res.json();
if (unavailable.length) console.warn("not readable with this token:", unavailable);import os, requests
base = "https://app.os.construction/rest/v1.0/companies/1/analytics"
h = {"Authorization": f"Bearer {os.environ['OS_ACCESS_TOKEN']}"}
r = requests.post(f"{base}/query", headers=h, json={"query": {
"metrics": ["pm.rfis_open"],
"dimensions": [{"key": "status"}],
}})
r.raise_for_status()
res = r.json()
print([c["label"] for c in res["columns"]], res["rows"]){
"columns": [
{ "key": "status", "label": "Status", "role": "dim", "type": "string" },
{ "key": "pm.rfis_open", "label": "Open rfis", "role": "metric", "type": "number", "unit": "count" },
{ "key": "pm.rfis_total", "label": "RFIs", "role": "metric", "type": "number", "unit": "count" }
],
"rows": [["open", 4, 4], ["closed", 0, 3], ["draft", 0, 1]],
"labels": { "status": { "open": "Open", "closed": "Closed", "draft": "Draft" } },
"totals": [4, 8],
"truncated": false,
"partial": [],
"unavailable": [],
"meta": { "asOf": "2026-10-26T04:45:14Z", "timezone": "UTC", "projects": 1, "cached": false, "ms": 280,
"plans": [{ "source": "rfis", "via": "base" }] }
}Dashboards and tiles
Every dashboard has an id and three references that hold while it exists: web_url (the page in the app), data_url (its computed tiles) and export_url (the same as CSV). Each tile is addressed by spec.tiles[].id, so data_url?tile=by_status is one tile. Curated packs work the same at /packs/{key}/data, saved explorations at /explorations/{id}/data.
- filters[<key>]Value for one of the dashboard's global filters (spec.filters[].key). Comma-separate several.
- time_presetlast_7d, last_30d, last_90d, mtd, qtd, ytd, last_12m or all: the time window of every tile.
- from, toA fixed window instead (YYYY-MM-DD).
- tileOnly this tile (spec.tiles[].id).
- view_idDashboards only: apply a saved filter view you can read first, then any f or filters[…] per key.
- formatjson (default), csv, xlsx or pdf. A single tile is a plain CSV table.
- fAdvanced: the app's base64url FilterState, with cross-filter chips.
Each tile returns the query it ran with the filters applied, and its result, its records or an error. One failing tile never fails the call.
# Dashboards you can see (page / per_page, Total + Link headers)
curl "https://app.os.construction/rest/v1.0/companies/{company_id}/analytics/dashboards?per_page=100" -H "Authorization: Bearer $OS_ACCESS_TOKEN"
# One definition: spec.filters, spec.tiles (each with a stable id), layout
curl "https://app.os.construction/rest/v1.0/companies/{company_id}/analytics/dashboards/42" -H "Authorization: Bearer $OS_ACCESS_TOKEN"
# Every tile computed as you, this year, two statuses
curl "https://app.os.construction/rest/v1.0/companies/{company_id}/analytics/dashboards/42/data?time_preset=ytd&filters[status]=open,draft" \
-H "Authorization: Bearer $OS_ACCESS_TOKEN"
# One tile as CSV
curl "https://app.os.construction/rest/v1.0/companies/{company_id}/analytics/dashboards/42/data?tile=by_status&format=csv" \
-H "Authorization: Bearer $OS_ACCESS_TOKEN" -o rfis-by-status.csv{
"target": { "type": "dashboard", "id": 42, "name": "PM weekly overview" },
"project_id": null,
"url": "/rest/v1.0/companies/1/analytics/dashboards/42/data",
"filters_applied": ["Created: Year to date", "Status: open, draft"],
"as_of": "2026-10-26T04:46:34Z",
"tiles": [
{
"id": "by_status",
"title": "RFIs by status",
"kind": "query",
"url": "/rest/v1.0/companies/1/analytics/dashboards/42/data?tile=by_status",
"export_url": "/rest/v1.0/companies/1/analytics/dashboards/42/data?tile=by_status&format=csv",
"query": { "metrics": ["pm.rfis_total"], "dimensions": [{ "key": "status" }], "time": { "dim": "created", "preset": "ytd" }, … },
"result": { "columns": [ … ], "rows": [["open", 4], ["draft", 1]], … },
"records": null,
"error": null
}
]
}Saved filter views
A saved view is a named set of dashboard filters (a FilterState). GET|POST /dashboards/{id}/views lists and saves them; GET|PATCH|DELETE /dashboards/{id}/views/{view_id} reads, renames, shares or deletes one.
A view is personal unless shared: nobody else can read a personal view (404). Only dashboard editors can share, un-share, change or delete a shared view. Each person keeps up to 50 views per dashboard. A view holds filter values, never data: data?view_id= still runs every tile as the token's user.
# Save the current filters as a personal view
curl -X POST "https://app.os.construction/rest/v1.0/companies/{company_id}/analytics/dashboards/42/views" \
-H "Authorization: Bearer $OS_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"view": {"name": "Open, this year",
"state": {"values": {"status": ["open"], "created": {"preset": "ytd"}}, "chips": []},
"shared": false}}'
# Views you can see: your own and shared ones
curl "https://app.os.construction/rest/v1.0/companies/{company_id}/analytics/dashboards/42/views" -H "Authorization: Bearer $OS_ACCESS_TOKEN"
# Every tile with that view applied
curl "https://app.os.construction/rest/v1.0/companies/{company_id}/analytics/dashboards/42/data?view_id=7" -H "Authorization: Bearer $OS_ACCESS_TOKEN"Schedules: conditions and webhook delivery
POST /schedules delivers a dashboard, exploration, pack or Project Story daily, weekly or monthly as CSV, XLSX or PDF, rendered per recipient as that recipient. Three fields shape the delivery:
- conditionSend only if: { metric_key, op (gt, lt, pct_up, pct_down), threshold, window (default last_30d), filters }. Checked before each run as the schedule owner. Not met, unreadable or no value: nothing is sent and last_status is skipped. PATCH null removes it.
- view_idDashboards only: a saved view applied before filters. A personal view can only go to its owner (422). If the view is later deleted or un-shared, the run fails rather than sending unfiltered data.
- webhooktrue also posts analytics.schedule.delivered to the company's webhook subscriptions once per run (once per project when burst is project, each event with that project_id and a link to its own file). Analytics admins only (else 403); not for Project Story.
Subscribe to analytics.schedule.delivered (or analytics.*) on a webhook. The payload carries metadata and file.download_url, never values or rows. The file was rendered once as the schedule owner, not per recipient, so hand the link only to systems that may see the owner's view.
GET /schedules/{id}/file?token= downloads it. The signed token is the credential: no bearer token is needed. It expires within 7 days (sooner if the company keeps exports for less). 403 means a bad signature. 410 means expired, the schedule deleted or its webhook turned off, the owner inactive or without Analytics, published report links turned off, or the file removed by retention. With report links off the event still fires, with download_url: null.
curl -X POST "https://app.os.construction/rest/v1.0/companies/{company_id}/analytics/schedules" \
-H "Authorization: Bearer $OS_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"schedule": {
"name": "Late RFIs, Mondays",
"target_type": "dashboard", "target_id": 42, "view_id": 7,
"format": "pdf", "cadence": "weekly", "weekday": 1, "hour": 7,
"timezone": "America/Chicago", "recipient_ids": [12, 31],
"condition": { "metric_key": "pm.rfis_late", "op": "gt", "threshold": 5, "window": "last_30d" },
"webhook": true
}}'{
"event": "analytics.schedule.delivered",
"data": {
"schedule": { "id": 9, "name": "Late RFIs, Mondays", "target_type": "dashboard", "target_id": 42,
"pack_key": null, "format": "pdf", "cadence": "weekly" },
"project_id": null,
"period_key": "…",
"delivered_at": "2026-10-26T12:00:04Z",
"title": "…",
"rendered_as": { "user_id": 12 },
"recipients": 2,
"file": {
"filename": "…", "content_type": "application/pdf", "bytes": 48213,
"download_url": "https://app.os.construction/rest/v1.0/companies/1/analytics/schedules/9/file?token=…",
"expires_at": "2026-11-02T12:00:04Z"
},
"url": "https://app.os.construction/…"
},
…
}Excel, Power BI and Google Sheets
Point the tool at a tile's CSV URL and send the token in the Authorization header. In Excel or Power BI: Get Data → Web → Advanced, or paste the query into the Advanced Editor. In Google Sheets, a small Apps Script function does the fetch.
Access tokens last two hours. A scheduled refresh needs a fresh one: trade the refresh token with grant_type=refresh_token in whatever runs the refresh.
let
Source = Web.Contents(
"https://app.os.construction/rest/v1.0/companies/1/analytics/dashboards/42/data",
[Query = [tile = "by_status", format = "csv"],
Headers = [Authorization = "Bearer " & Token]]),
Table = Table.PromoteHeaders(Csv.Document(Source, [Encoding = 65001]))
in
Table// In a cell: =OS_TILE(42, "by_status")
// Store the token in Project settings → Script properties as OS_TOKEN.
function OS_TILE(dashboardId, tileId) {
const token = PropertiesService.getScriptProperties().getProperty("OS_TOKEN");
const url = "https://app.os.construction/rest/v1.0/companies/1/analytics/dashboards/" + dashboardId +
"/data?format=csv&tile=" + encodeURIComponent(tileId);
const res = UrlFetchApp.fetch(url, { headers: { Authorization: "Bearer " + token } });
return Utilities.parseCsv(res.getContentText().replace(/^\uFEFF/, ""));
}csv = requests.get(f"{base}/dashboards/42/data", headers=h,
params={"tile": "by_status", "format": "csv"})
open("by_status.csv", "wb").write(csv.content)Pagination and limits
- Rate limitPer token, per minute (6,000 by default). Each query in a batch counts. 429 with Retry-After.
- RowsA query returns up to limit rows (default 1,000, max 5,000). truncated: true means more groups exist.
- Recordsquery/detail pages 500 records at a time with cursor / next_cursor.
- ListsDashboards, explorations, alerts, schedules and goals page with page / per_page and the Total, Per-Page and Link headers.
- TimeA query that runs too long is a 408. Dashboard /data and exports get a longer budget.
More on retries and the error format under Limits, paging & errors.