Esc

↑↓ move↵ openIndex · Pagefind
Analytics API

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.

Live/rest/v1.0/companies/{company_id}/analyticsEndpoint reference →

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:read
    Catalog, GET and POST query, query/batch, query/detail, dashboards and their /data, packs, explorations, exports.
  • analytics:write
    Create and change dashboards, explorations, saved views, alerts, schedules, goals and settings.
  • <tool>:read
    The 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.

A token for analyticscurl
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.

Catalogcurl
curl "https://app.os.construction/rest/v1.0/companies/{company_id}/analytics/catalog" \
  -H "Authorization: Bearer $OS_ACCESS_TOKEN"
200 response (trimmed)json
{
  "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.

Run a query
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" }
      }}'
200 responsejson
{
  "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_preset
    last_7d, last_30d, last_90d, mtd, qtd, ytd, last_12m or all: the time window of every tile.
  • from, to
    A fixed window instead (YYYY-MM-DD).
  • tile
    Only this tile (spec.tiles[].id).
  • view_id
    Dashboards only: apply a saved filter view you can read first, then any f or filters[…] per key.
  • format
    json (default), csv, xlsx or pdf. A single tile is a plain CSV table.
  • f
    Advanced: 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.

List, read, computecurl
# 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
GET /dashboards/42/data (trimmed)json
{
  "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, list, applycurl
# 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:

  • condition
    Send 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_id
    Dashboards 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.
  • webhook
    true 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.

Send only if, with a saved view, also to webhookscurl
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
      }}'
analytics.schedule.delivered (trimmed)json
{
  "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.

One tile into a sheet
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

Pagination and limits

  • Rate limit
    Per token, per minute (6,000 by default). Each query in a batch counts. 429 with Retry-After.
  • Rows
    A query returns up to limit rows (default 1,000, max 5,000). truncated: true means more groups exist.
  • Records
    query/detail pages 500 records at a time with cursor / next_cursor.
  • Lists
    Dashboards, explorations, alerts, schedules and goals page with page / per_page and the Total, Per-Page and Link headers.
  • Time
    A 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.

Ask the OS

Reading this page

Ask anything about this page. Answers use what you're reading as context.

Suggested

    AI · may be imperfect · sample data where shown

    Open file