Embed analytics — SDK + session route (web component)

Production path for static HTML, Svelte, Lit, or any host that can load a custom element: your session-route BFF mints sessions; the page mounts <embedded-canvas-analytics>.

SDK + session route — what is a BFF? A Backend-for-Frontend (BFF) is a thin server route owned by your app (Node, Next.js Route Handler, etc.) that mints embed sessions with your embed API key. The browser calls your session route (BFF); the BFF calls Embedded Canvas. That keeps ec_live_… off the client, binds customerId to your auth session, and is the production-safe path for SaaS embeds.

Part I — Embedded Canvas setup

Control plane · do this first

EC-1. Sign in and open Analytics

  1. Sign in at https://embeddedcanvas.com → open Analytics.

EC-2. Step 1 — Start analytics runtime

  1. Provision runtime until Status: active → Launch analytics builder.

EC-3. Step 2 — Configure workspace

  1. Connect warehouse → set Tenant column → Save.
  2. Confirm two test tenant ids with different row counts.

EC-4. Build dashboard + native filters

  1. Create a dataset/chart that changes between tenants → publish the dashboard.
  2. Add native filters in the builder for every column you will drive from your app UI later (tutorial examples: city, service_line, optional tech_name). These native filters are required even if you hide the filter bar — host-owned filters sync to them via filters.extra (see Host-owned filters).
  3. For a white-label product UX, hide the native filter bar in the iframe and own filters in your app UI (Part II).

EC-5. Register dashboard & dual allowlist

  1. Builder → Embed dashboard → Allowed Domains: http://localhost:5173.
  2. Analytics Step 3 → register → iframe parent origins: http://localhost:5173.
  3. Copy the control-plane embed token UUID — you will paste it into Vite .env as VITE_* (or HTML attributes) in Part II.
Dual allowlist Step 3 iframe parent origins and builder Allowed Domains must both list every host origin.

EC-6. Step 4 — Create embed API key

  1. Create embed API key → copy ec_live_… once and keep it for Part II (you will create the server env file there).
  2. Never put this key in Vite/React/Next public env (VITE_* / NEXT_PUBLIC_*), browser code, or git.

Values checklist

ValueWhere you get itWhere you put it
EMBED_TOKENAnalytics Step 3 — dashboard embed token UUIDClient (and server mint body on BFF)
CUSTOMER_IDTenant id from your warehouse / authServer derives; client may demo via input
EMBED_API_KEYAnalytics Step 4 — ec_live_…Server .env only
EMBEDDED_CANVAS_API_URLhttps://api.embeddedcanvas.comServer .env only

Where to put each credential

Copy values from the Analytics wizard into the files below. Secrets stay on the server; the browser only gets the public dashboard embed token + short-lived session from your BFF.

CredentialLooks likePut it hereNever put it here
EMBED_TOKEN UUID from Analytics Step 3 Server: BFF .env next to server.mjs as EMBED_TOKEN (session mint body)
Browser: Vite .env as VITE_* (or HTML attributes) / embed-token / customer-id on the custom element as embedToken (or NEXT_PUBLIC_EMBED_TOKEN / VITE_EMBED_TOKEN)
—
EMBED_API_KEY ec_live_… from Analytics Step 4 Server only: BFF .env next to server.mjs → read in your BFF; send as Authorization: Bearer … Vite/React client code, NEXT_PUBLIC_*, browser localStorage, repo commits
EMBEDDED_CANVAS_API_URL https://api.embeddedcanvas.com Server only: BFF .env next to server.mjs Client bundles (unless you only proxy via BFF — still prefer server)
CUSTOMER_ID / customerId Tenant key from your auth / warehouse Resolved in the BFF from the signed-in user, then returned/used in session mint. Demo UI may use a test input. Hard-coded production tenant ids that customers can spoof without auth checks
Session guestToken Short-lived token from BFF response Memory only — SDK receives it via fetchSession; do not persist long-term Logs, analytics tools, permanent cookies
Integrator mental model ec_live_… unlocks minting on the server. EMBED_TOKEN selects which dashboard to open (safe in the client). customerId selects which tenant rows the guest token may see.

Part II — HTML web component + session route (BFF)

Your laptop · follow in order

You will create a Vite vanilla app (no React/Vue) and a Node server.mjs BFF in the same folder. The browser never sees ec_live_….

1. Scaffold Vite vanilla + install SDKs

In a terminal, run:

npm create vite@latest my-analytics-html -- --template vanilla
cd my-analytics-html
npm install
npm install @embeddedcanvas/embed-sdk@^1.8.3 @superset-ui/embedded-sdk

Stay in my-analytics-html for every remaining step. Vite’s vanilla template gives you index.html and src/main.js — no framework required.

Requirement Use Node 20+ so node --env-file=.env works when you start the BFF. Check with node -v.

2. Create server.mjs (the BFF)

This file does not exist yet — Vite does not create it. In the project root (next to package.json), create server.mjs.

  1. Create the file: touch server.mjs / PowerShell New-Item server.mjs / New File in your editor.
  2. Paste the entire contents below and save (do not leave the file empty — empty → HTTP 405).
/**
 * Session-route BFF — mints embed sessions with your embed API key.
 * Run from this folder: npm run bff
 * (uses Node's --env-file=.env; requires Node 20+)
 */
import http from "node:http";

const API_URL = (process.env.EMBEDDED_CANVAS_API_URL || "").replace(/\/$/, "");
const EMBED_API_KEY = process.env.EMBED_API_KEY?.trim();
const EMBED_TOKEN = process.env.EMBED_TOKEN?.trim();
const PORT = Number(process.env.PORT || 3090);

function json(res, status, body) {
  res.writeHead(status, {
    "Content-Type": "application/json",
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Methods": "POST, OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type",
  });
  res.end(JSON.stringify(body));
}

const server = http.createServer(async (req, res) => {
  if (req.method === "OPTIONS") return json(res, 204, {});
  if (req.method === "POST" && req.url === "/api/analytics/session") {
    if (!API_URL || !EMBED_API_KEY || !EMBED_TOKEN) {
      return json(res, 500, { error: "Configure .env (EMBEDDED_CANVAS_API_URL, EMBED_API_KEY, EMBED_TOKEN)" });
    }
    let body = "";
    for await (const chunk of req) body += chunk;
    let customerId = process.env.TEST_CUSTOMER_ID || "CUST-1004";
    let filters;
    try {
      const parsed = body ? JSON.parse(body) : {};
      if (typeof parsed.customerId === "string" && parsed.customerId.trim()) {
        customerId = parsed.customerId.trim();
      }
      filters = parsed.filters;
    } catch {
      return json(res, 400, { error: "Invalid JSON" });
    }
    const payload = { embedToken: EMBED_TOKEN, customerId };
    if (filters) payload.filters = filters;
    const upstream = await fetch(`${API_URL}/public/embed/v1/sessions`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${EMBED_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(payload),
    });
    return json(res, upstream.status, await upstream.json());
  }
  json(res, 404, { error: "Not found" });
});

server.listen(PORT, () => {
  console.log(`BFF listening on http://localhost:${PORT}`);
  console.log(`POST http://localhost:${PORT}/api/analytics/session`);
});

3. Create project-root .env (paste tokens)

Create one .env next to server.mjs and package.json. Both the BFF and Vite read from it (Vite only injects names that start with VITE_).

  1. Create the file: touch .env (or New File → .env).
  2. Paste the template below.
  3. Replace the placeholders with the values you copied in Part I:
    • EMBED_API_KEY ← Step 4 ec_live_…
    • EMBED_TOKEN and VITE_EMBED_TOKEN ← paste the same Step 3 UUID into both lines
    • TEST_CUSTOMER_ID and VITE_TEST_CUSTOMER_ID ← same warehouse tenant id (example: co_alltrade)
  4. Save. Confirm .env is listed in .gitignore.
# Server only — read by: npm run bff  (node --env-file=.env server.mjs)
EMBEDDED_CANVAS_API_URL=https://api.embeddedcanvas.com
EMBED_API_KEY=ec_live_PASTE_STEP4_KEY
EMBED_TOKEN=PASTE_STEP3_EMBED_TOKEN_UUID
PORT=3090
TEST_CUSTOMER_ID=CUST-1004

# Same Step 3 UUID again — Vite only injects names that start with VITE_ into src/
VITE_EMBED_TOKEN=PASTE_STEP3_EMBED_TOKEN_UUID
# Same tenant as TEST_CUSTOMER_ID — main.js sets customer-id from this
VITE_TEST_CUSTOMER_ID=CUST-1004
Why does the embed token appear twice? It is one dashboard embed token (Step 3 UUID), not two different secrets.
  • EMBED_TOKEN — Node BFF reads this and sends it in the session mint body.
  • VITE_EMBED_TOKEN — the page reads this and sets the embed-token attribute.
Do not create a VITE_EMBED_API_KEY — the API key stays server-only.
Never put ec_live_… in HTML Only the embed token UUID and tenant id belong in the browser. The custom element uses session-url so the BFF holds the API key.

4. Add the bff script to package.json

Open package.json. Inside the existing "scripts" object, add the bff line (keep dev / build / preview). Example:

File: package.json

{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "bff": "node --env-file=.env server.mjs"
  }
}

Save. From now on npm run bff starts server.mjs with your .env.

5. Create vite.config.js (proxy /api/…)

Vite’s vanilla template often ships without a config file — that is normal. Create vite.config.js in the project root (next to package.json) so session-url="/api/analytics/session" on http://localhost:5173 is forwarded to the BFF on port 3090.

  1. Create the file: New-Item vite.config.js (PowerShell) or New File in your editor.
  2. Paste the entire contents below and save:
import { defineConfig } from "vite";

export default defineConfig({
  server: {
    proxy: {
      "/api/analytics/session": {
        target: "http://localhost:3090",
        changeOrigin: true,
      },
    },
  },
});

If a vite.config.ts already exists instead, add the same server.proxy block there (do not create a second config). After saving, restart npm run dev.

6. Replace index.html + src/main.js

  1. Replace all of index.html with:
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Embedded Canvas — HTML</title>
    <style>
      body { margin: 0; font-family: system-ui, sans-serif; color: #0f172a; }
      .bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 12px; }
      .bar input { padding: 6px 8px; min-width: 140px; }
      .meta { padding: 0 12px; color: #64748b; font-size: 14px; }
      #mount { min-height: 70vh; }
    </style>
  </head>
  <body>
    <p class="meta">Tenant: <code id="tenantLabel"></code></p>
    <div class="bar">
      <input id="city" placeholder="city" />
      <input id="serviceLine" placeholder="service_line" />
      <button type="button" id="apply">Apply</button>
    </div>
    <div id="mount">
      <embedded-canvas-analytics
        id="embed"
        session-url="/api/analytics/session"
        layout="chromeless"
      ></embedded-canvas-analytics>
    </div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>
  1. Replace all of src/main.js with:
import { registerEmbeddedCanvasAnalyticsElement } from "@embeddedcanvas/embed-sdk/web-component";

registerEmbeddedCanvasAnalyticsElement();

const EMBED_TOKEN = import.meta.env.VITE_EMBED_TOKEN || "YOUR_EMBED_TOKEN_UUID";
const tenantId = import.meta.env.VITE_TEST_CUSTOMER_ID || "CUST-1004";

const embed = document.getElementById("embed");
embed.setAttribute("embed-token", EMBED_TOKEN);
embed.setAttribute("customer-id", tenantId);
document.getElementById("tenantLabel").textContent = tenantId;

document.getElementById("apply").onclick = async () => {
  const city = document.getElementById("city").value.trim();
  const serviceLine = document.getElementById("serviceLine").value.trim();
  const extra = {};
  if (city) extra.city = city;
  if (serviceLine) extra.service_line = serviceLine;
  // setFilters remints via session-url POST with filters.extra (SDK 1.8.3+)
  await embed.setFilters(Object.keys(extra).length ? { extra } : {});
};
  1. Confirm VITE_EMBED_TOKEN and VITE_TEST_CUSTOMER_ID in .env match the BFF values. After editing .env, restart npm run dev.
  2. You can delete unused starter files (src/counter.js, default CSS) if the template left them — keep the mount working first.
What the custom element does
  • session-url — browser POSTs { customerId, filters? } to your BFF (never calls Embedded Canvas with the API key).
  • embed-token / customer-id — Step 3 UUID + RLS tenant (safe in the browser).
  • setFilters({ extra }) — remints the session with host-owned filter values (requires @embeddedcanvas/embed-sdk@^1.8.3).

7. Run BFF + Vite and open the app

  1. Terminal A: npm run bff → expect BFF listening on http://localhost:3090.
  2. Terminal B: npm run dev → open the URL Vite prints (usually http://localhost:5173).
  3. You should see the dashboard for VITE_TEST_CUSTOMER_ID.
Both processes required If only Vite is running, POST /api/analytics/session fails (proxy has nowhere to go). If only the BFF is running, there is no page to open.

Quick troubleshooting

Suggested layout when finished:

my-analytics-html/
  .env                 ← API key + tokens (gitignored)
  server.mjs           ← BFF
  package.json         ← "bff" script
  vite.config.js       ← proxy /api/analytics/session → :3090
  index.html           ← host filter UI + <embedded-canvas-analytics>
  src/main.js          ← register element, set attributes, Apply → setFilters

Host-owned filters (non-native UI) — step by step

White-label filter UX

Native filters are the filter controls inside the analytics builder / iframe. Host-owned filters are inputs in your app (the city / service_line boxes in Part II). Your app collects values → sends them as filters.extra on session mint → Embedded Canvas applies them and syncs matching native filters. End users never need the native filter bar.

F-1. Create matching native filters in the builder (required)

  1. Open the dashboard in the analytics builder (Part I).
  2. Add a native filter for each column your host UI will drive (examples used in these tutorials: city, service_line).
  3. The filter must target the same dataset column you will send in filters.extra. Keys are matched by column name (case/spelling must match warehouse values too).
  4. Publish / save the dashboard.
  5. Optional white-label: hide the native filter bar in the builder embed settings / keep layoutMode: "chromeless" (Part II samples already do this).
If host filters do nothing Usually the native filter was never created, the column name does not match the filters.extra key, or the typed value does not exist in the data (wrong spelling/case).

F-2. Collect values in your app UI

Part II already adds simple inputs (e.g. city, service_line) and an Apply button. Those are host-owned filters — not Superset UI.

F-3. Send values on session mint as filters.extra

Your session-route BFF (server.mjs) must include non-empty host values under filters.extra when calling POST /public/embed/v1/sessions (directly or via your BFF):

{
  "embedToken": "…",
  "customerId": "co_alltrade",
  "filters": {
    "extra": {
      "city": "Denver",
      "service_line": "Electrical"
    }
  }
}

In Part II sample apps, the browser posts that shape to /api/analytics/session; the BFF forwards filters to Embedded Canvas. Empty strings are omitted — only filled inputs are sent.

F-4. On Apply, remint (do not only update local React/Vue state)

In the HTML sample, Apply builds extra from the city / service_line inputs, then calls await embed.setFilters({ extra }) on the custom element. The element remints through your BFF with filters.extra (SDK 1.8.3+). Changing the inputs alone does not remint — you must click Apply. Native filters for those columns must exist on the dashboard (F-1).

Changing an input alone does nothing until Apply remints the guest session with the new filters.extra. Charts then reload under RLS + the new filter values.

F-5. Verify

  1. DevTools → Network → POST …/session (or your BFF path) → confirm JSON includes filters.extra.
  2. Type a value you know exists for the current tenant → Apply → chart numbers should change.
  3. Clear inputs → Apply → broader (or unfiltered) result for that tenant.
Mental model Native filters = definition in the builder (which columns can be filtered). Host-owned filters = your UI + filters.extra at mint time. Both are required for white-label filter UX on the SDK + session route path.