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:
| Field | Effect |
|---|---|
read_only | Denies every write-action permission before the admin bypass. |
scopes | Allowlist of resource.action strings. A ceiling, not a grant. |
scope_org_id / scope_project_id | Pins 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
| Status | Meaning |
|---|---|
401 | Bad, revoked or expired token. |
403 | Permission denied, or the token is scoped to a different org/project. |
404 | Not found or not visible to you. Deliberately indistinguishable — a 404 does not prove the object doesn't exist. |
429 | Rate 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.
| # | Method | Path | Purpose |
|---|---|---|---|
| 1 | GET | /api/webapps/{key} | The config: pages, nav, filters, every block. |
| 2 | POST | /api/dashboards/{dashboard_id}/cards/{card_id}/execute | Run one referenced card. |
| 3 | POST | /api/dashboards/{dashboard_id}/cards/execute-batch | Run many cards on one dashboard in a single call. |
| 4 | POST | /api/webapps/{key}/pages/{page_key}/blocks/{block_idx}/execute | Run one block's server-side SQL. |
| 5 | POST | /api/webapps/preview-filter-options | Turn a filter's options_sql into dropdown options. |
| 6 | GET | /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:
| situation | region | region_codes |
|---|---|---|
| nothing selected | the filter's emptyValue (e.g. "ALL") | same emptyValue |
| single selection | the option's value | the option's code |
| multi selection | array of values | codes 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'sbound_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 containssqlat any nesting depth in the hydratedcard_config. Blocks markedpublic_hiddenare blanked in place, soblock_idxstays 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-optionsis authenticated only; public webapps fall back to the staticoptionsarray 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
GET /api/webapps/{key}once per page load. Cache it — it changes only on republish.- Lay the grid out from
layoutplus the hydratedcard_type. Do not wait for execute results to size the page. - Render static filter
optionsimmediately; call endpoint 5 foroptions_sqlslots, sending the config string verbatim. - Group card refs by
dashboard_id, one batch per dashboard. Call endpoint 4 once percodeblock and perdata_sql-bearinghtmlblock. - Build
paramsper Filters → params: period keys from the filter'sparamsmap;<param>and<param>_codesfor every select filter,emptyValuesentinel when nothing is selected; badge filter-blind cards viabound_params. - Check
erroron every result before readingrows— failures arrive as200. - Honour
truncatedon the block path; surface it rather than showing partial data. - Pass
asset_idon card executes so pinned snapshots resolve. - Treat
403as 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".