Embedding a dashboard
Embedding is the point at which a dashboard stops being something your team opens and becomes part of a product your customers use. Vibedasher has two ways to do it, and the difference is not cosmetic — it decides who can read the data.
| Public lane | Signed-token lane | |
|---|---|---|
Dashboard view_mode | public | protected |
| Who can view | anyone with the URL | only a viewer your backend minted a token for |
| Per-viewer row filtering | no | yes, enforced server-side |
| What you embed | a URL | a URL plus a short-lived token |
A private dashboard is not embeddable by either lane. Both lanes require the dashboard to
be published first — see Publish and share.
Embedding puts our rendered dashboard inside a rectangle on your page. The other answer is to eject — take the React and SQL source and render it yourself, natively, with us out of the render path and only the data engine underneath. Which one you want is decided mostly by two questions: do different viewers need to see different rows (embedding is the only lane that does that), and does the dashboard have to look and behave like the rest of your app (only ejecting gets you that). The trade-offs are laid out side by side in Embed or eject.
The public lane
If the dashboard is published with view_mode: public, an iframe is the whole integration:
<iframe
src="https://cloud.vibedasher.com/v/<slug>"
width="100%"
height="800"
style="border: 0;"
title="Vibedasher dashboard"
sandbox="allow-scripts allow-same-origin allow-downloads allow-popups"
allowfullscreen
loading="lazy"
></iframe>
allow-same-origin looks alarming next to allow-scripts and is safe here specifically
because the frame is cross-origin to your page: it grants the renderer its own origin,
not yours. allow-top-navigation is deliberately absent, so the frame cannot navigate your
page away. allow-downloads is what makes chart PNG export work.
The snippet works from any page you control — nothing on our side has to be configured first. Which audience you publish under is the control that matters here, and The boundary of the embed is where that choice is set out.
The Share dialog in the app gives you this snippet pre-filled. Grab it from there rather than hand-assembling the URL.
The signed-token lane
This is the one worth understanding, because it is the only place Vibedasher does row-level security.
Your backend mints the token — you do not sign anything
Vibedasher signs the token itself, ES256, with its own rotating key. Your backend asks for one with its API key:
from vibedasher.models import EmbedTokenRequest, EmbedTokenRequestFilters
resp = client.embed_tokens.create_embed(
EmbedTokenRequest(
viz_id=1234,
filters=EmbedTokenRequestFilters.from_dict({"region": "EMEA"}),
ttl_seconds=600,
)
)
print(resp.token, resp.expires_in)
const resp = await client.embedTokens.createEmbed({
vizId: 1234,
filters: { region: 'EMEA' },
ttlSeconds: 600,
});
You hold no signing material and need none — mint tokens as shown here.
Or with plain HTTP, from any language:
curl -X POST https://api.vibedasher.com/api/v1/embed/token \
-H "X-Api-Key: $VIBEDASHER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"vizId": 1234, "ttlSeconds": 600, "filters": {"region": "EMEA"}}'
# → {"token": "<ES256 JWT>", "token_type": "embed", "expires_in": 600,
# "scope": { ... what this token permits — see Row filters below ... }}
The request body takes:
| Field | Required | Notes |
|---|---|---|
vizId | yes | Exactly one dashboard. A token cannot cover a folder or an account. |
filters | no | The row filters. See below. |
viewerId | no | An opaque seat id. Audit and metering only — it is not an access-control input. Do not rely on it to scope anything. |
ttlSeconds | no | Default 600. Floor 60, hard ceiling 86 400 (24 h). Values outside the range are clamped at mint and re-checked at verify. Verification allows a small clock-skew margin on top of exp, capped at 120 s and at half the token's own lifetime — so the enforced lifetime is never more than 1.5× expires_in, and from a 240 s TTL upwards the margin is the flat 120 s. |
apiKeyId | no | Only needed when minting from a browser session, to bind the token to a key's domain allow-list and revocation. |
The account_id is stamped from your credential and is never read from the body.
Minting fails with a message that names the check that failed — you are minting, not presenting a token, so the error tells you what to change about the dashboard or the key:
| Status | Detail | Fix |
|---|---|---|
403 | the visualization belongs to another account | mint with an API key of the account that owns it |
403 | the visualization is not embeddable with a token: its visibility must be protected | switch the dashboard's visibility to Protected in the Share dialog |
404 | the visualization is not published | publish it first |
403 | no usable API key to bind the embed token to | the key is missing, inactive, expired, or belongs to another account |
404 | Viz not found | check vizId |
403 | this embed token was NOT minted: no column ... is named X | the filter key is not a column of any of the dashboard's datasets — the message lists the ones that are. Use the sqlName from GET /v1/datasets/{id} |
A public dashboard is rejected by this lane — it already has the anonymous lane, and
mixing the two would be a way to think you had row filters when you did not.
Row filters
filters is a map of column name to a scalar or a list. A scalar becomes =, a list
becomes IN, and several keys are ANDed. Keys are the column's sqlName as
GET /v1/datasets/{id} reports it — the name the SQL view exposes, not the display name.
The platform does not append a WHERE clause to your query; it rewrites each dataset
reference into a filtered relation, keeping the original alias:
FROM orders
-> FROM (SELECT * FROM "orders" WHERE "tenant" = 'acme') AS orders
That is what makes the scope un-widenable rather than merely present. Whatever the page
asks for applies outside the filtered relation, so it can only ever narrow it further —
WHERE tenant = 'globex' OR 1=1 still sees one tenant, a wrapping subquery wraps the
filtered relation, both arms of a UNION are rewritten, and a CTE that shadows the alias,
a schema-qualified reference, and any file-reading function are refused outright.
Three properties make this auditable:
- The filters come only from the verified token. They are never read from the request body, so a viewer poking at the network tab cannot widen their own scope.
- A filter key that names no column of any of the dashboard's datasets is refused at
mint —
403, naming the key and listing the column names that do exist. No token is signed. The same is true of a value that cannot be coerced to its column's type. A filter that silently matched everything would be a data leak wearing a security control's clothes, so it is refused before it can become a link you hand to a customer. - The mint tells you what the token permits, in the
scopefield of its response. Read it; do not assume.
Read scope.fullyScoped — it is not "the filters were accepted"
A dashboard can read more than one dataset, and a filter can only apply to a dataset that
has that column — you cannot filter on a column a table does not have. So a dashboard
over orders and price_list, scoped {"tenant": "acme"}, scopes orders and reads
price_list whole. The page loads, every tile draws, every number is plausible, and
nothing about the request looks wrong.
That is correct and intended when the second dataset is a shared lookup — a price list, a
plan catalogue, a set of labels. It is not what you want when the second dataset holds
per-customer rows under a differently-named column (tenant_id rather than tenant,
customer, org). The mint response is where you find out which case you are in:
{
"token": "<ES256 JWT>", "token_type": "embed", "expires_in": 600,
"scope": {
"vizId": 1234,
"filtered": [{"datasetId": 7, "datasetName": "orders",
"predicate": "\"tenant\" = 'acme'", "columns": ["tenant"]}],
"unfiltered": [{"datasetId": 8, "datasetName": "price_list",
"predicate": null, "columns": []}],
"unresolvedDatasetIds": [],
"fullyScoped": false,
"meaning": "PARTIALLY SCOPED — read this before handing the link out. ..."
}
}
| Field | What to do with it |
|---|---|
fullyScoped | The one to assert on. true only when every dataset the dashboard reads is scoped by these filters. Anything else is false. |
filtered[].predicate | The exact SQL appended to that dataset. This is the sentence to put in front of a security reviewer. |
unfiltered[] | Datasets this token reads whole. Decide, per dataset, whether that is a shared lookup or a gap. |
unresolvedDatasetIds | Datasets whose schema could not be read. Not the same as unfiltered: "reads whole" and "we do not know what this reads" are different answers, and neither is folded into the other. |
A reasonable production check in your mint route is to refuse to return the token when
scope.fullyScoped is false and you expected it to be true — you know which of your
dashboards are meant to be per-customer, and the platform does not.
Present the token
The frame cannot set a header, so the token rides on the URL:
<iframe
src="https://cloud.vibedasher.com/v/<slug>?embed_token=<token>"
sandbox="allow-scripts allow-same-origin allow-downloads allow-popups"
allowfullscreen
></iframe>
Embed responses are sent with Referrer-Policy: no-referrer so the token does not leak into
a Referer header on sub-requests. It is still a bearer credential in a URL, which is the
real reason the default TTL is ten minutes: mint per page load, keep it short, and refresh
it rather than raising the TTL.
Set your domain allow-list
An API key can carry a list of domains that its embed tokens will be honoured from. An
empty list means allow-all. That is a deliberate decision so the feature works the moment
you try it, and it means an unconfigured key protects nothing at the origin level. Set
allowed_domains on the key before you go to production.
The token-refresh loop
A ten-minute token on a dashboard someone leaves open all afternoon needs refreshing, and the renderer tells you when:
- The frame posts
EMBED_TOKEN_EXPIRED(orEMBED_TOKEN_INVALID) to your page. - Your page asks your backend for a fresh token.
- Your page posts
UPDATE_TOKENinto the frame:{ type: 'UPDATE_TOKEN', token: '<new>' }.
window.addEventListener('message', async (e) => {
if (e.origin !== 'https://cloud.vibedasher.com') return;
if (e.data?.type === 'EMBED_TOKEN_EXPIRED' || e.data?.type === 'EMBED_TOKEN_INVALID') {
const token = await fetch('/api/vibedasher-embed-token').then((r) => r.text());
frame.contentWindow.postMessage({ type: 'UPDATE_TOKEN', token }, 'https://cloud.vibedasher.com');
}
});
Always check e.origin. UPDATE_TOKEN is the only message a third-party host may send
inward — everything else is ignored.
What your page actually receives
The messages a third-party host gets are a fixed allow-list of thirteen. This is the whole list:
| Message | When |
|---|---|
READY | The renderer booted. |
LOADED | The dashboard finished loading. |
RESIZE | Content height changed — use it to size the iframe. |
BUNDLE_STARTED / BUNDLE_COMPLETED / BUNDLE_FAILED / BUNDLE_RETRY | Build lifecycle of the dashboard code. |
RUNTIME_ERROR | The dashboard code threw. |
EMPTY_DATA | A query returned no rows. |
RENDER_HEALTH | Per-tile render health. |
EMBED_TOKEN_EXPIRED / EMBED_TOKEN_INVALID / EMBED_UNAVAILABLE | Token lifecycle — see above. |
Anything else you may see referenced is internal. In particular a set of messages is
explicitly never forwarded to a third-party host — VIZ_DATA, FILES, UPDATE_TOKEN
outbound, SQL_ERROR, CONSOLE_ERROR, REQUEST_NEW_AUTH_TOKEN, ELEMENT_SELECTED,
FILTER_STATE_CHANGED — because they carry either your dashboard's source or its raw query
state.
The boundary of the embed
Two properties decide what an integration can be built on, and both are worth reading before you design around the frame rather than after.
The frame owns its own filter state. The messages a third-party host receives are the ones listed above; the dashboard's filter state is not among them, in either direction. The frame is a self-contained view: drive it by the URL and the token you mint, and let it own what happens inside. If your chrome needs to share a selection with the dashboard, put that selection in the token's filter predicate, which the platform appends to every query it authorizes — server-side, and the same answer for every tile.
Choose the audience with framing in mind. A dashboard published public is a public URL,
with everything that follows from one; the signed-token lane is the one to use when it
matters who is looking and from where, and an API key's domain allow-list is what binds its
responses to your origin.
The signed-token query lane runs in eu-central-1. That is where the query runtime for
protected embeds lives. If your account's data is in another region, talk to us before
building on this lane.
Next steps
- Who can see what — where row-level filtering fits in the wider permission model, and why it exists only here.
- Publish and share — getting a dashboard to
publishedand choosing itsview_mode. - API Reference — the
embed-tokensoperation in full.