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: 0not 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".