Loopfour
API Reference

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.

FieldValuesDefault
localeBCP-47 tag, for example en-US or de-CHen-US
currencyISO-4217 code, display only (no FX conversion)USD
negativeminus or parenthesesminus
fiscalYearStartMonthInteger 1 through 121
densitycompact or comfortablecompact
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.

FieldRequirements
config.version1 (the default)
config.titleOptional string, at most 120 characters
config.specjson-render spec {root, elements, state?}
positionPOST: 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 root must name an element.
  • spec.state may contain only filters. Figures come only from loadData, never literals.
  • Every tableId must 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:

CapLimitStatus
Widgets per dashboard40409
Load bindings across all widgets200409
Total spec size across all widgets2 MB409

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.

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.

StatusMeaning
200Published dashboard
404Unknown or malformed token
410error.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.

FieldRequirements
requests1 through 50 entries
requests[].widgetIdWidget UUID on this dashboard
requests[].targetThe redacted /data/<key>, at most 64 characters
requests[].filtersOptional 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..to range may span at most 36 months. On a public share link, a missing to means the current month and a missing from means 35 months before to.
  • On a public share link, each row carries only the fields that the components bound to that target display. 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.truncated marks 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.

FieldRequirements
widgetsComplete array, at most 40 entries; empty only for an empty dashboard
widgets[].idUnique widget UUID belonging to this dashboard
widgets[].xInteger from 0 through 11
widgets[].yNonnegative safe integer, at most Number.MAX_SAFE_INTEGER - 12
widgets[].wPositive integer, at most 12; x + w must not exceed 12
widgets[].hPositive 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.

StatusMeaning
200Complete accepted layout saved
400Invalid coordinates, duplicate IDs, or an ID outside this dashboard
401 / 403Missing authentication or insufficient scope
404Dashboard is unavailable to the caller as its owner
409The layout omits widgets added since the client loaded it; reload before saving
413Request exceeds 1 MB
429Write 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.

On this page