# 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](/guides/publish-and-share.md).

Embedding is one of two answers

Embedding puts *our* rendered dashboard inside a rectangle on your page. The other answer is to [eject](/guides/eject-your-dashboard.md) — 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](/guides/embed-or-eject.md)**.

## The public lane[​](#the-public-lane "Direct link to 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](#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[​](#the-signed-token-lane "Direct link to 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[​](#your-backend-mints-the-token--you-do-not-sign-anything "Direct link to 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:

| 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[​](#row-filters "Direct link to 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"[​](#read-fullyscoped "Direct link to read-fullyscoped")

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[​](#present-the-token "Direct link to 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[​](#set-your-domain-allow-list "Direct link to 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[​](#the-token-refresh-loop "Direct link to 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[​](#what-your-page-actually-receives "Direct link to 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[​](#the-boundary-of-the-embed "Direct link to 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[​](#next-steps "Direct link to Next steps")

* **[Who can see what](/guides/who-can-see-what.md)** — where row-level filtering fits in the wider permission model, and why it exists only here.
* **[Publish and share](/guides/publish-and-share.md)** — getting a dashboard to `published` and choosing its `view_mode`.
* **[API Reference](/reference/vibedasher-sdk-api.md)** — the `embed-tokens` operation in full.
