Skip to main content

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 laneSigned-token lane
Dashboard view_modepublicprotected
Who can viewanyone with the URLonly a viewer your backend minted a token for
Per-viewer row filteringnoyes, enforced server-side
What you embeda URLa 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 is one of two answers

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,
});
There is no "embed secret"

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:

FieldRequiredNotes
vizIdyesExactly one dashboard. A token cannot cover a folder or an account.
filtersnoThe row filters. See below.
viewerIdnoAn opaque seat id. Audit and metering only — it is not an access-control input. Do not rely on it to scope anything.
ttlSecondsnoDefault 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.
apiKeyIdnoOnly 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:

StatusDetailFix
403the visualization belongs to another accountmint with an API key of the account that owns it
403the visualization is not embeddable with a token: its visibility must be protectedswitch the dashboard's visibility to Protected in the Share dialog
404the visualization is not publishedpublish it first
403no usable API key to bind the embed token tothe key is missing, inactive, expired, or belongs to another account
404Viz not foundcheck vizId
403this embed token was NOT minted: no column ... is named Xthe 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 scope field 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. ..."
}
}
FieldWhat to do with it
fullyScopedThe one to assert on. true only when every dataset the dashboard reads is scoped by these filters. Anything else is false.
filtered[].predicateThe 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.
unresolvedDatasetIdsDatasets 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:

  1. The frame posts EMBED_TOKEN_EXPIRED (or EMBED_TOKEN_INVALID) to your page.
  2. Your page asks your backend for a fresh token.
  3. Your page posts UPDATE_TOKEN into 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:

MessageWhen
READYThe renderer booted.
LOADEDThe dashboard finished loading.
RESIZEContent height changed — use it to size the iframe.
BUNDLE_STARTED / BUNDLE_COMPLETED / BUNDLE_FAILED / BUNDLE_RETRYBuild lifecycle of the dashboard code.
RUNTIME_ERRORThe dashboard code threw.
EMPTY_DATAA query returned no rows.
RENDER_HEALTHPer-tile render health.
EMBED_TOKEN_EXPIRED / EMBED_TOKEN_INVALID / EMBED_UNAVAILABLEToken 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 published and choosing its view_mode.
  • API Reference — the embed-tokens operation in full.