Skip to main content
Version: v0.1.73

Webapp Data API

A webapp is a published multi-page analytics app. Its layout, filters and blocks live in a JSON config; its numbers come from SQL that lives server-side.

This page is the contract for reading that config and executing its blocks from your own frontend — a custom UI, a mobile client, or an embedded surface — instead of using the webapp renderer Honeyframe ships. You never send SQL: the server owns every query, and you send only filter values.

There is no write surface here. A read_only token is denied every edit-class permission before any role check, so publishing, editing config and running ad-hoc SQL are all closed to it by design.

:::info Minimum version Requires platform v0.2.62 or newer. Two things this page depends on landed in that release: card_config hydration on the config response, and preview-filter-options being reachable by a read-only token. Check with GET /api/version. :::

Which host​

Webapps are served by the App surface — the customer-facing tier for a published vertical app:

https://app.your-domain.com

The same routes exist on the Platform surface, but a given webapp is reachable on the tier it was published to. Use the App host unless your operator tells you otherwise.

Authentication​

Every authenticated call carries a bearer Personal Access Token:

Authorization: Bearer hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Tokens are minted by an account holder via POST /api/auth/tokens. The full secret is returned once, in the token field of the create response; only a hash is stored.

A token may carry:

FieldEffect
read_onlyDenies every write-action permission before the admin bypass.
scopesAllowlist of resource.action strings. A ceiling, not a grant.
scope_org_id / scope_project_idPins the token to one org or project. Sending a different X-Org-Id / X-Project-Id is rejected, not silently honoured.

For the endpoints on this page, the required permissions are project.view (config, block execute, filter options) and dashboard.view (card execute). A ceiling of ["project.view", "dashboard.view"] is sufficient and is what we recommend minting.

Failure modes​

StatusMeaning
401Bad, revoked or expired token.
403Permission denied, or the token is scoped to a different org/project.
404Not found or not visible to you. Deliberately indistinguishable — a 404 does not prove the object doesn't exist.
429Rate limited. Back off and retry.

:::note The licence gate is Platform-only On the Platform tier, a token belonging to an unlicensed org gets every authenticated call 403'd with a license_required body. That middleware runs ahead of the endpoint, so it looks like a blanket failure of the whole API rather than a per-endpoint permission problem.

The App tier does not mount it. If you see license_required while pointed at an App host, check your base URL before debugging anything else. :::

The endpoints​

Six, and most integrations use three.

#MethodPathPurpose
1GET/api/webapps/{key}The config: pages, nav, filters, every block.
2POST/api/dashboards/{dashboard_id}/cards/{card_id}/executeRun one referenced card.
3POST/api/dashboards/{dashboard_id}/cards/execute-batchRun many cards on one dashboard in a single call.
4POST/api/webapps/{key}/pages/{page_key}/blocks/{block_idx}/executeRun one block's server-side SQL.
5POST/api/webapps/preview-filter-optionsTurn a filter's options_sql into dropdown options.
6GET/api/public/webapps/{key}Anonymous variant, SQL redacted. Only for webapps published public.

:::danger Don't build against /openapi.json The raw schema exposes every internal router on the service. Those routes are not a contract, change without notice, and will mislead you. This page is the contract. :::

The config​

GET /api/webapps/{key}, where {key} is the webapp's asset_key, lowercased. Cache the response — it only changes on republish.

Alongside metadata (id, org_id, project_id, asset_id, status, theme, …) you get config:

{
"pages": [
{
"key": "overview",
"title": "Overview",
"card_refs": [],
"filters": {}
}
],
"nav": [ { "page_key": "overview", "label": "Overview", "icon": "gauge" } ],
"filters": {},
"parameters": [ { "name": "period", "type": "text", "default": "MTD" } ]
}

config.parameters[] declares defaults. The server fills any declared parameter you do not send on the execute paths, so a first paint is not all-NULL.

Block shapes​

Despite the name, card_refs holds every block kind. Switch on kind.

kind: "card" (or absent) — a dashboard-card reference:

{
"kind": "card",
"dashboard_id": 12,
"card_id": 345,
"layout": { "x": 0, "y": 0, "w": 12, "h": 7 },

"card_type": "bar",
"card_title": "Revenue by region",
"card_config": { "xField": "region", "yField": "revenue" },

"card_config_overrides": {}
}

card_type, card_title and card_config are hydrated onto the config by the server, so you can lay out and style the page before executing anything. card_config is the render spec — the field mapping and styling the chart needs: xField/yField for bar and line, nameField/valueField for pie and donut, color_theme for KPI, content for text cards, plus formatting options. Apply card_config_overrides on top of it when present.

:::note Hydration is permission-gated A card your token cannot see is left unannotated rather than wrong — card_type, card_title and card_config will be absent. Fall back to the authored layout.h for sizing and a generic renderer for type. :::

kind: "code" — a server-executed SQL block. Run it with endpoint 4.

kind: "html" — sandboxed markup that renders client-side and has no API access of its own. When it carries data_sql, run endpoint 4 at that block's index and feed the rows into the markup yourself.

The block_idx contract​

block_idx is the block's index in the card_refs array, and it is the only address the execute path accepts. Index positions are stable — the public redactor blanks hidden blocks in place rather than removing them, so indexes never shift.

Do not filter the array before deriving an index.

Running cards​

Group card refs by dashboard_id and fire one batch per dashboard rather than N single calls.

curl -sS -X POST \
"https://app.your-domain.com/api/dashboards/12/cards/execute-batch?asset_id=42" \
-H "Authorization: Bearer $HF_TOKEN" \
-H "Content-Type: application/json" \
-d '{"card_ids":[345,346],"params":{"period_start":"2026-01-01","period_end":"2026-12-31","period_label":"YTD","region_codes":"ALL"}}'

The batch response is a map keyed by card id — not a list, and not wrapped in results. JSON object keys are strings, so index with String(cardId).

A single card result:

{
"card_id": 345,
"columns": ["region", "revenue"],
"rows": [{ "region": "North", "revenue": 71.2 }],
"row_count": 1,
"execution_ms": 143,
"error": null,
"bound_params": ["period_start", "period_end", "region_codes"]
}

:::warning Failed queries arrive as HTTP 200 A failed query returns 200 with error set to a string and columns: [], rows: [], row_count: 0 — not a 5xx. Always check error before reading rows. This is the most common way an integration silently renders empty charts instead of surfacing a problem. :::

Pass asset_id — the webapp's asset_id — as a query parameter, not a body field, so pinned card snapshots and execute metrics resolve correctly.

type CardResult = {
card_id: number;
columns: string[];
rows: Record<string, unknown>[];
row_count: number;
execution_ms: number;
error: string | null;
bound_params?: string[];
};

async function executeBatch(
dashboardId: number, cardIds: number[],
params: Record<string, unknown> = {}, assetId?: number,
): Promise<Record<string, CardResult>> {
const q = assetId ? `?asset_id=${assetId}` : '';
const res = await fetch(
`${BASE}/api/dashboards/${dashboardId}/cards/execute-batch${q}`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ card_ids: cardIds, params }),
},
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json(); // keys are card ids AS STRINGS
}

:::warning The row cap is intent, not a guarantee The documented contract is a 1000-row cap with a 30s statement timeout. The cap is appended as a LIMIT on the connector-backed path and on the parameterless warehouse path — but on the parameterised warehouse path, the common case for a card executed with params, the SQL runs without an appended LIMIT and only the timeout bounds it. Size your client for a larger result. :::

Filters → params​

params is one flat dict shared by every card in the batch. The keys are not the filter names — they come from the filter definitions in config.filters, and one family of keys is derived client-side. This is the part the config alone does not teach.

Date-range filters (type: "preset_date_range")​

The filter's params object names the request keys your client must send:

"period": {
"type": "preset_date_range",
"params": { "start": "period_start", "end": "period_end", "label": "period_label" }
}

Send ISO dates — {"period_start": "2026-05-01", "period_end": "2026-05-31", "period_label": "MTD"} — using the preset key as the label, or "custom" for a manual range. The filter itself declares no top-level param; only the params map counts.

Select and pill filters (branch_select, pill_multi)​

The filter declares a param (say region). Cards bind two keys — the raw value and a derived companion your client must synthesize:

situationregionregion_codes
nothing selectedthe filter's emptyValue (e.g. "ALL")same emptyValue
single selectionthe option's valuethe option's code
multi selectionarray of valuescodes joined with commas: "N,NE"

<param>_codes is a CSV string of the selected options' code fields (fall back to value when options carry no code). Card SQL guards on the sentinel, so "ALL" means unfiltered:

WHERE (COALESCE(:region_codes, 'ALL') = 'ALL'
OR region = ANY(string_to_array(:region_codes, ',')))

Always send the _codes key with a non-null value — the selection CSV or the emptyValue sentinel. Omitting it is safe for COALESCE-guarded cards, but a card that binds the key without a guard fails on the unbound placeholder.

Cards that ignore a filter​

Not every card reacts to every filter: a current-state KPI has no date dimension; a network-wide total ignores the region selector. The built-in renderer badges these so an unchanging number does not read as a broken filter — replicate it from bound_params:

  • a select filter is active and none of [param, param_codes] appear in the card's bound_params → badge the card ("not filtered by region")
  • the date range moved off its default and none of the period keys appear in bound_params → badge it ("not filtered by period")
  • show at most one badge (the selection badge wins), and only on cleanly loaded cards.

Running blocks​

For code blocks, and html blocks carrying data_sql:

curl -sS -X POST \
"https://app.your-domain.com/api/webapps/my-app/pages/overview/blocks/2/execute" \
-H "Authorization: Bearer $HF_TOKEN" \
-H "Content-Type: application/json" \
-d '{"filters":{}}'

Limits on this path: 8s timeout, 5000 rows. Honour the truncated flag when it comes back — surface it rather than silently showing partial data as if it were complete.

Filter options​

Filter slots that ship a static options array in the config need no call — render them immediately. Slots carrying options_sql use endpoint 5.

curl -sS -X POST \
"https://app.your-domain.com/api/webapps/preview-filter-options" \
-H "Authorization: Bearer $HF_TOKEN" \
-H "Content-Type: application/json" \
-d '{"sql":"SELECT code AS value, name AS label FROM marts.dim_region ORDER BY 2"}'
{ "options": [ { "value": "N", "label": "North", "code": "N" } ], "error": null }

code is present only when the query returns a code column. When the query returns neither value nor label, the first column becomes both and the second becomes the label.

:::danger This is not an ad-hoc SQL runner Although the SQL travels in the request body, a caller without project.edit — which includes every read-only token — may only submit SQL that an editor has already authored as some visible webapp's options_sql. Anything else is rejected:

403 This SQL is not an authored filter's options_sql for any webapp you can
view. Running ad-hoc SQL here requires project.edit.

So read options_sql out of the config and send it back verbatim. Do not construct, template, or tidy it. The constraint exists because the read-only transaction blocks writes but not reads — an unrestricted gate here would let any viewer read credential tables. :::

Every query still runs read-only, with the block path's 8s timeout and 5000-row cap.

Anonymous access​

The same paths exist under /api/public/webapps/… with no Authorization header and an optional ?token= share-link token. Without a share token the webapp must have is_public = true.

Differences from the authenticated set:

  • SQL is redacted from the config — code blocks' sql, html blocks' data_sql, header SQL, the pinned-snapshot map, and any key whose name contains sql at any nesting depth in the hydrated card_config. Blocks marked public_hidden are blanked in place, so block_idx stays stable.
  • PII is masked on every data path — columns classified by name (email, phone, national ID, name, address) come back masked.
  • Filter options are unavailable anonymously. preview-filter-options is authenticated only; public webapps fall back to the static options array in the config.
  • There is no /api/public/dashboards/…. The anonymous card-execute path lives on the webapps router — POST /api/public/webapps/{key}/cards/{dashboard_id}/{card_id}/execute — and returns card metadata and results together, so no second fetch is needed.

If a webapp is not public and you hold no share token, these paths return 404.

Integration checklist​

  1. GET /api/webapps/{key} once per page load. Cache it — it changes only on republish.
  2. Lay the grid out from layout plus the hydrated card_type. Do not wait for execute results to size the page.
  3. Render static filter options immediately; call endpoint 5 for options_sql slots, sending the config string verbatim.
  4. Group card refs by dashboard_id, one batch per dashboard. Call endpoint 4 once per code block and per data_sql-bearing html block.
  5. Build params per Filters → params: period keys from the filter's params map; <param> and <param>_codes for every select filter, emptyValue sentinel when nothing is selected; badge filter-blind cards via bound_params.
  6. Check error on every result before reading rows — failures arrive as 200.
  7. Honour truncated on the block path; surface it rather than showing partial data.
  8. Pass asset_id on card executes so pinned snapshots resolve.
  9. Treat 403 as a stop, not a retryable error.

A loop over one page's blocks, in full:

for (const [idx, blk] of page.card_refs.entries()) {
if (blk.kind === 'code' || (blk.kind === 'html' && blk.data_sql)) {
await executeBlock(key, page.key, idx, filters); // endpoint 4
} else if (blk.kind === undefined || blk.kind === 'card') {
// collect by dashboard_id, then one batch per dashboard // endpoint 3
} else if (blk.kind === 'html') {
// static markup — render blk.html, no data call
}
}

On the public path data_sql is redacted, so you cannot detect a data-bearing block by its presence. Attempt the block execute and treat a 400 ("no data_sql to run") as "this block is static".