Dashboards
Create dashboards, save json-render widgets, set board formatting, and read public share links with live data.
Every dashboard widget is a json-render spec: a flat element tree whose figures
come from loadData actions against tables. The
API stores the spec, and the client runs its loads when it renders.
Signed-in routes need a session or API key. Reads need workflows:read and use
the read rate limit. Writes need workflows:write, use the write rate limit,
and require the caller to own the dashboard.
Dashboard settings
PATCH /api/v1/dashboards/:id
The body takes optional name, description, layout, and settings.
settings holds the board-wide formatting defaults. A component's own format
overrides them field by field. Only the keys you send change; the server merges
them over the stored settings in one locked transaction.
| Field | Values | Default |
|---|---|---|
locale | BCP-47 tag, for example en-US or de-CH | en-US |
currency | ISO-4217 code, display only (no FX conversion) | USD |
negative | minus or parentheses | minus |
fiscalYearStartMonth | Integer 1 through 12 | 1 |
density | compact or comfortable | compact |
curl -X PATCH 'https://API_HOST/api/v1/dashboards/DASHBOARD_ID' \
-H 'Content-Type: application/json' \
-H 'Cookie: better-auth.session_token=SESSION_TOKEN' \
--data '{"settings":{"currency":"EUR","negative":"parentheses"}}'Unknown settings keys return 400. GET /api/v1/dashboards/:id returns the
stored settings; a missing key takes the default above.
POST /api/v1/dashboards also takes an optional settings object with the
same fields. The server validates it the same way and stores it with the new
dashboard.
Widgets
POST /api/v1/dashboards/:id/widgets creates a widget.
PATCH /api/v1/dashboards/:id/widgets/:widgetId replaces its config, its
position, or both. DELETE /api/v1/dashboards/:id/widgets/:widgetId removes it.
| Field | Requirements |
|---|---|
config.version | 1 (the default) |
config.title | Optional string, at most 120 characters |
config.spec | json-render spec {root, elements, state?} |
position | POST: optional {w, h} size hints. PATCH: a complete {x, y, w, h} |
PATCH replaces the whole config; it never merges a partial spec. Send at least
one of config or position. Other keys return 400.
{
"config": {
"version": 1,
"title": "MRR",
"spec": {
"root": "mrr",
"elements": {
"mrr": {
"type": "Metric",
"props": { "label": "MRR", "value": { "$state": "/data/mrr/value" } },
"children": [],
"on": {
"mount": {
"action": "loadData",
"params": {
"target": "/data/mrr",
"tableId": "3f1c2a4e-8b7d-4c1e-9a2b-5d6e7f8a9b0c",
"aggregate": { "fn": "sum", "columnId": "amount" }
}
}
}
}
},
"state": { "filters": {} }
}
}
}The server validates every spec before it saves it:
- The spec must match the component catalog, and
rootmust name an element. spec.statemay contain onlyfilters. Figures come only fromloadData, never literals.- Every
tableIdmust belong to the dashboard company, and every column must exist. - One widget may declare at most 50 load bindings, and one refresh may cost at most 20 table reads.
- The request body is limited to 1 MB.
The dashboard caps apply under a row lock, so concurrent writes cannot both pass:
| Cap | Limit | Status |
|---|---|---|
| Widgets per dashboard | 40 | 409 |
| Load bindings across all widgets | 200 | 409 |
| Total spec size across all widgets | 2 MB | 409 |
Invalid specs return 400 with at most three issues in the message.
Check a spec
POST /api/v1/dashboards/specs/check
Runs the same validation without saving. Send {"spec": {...}}. Requires
workflows:read.
{ "success": true, "data": { "ok": false, "issues": ["spec.root \"nope\" is not an element"] } }A valid spec returns {"ok": true}. The dashboard caps do not apply here.
Public share links
GET /api/v1/dashboards/share/:token needs no authentication. It returns the
dashboard name, description, layout, settings, publishedAt, and
widgets. Each public spec is redacted: loadData bindings keep only their
opaque target, and table IDs, column IDs, and literal filter values are
removed.
| Status | Meaning |
|---|---|
200 | Published dashboard |
404 | Unknown or malformed token |
410 | error.reason is revoked or expired |
Load public data
POST /api/v1/dashboards/share/:token/data
Runs a public viewer's loads on the server against the stored, unredacted spec.
The client names only a widget and a target, so it cannot choose a table,
column, or aggregate.
| Field | Requirements |
|---|---|
requests | 1 through 50 entries |
requests[].widgetId | Widget UUID on this dashboard |
requests[].target | The redacted /data/<key>, at most 64 characters |
requests[].filters | Optional from, to, compareFrom, compareTo (YYYY-MM); compare (none, previous_period, previous_year); customer, plan (at most 100 characters) |
{
"requests": [
{ "widgetId": "WIDGET_ID", "target": "/data/mrr", "filters": { "from": "2026-01", "to": "2026-09" } }
]
}Each result succeeds or fails on its own:
{
"success": true,
"data": {
"results": [
{
"widgetId": "WIDGET_ID",
"target": "/data/mrr",
"ok": true,
"rows": [],
"value": 125000,
"meta": { "asOf": "2026-10-09T12:00:00.000Z", "rowCount": 412, "truncated": false, "stale": false }
}
]
}
}A failed item has ok: false and an error string. The server resolves only
/filters/ state paths from filters; it ignores all other client state.
- The
from..torange may span at most 36 months. On a public share link, a missingtomeans the current month and a missingfrommeans 35 months beforeto. - On a public share link, each row carries only the fields that the
components bound to that
targetdisplay. Other source columns are removed. - On a public share link, a company runs at most 300 uncached queries per
minute across all its links. Past that limit, a figure returns its last good
result, or the error
too many requests. - A result carries at most 500 rows;
meta.truncatedmarks a cut. - The rate limit is 120 requests per minute per IP and 600 per minute per token.
- Results are cached for 60 seconds. If a query fails, the last good result
(kept for 7 days) returns with
meta.stale: true. A figure that depends on free-text search is never cached. - Widget writes and share changes clear the cache.
Save widget layout
PUT /api/v1/dashboards/:id/layout
Requires an authenticated user who owns the dashboard in their active company,
with workflows:write access. Company viewers and invited viewers cannot change
layout. Public share links are read-only.
Send every widget in the dashboard, using IDs and positions from
GET /api/v1/dashboards/:id. The grid has 12 columns. Studio offers widths of
4, 6, or 12 columns and heights of 4 or 8 rows.
| Field | Requirements |
|---|---|
widgets | Complete array, at most 40 entries; empty only for an empty dashboard |
widgets[].id | Unique widget UUID belonging to this dashboard |
widgets[].x | Integer from 0 through 11 |
widgets[].y | Nonnegative safe integer, at most Number.MAX_SAFE_INTEGER - 12 |
widgets[].w | Positive integer, at most 12; x + w must not exceed 12 |
widgets[].h | Positive integer, at most 12 |
curl -X PUT 'https://API_HOST/api/v1/dashboards/DASHBOARD_ID/layout' \
-H 'Content-Type: application/json' \
-H 'Cookie: better-auth.session_token=SESSION_TOKEN' \
--data '{"widgets":[{"id":"00000000-0000-0000-0000-000000000001","x":0,"y":0,"w":6,"h":8}]}'The server closes vertical gaps, resolves overlaps in row/column order, and saves all positions atomically. Always apply the returned positions: they can differ from the submitted Y coordinates. Merge them into your existing widgets by ID. This endpoint never replaces widget configs and does not create a dashboard version.
{
"success": true,
"data": {
"widgets": [
{ "id": "00000000-0000-0000-0000-000000000001", "x": 0, "y": 0, "w": 6, "h": 8 }
],
"updatedAt": "2026-09-18T12:00:00.000Z"
}
}A successful save updates the dashboard timestamp and records one audit event. Send a request after a completed edit, rather than when rendering or polling.
| Status | Meaning |
|---|---|
200 | Complete accepted layout saved |
400 | Invalid coordinates, duplicate IDs, or an ID outside this dashboard |
401 / 403 | Missing authentication or insufficient scope |
404 | Dashboard is unavailable to the caller as its owner |
409 | The layout omits widgets added since the client loaded it; reload before saving |
413 | Request exceeds 1 MB |
429 | Write rate limit reached |
Invalid or stale sets save nothing. A concurrent widget creation is serialized
with a layout save. Creation remains server-placed: POST /:id/widgets accepts
optional width/height hints, and ignores requested X/Y positions. The existing
PATCH /:id/widgets/:widgetId also accepts a complete position within the same
bounds, compacts affected widgets, and keeps the config when it is omitted.
Loopfour