---
sidebar_position: 5
title: Embedding Dives in your web application
description: Embed interactive MotherDuck Dives in your web app using iframes and embed sessions
feature_stage: preview
---

import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';

You can embed Dives in your own web application so your users can interact with live data dashboards without signing in to MotherDuck. Your backend creates an embed session, and your frontend loads the Dive in a sandboxed iframe.

Embedding Dives is available on the **Business plan**.

## Prerequisites

Before you start, you need:

- A **MotherDuck Business plan** account
- A read/write access token for an account with the Admin role. For production, we recommend using a dedicated [service account](/key-tasks/service-accounts-guide/create-and-configure-service-accounts/)
- A Dive you want to embed, with its [data shared](/sql-reference/mcp/share-dive-data) to the target service account that the embedded Dive will run as
- A backend server that can make authenticated API calls

:::tip Use a dedicated service account
We recommend using a service account that does not own databases with the same names as the databases your Dives query. When the service account attaches shared Dive data, the share alias defaults to the source database name. If the service account already has a database with that name, the attach fails. Using a dedicated, empty service account for embedding avoids this conflict.
:::

## How it works

Embedded Dives follow a short server-side flow:

1. **Your backend** calls the MotherDuck API with your access token to create an embed session: an opaque string that contains a read-only session string and the information needed to load the Dive.
2. **Your frontend** renders a sandboxed iframe that loads the Dive from `embed-motherduck.com`, passing the session string.
3. **MotherDuck** loads the Dive and runs live SQL queries.

Your end-users see an interactive dashboard without needing a MotherDuck account.

::::info[Two tokens are in play]
Your service account's access token is a **high-privilege read-write admin token** that stays on your backend and is used only to create embed sessions. The session string it produces contains a **separate, read-only token** that is limited in scope and expires after 24 hours. Only the session string should ever reach the frontend.
::::

```mermaid
sequenceDiagram
    participant M as MotherDuck
    participant B as Your backend
    participant F as Your frontend
    participant E as Embed iframe

    Note over B: Holds your access token
    B->>M: POST /v1/dives/<dive_id>/embed-session
    M-->>B: Session string
    B-->>F: Return session string
    F->>E: Load iframe /sandbox/#session=<session>
    Note over F,E: The session stays in the<br />URL fragment, not the request
    E->>M: Fetch Dive metadata and content
    M-->>E: Return the Dive
```

## Step 1: Create an embed session

Your backend calls the MotherDuck API to create an embed session. The access token used for this call must belong to an account with admin-level access. The session string contains a read-only token that expires after 24 hours.

::::warning[Important]
**Never expose your access token in client-side code.** The access token stays on your backend. Only the session string reaches the browser.
::::


<Tabs groupId="language">
<TabItem value="node" label="Node.js" default>

```javascript
const DIVE_ID = "<your_dive_id>";

const response = await fetch(
  `https://api.motherduck.com/v1/dives/${DIVE_ID}/embed-session`,
  {
    method: "POST",
    headers: {
      // This is the admin account used to generate the embed session.
      Authorization: `Bearer ${MOTHERDUCK_TOKEN}`,
      "Content-Type": "application/json",
    },
    // This is the service account whose compute / perms will be used for the Dive.
    body: JSON.stringify({ username: SERVICE_ACCOUNT_USERNAME }),
  }
);

if (!response.ok) {
  throw new Error(`Failed to create embed session: ${response.status}`);
}

const { session } = await response.json();
// Return this session string to your frontend
```

</TabItem>
<TabItem value="python" label="Python">

```python
import httpx

DIVE_ID = "<your_dive_id>"

response = httpx.post(
    f"https://api.motherduck.com/v1/dives/{DIVE_ID}/embed-session",
    headers={
        "Authorization": f"Bearer {MOTHERDUCK_TOKEN}",
        "Content-Type": "application/json",
    },
    json={"username": SERVICE_ACCOUNT_USERNAME},
)
response.raise_for_status()
session = response.json()["session"]
# Return this session string to your frontend
```

</TabItem>
</Tabs>

Replace `<your_dive_id>` with the ID of your Dive. You can find this in **Settings** > **Dives** or through the [`list_dives`](/sql-reference/mcp/list-dives) MCP tool.

Each session is tied to a single Dive. If you embed multiple Dives on the same page, create a separate embed session for each one. You can use the same service account and access token for all of them. The session string is base64-encoded but **not encrypted** — it contains a read-only (read scaling) token, the Dive ID, and endpoint URLs. Treat it like a short-lived credential: do not log it or store it in persistent storage.
The embedded Dive runs queries as the service account specified in the session. If you need data isolation (for example, separate databases per region), use separate service accounts scoped to only the data each should access.

## Step 2: Embed the iframe

Add a sandboxed iframe to your page that points to the MotherDuck embed URL. Pass the session string in the URL fragment:

```html
<iframe
  src="https://embed-motherduck.com/sandbox/#session=<session_from_backend>"
  sandbox="allow-scripts allow-same-origin"
  width="100%"
  height="600"
  style="border: none;"
></iframe>
```

Replace `<session_from_backend>` with the session string your backend generated.

The `sandbox` attribute must include `allow-scripts allow-same-origin` for the embed to function.

### Query modes

Embedded Dives use **dual mode** by default, where queries can use browser DuckDB WASM or run server-side through MotherDuck depending on the query. Dual mode is required for browser DuckDB features such as data exports.

You can force **server mode** for embeds that only need server-side SQL queries. To use server mode, add `?queryMode=server` to the iframe URL:

```html
<iframe
  src="https://embed-motherduck.com/sandbox/?queryMode=server#session=<session_from_backend>"
  sandbox="allow-scripts allow-same-origin"
  width="100%"
  height="600"
  style="border: none;"
></iframe>
```

#### Server mode data type limitations

Server mode runs queries through the Postgres wire protocol, which does not support all DuckDB data types. Basic types (integers, strings, floats) work fine, but nested types (structs, lists) and some less common timestamp types may not render correctly. If you encounter issues with specific columns, try dual (WASM) mode, which supports the full range of DuckDB types.

### URL structure

| Part | Description |
|------|-------------|
| `embed-motherduck.com/sandbox/` | The MotherDuck embed host |
| `?queryMode=server` | Optional: forces server-only query mode |
| `#session=<session>` | The session string, passed in the URL fragment so it is never sent to the server |

The session is placed in the URL fragment (after `#`) rather than the query string. Browsers strip fragments before making HTTP requests, so the session does not appear in server logs or Referer headers.

## Handle link navigation from embedded Dives

Embedded Dives run inside an isolated MotherDuck sandbox iframe. Dive code cannot directly navigate the parent page or open popups. When someone clicks a link in an embedded Dive, or Dive code calls `window.open()`, the sandbox blocks the browser navigation and sends a `postMessage` to the parent page.

The message has the following shape:

```typescript
type NavigationRequest = {
  type: "navigation-request";
  url: string;
  source: "anchor-click" | "window-open";
  target: "_blank" | "_self" | null;
  rel: string | null;
};
```

The parent page decides how to handle the request. Listen for `navigation-request`, validate the event origin and URL, and apply your own policy before opening anything.

The following example uses `window.confirm`; replace it with your application's confirmation UI:

```typescript
const iframe = document.querySelector<HTMLIFrameElement>("#motherduck-dive");

if (!iframe) {
  throw new Error("MotherDuck Dive iframe not found");
}

const motherduckEmbedOrigin = new URL(iframe.src).origin;

window.addEventListener("message", (event) => {
  if (event.origin !== motherduckEmbedOrigin) return;
  if (event.source !== iframe.contentWindow) return;

  const message = event.data;
  if (message?.type !== "navigation-request") return;

  let url: URL;
  try {
    url = new URL(message.url);
  } catch {
    return;
  }

  if (!["https:", "http:"].includes(url.protocol)) return;

  const confirmed = window.confirm(`Open ${url.toString()}?`);
  if (!confirmed) return;

  window.open(url.toString(), "_blank", "noopener,noreferrer");
});
```

::::warning[Important]
Treat `navigation-request` as untrusted user intent from sandboxed content, not as a command. The parent page should not navigate, submit forms, mutate application state, or grant permissions based only on the message.
::::

### Use absolute URLs in Dive links

If you plan to embed a Dive, use absolute URLs in links inside the Dive. Avoid app-relative links like this:

```html
<a href="/settings/members">Settings</a>
```

In an embedded Dive, `/settings/members` resolves against the embed origin, not the MotherDuck app. The parent page receives a URL such as:

```text
https://embed-motherduck.com/settings/members
```

Use absolute URLs instead:

```html
<a href="https://motherduck.com/docs/">Docs</a>
<a href="https://app.motherduck.com/dives/<dive_id>">Another Dive</a>
```

For embedded Dives, the parent page owns the policy for whether a navigation request opens a new tab, replaces the current page, or is blocked.

## Handle data exports from embedded Dives

Dives can include export buttons created with the `exportAs` return value from `useSQLQuery()` or the `useExport()` hook. When a user starts an export, the Dive runs the export SQL with DuckDB `COPY TO` and sends the generated file to the parent page.

Because embedded Dives run in a sandboxed iframe, the iframe cannot download the file directly. Your parent page must listen for export messages, validate the event, and decide how to offer the file to your user.

Embedded exports support `csv`, `json`, `parquet`, and `xlsx` formats. Exports require dual mode because file generation uses browser DuckDB. If you force `?queryMode=server`, export controls return an error.

The parent page receives these message types:

```typescript
type ExportStarted = {
  type: "export-started";
  requestId: string;
  format: "csv" | "json" | "parquet" | "xlsx";
  title?: string;
  filename: string;
};

type ExportFile = {
  type: "export-file";
  requestId: string;
  format: "csv" | "json" | "parquet" | "xlsx";
  title?: string;
  filename: string;
  mimeType: string;
  byteLength: number;
  previewOptions?: Record<string, unknown>;
  data: ArrayBuffer;
};

type ExportError = {
  type: "export-error";
  requestId: string;
  format: "csv" | "json" | "parquet" | "xlsx";
  title?: string;
  filename?: string;
  error: string;
};
```

The following example stores the completed export and shows a host-page download button. Replace the status and button UI with your application's pattern:

```html
<iframe
  id="motherduck-dive"
  src="https://embed-motherduck.com/sandbox/#session=<session_from_backend>"
  sandbox="allow-scripts allow-same-origin"
  width="100%"
  height="600"
  style="border: none;"
></iframe>

<p id="dive-export-status" aria-live="polite"></p>
<button id="dive-export-download" type="button" hidden>
  Download export
</button>
```

```javascript
const iframe = document.querySelector("#motherduck-dive");
const status = document.querySelector("#dive-export-status");
const downloadButton = document.querySelector("#dive-export-download");

if (!iframe || !status || !downloadButton) {
  throw new Error("MotherDuck Dive export controls not found");
}

const motherduckEmbedOrigin = new URL(iframe.src).origin;
let pendingExport = null;

function isArrayBuffer(value) {
  return Object.prototype.toString.call(value) === "[object ArrayBuffer]";
}

function isExportFile(message) {
  return (
    message?.type === "export-file" &&
    typeof message.requestId === "string" &&
    typeof message.filename === "string" &&
    typeof message.mimeType === "string" &&
    typeof message.byteLength === "number" &&
    isArrayBuffer(message.data)
  );
}

window.addEventListener("message", (event) => {
  if (event.origin !== motherduckEmbedOrigin) return;
  if (event.source !== iframe.contentWindow) return;

  const message = event.data;

  if (message?.type === "export-started") {
    status.textContent = `Preparing ${message.filename}`;
    downloadButton.hidden = true;
    pendingExport = null;
    return;
  }

  if (message?.type === "export-error") {
    status.textContent = `Export failed: ${message.error}`;
    downloadButton.hidden = true;
    pendingExport = null;
    return;
  }

  if (!isExportFile(message)) return;

  pendingExport = message;
  status.textContent = `${message.filename} is ready to download`;
  downloadButton.hidden = false;
});

downloadButton.addEventListener("click", () => {
  if (!pendingExport) return;

  const blob = new Blob([pendingExport.data], {
    type: pendingExport.mimeType || "application/octet-stream",
  });
  const url = URL.createObjectURL(blob);
  const link = document.createElement("a");
  link.href = url;
  link.download = pendingExport.filename;
  document.body.appendChild(link);
  link.click();
  link.remove();
  URL.revokeObjectURL(url);

  pendingExport = null;
  downloadButton.hidden = true;
  status.textContent = "Export downloaded";
});
```

::::warning[Important]
Treat export messages as untrusted content from sandboxed Dive code. After you validate the event origin and source, use the message to offer a download to your user. Do not upload the file, attach it to another account, or trigger backend workflows based only on the message.
::::

Exports run the full SQL passed by the Dive, not the rows already rendered in React. Large exports can use significant browser memory because the generated file is transferred to the parent page as an `ArrayBuffer`. For larger data delivery workflows, consider creating a server-side export flow outside the embedded Dive.

## Session lifecycle

Embed sessions expire after 24 hours. You have two options for handling expiration:

- **Generate a fresh session per page load.** The simplest approach. Each time a user loads the page, your backend creates a new embed session and passes it to the iframe.
- **Cache and refresh.** Your backend caches the session and refreshes it before it expires. This reduces API calls but adds complexity.

If a session expires while a Dive is open, the embed displays a "Session expired" message. The user needs to reload the page to get a new session.

## Security best practices

- **Keep your access token server-side.** Never include your access token in client-side JavaScript, HTML, or any code that reaches the browser.
- **Use a dedicated service account.** Create a [service account](/key-tasks/service-accounts-guide/create-and-configure-service-accounts/) specifically for embedding, separate from your personal account. The account needs a read/write, Admin-level access token to create embed sessions, but the sessions it generates are always read-only.
- **Sessions are read-only.** The embed session always contains a read scaling token, so it can only read data, not modify it.
- **Session in URL fragment.** The fragment (`#session=...`) is never sent to the server in HTTP requests, keeping the session out of access logs and referrer headers.
- **Scope service accounts for data isolation.** If you need to restrict which data different users can see (for example, per-region databases), create separate service accounts with access scoped to the appropriate data. The embedded Dive queries data as the service account used to create the session.

## CSP configuration

If your site uses a restrictive [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP), add `embed-motherduck.com` to your `frame-src` directive:

```text
Content-Security-Policy: frame-src https://embed-motherduck.com;
```

Without this, the browser blocks the iframe from loading.

## Troubleshooting

Errors from the embed itself (expired token, Dive not found) appear as messages **inside the iframe**. CSP or network-related errors typically appear only in the **browser developer console**.

| Error message | Cause | Solution |
|---------------|-------|----------|
| "Dive embedding requires a Business plan." | Your organization is not on the Business plan | Upgrade to a [Business plan](https://motherduck.com/pricing/) |
| "Invalid or expired token. Please reload the page." | The session has expired or is malformed | Create a fresh embed session from your backend |
| "Dive not found." | The Dive ID is incorrect or the Dive has been deleted | Verify the Dive ID in **Settings** > **Dives** |
| "Failed to load dive. Please try again." | A generic error occurred while loading | Check your session string and network connectivity, then reload |
| "Can't open share: Share alias cannot be the same as an existing database name. _name_ is already taken and used as a database name." | Your service account already has a database with the same name as one of the Dive's shared databases | Rename or [detach](/key-tasks/database-operations/detach-and-reattach-motherduck-database/) the conflicting database on the service account. See [share alias conflicts](/sql-reference/motherduck-sql-reference/attach/#share-alias-conflicts) for details. |
| Links in the embedded Dive do not open | Embedded Dives cannot directly navigate the parent page or open popups from the sandbox | Listen for `navigation-request` messages in the parent page, validate the URL, and decide whether to open it |
| Export buttons do not download a file | The iframe cannot download files directly from the sandbox, or the embed is using server mode | Listen for `export-file` messages in the parent page and offer the file for download. Use dual mode for Dives that include export controls. |
| Iframe does not load (blank or blocked) | Your site's CSP blocks `embed-motherduck.com` | Add `frame-src https://embed-motherduck.com` to your CSP header (visible in browser dev console as a CSP violation) |
| User role "restricted" does not meet minimum role "admin" required for dashboards.createEmbedSession" | The user associated with the token is not an admin. Generating embed tokens requires the user or service account to have admin permissions. | In the service accounts panel under settings, change the role of the service account to 'Admin' |
| unauthorized_client: Callback URL mismatch. `<url>` is not in the list of allowed callback URLs | Embedded dives use MotherDuck's authorization system to determine permissions this limits what URLs can be used for authorization. | For local development ensure that you are running on `localhost` not something like `127.0.0.1` |

## Related resources

- [Creating visualizations with Dives](/key-tasks/ai-and-motherduck/dives/)
- [Dives SQL functions](/sql-reference/motherduck-sql-reference/ai-functions/dives/)
- [Managing Dives as code](/key-tasks/ai-and-motherduck/dives/managing-dives-as-code)


---

## Docs feedback

MotherDuck accepts optional user-submitted feedback about this page at `POST https://motherduck.com/docs/api/feedback/agent`.
For agents and automated tools, feedback submission should be user-confirmed before sending.

Payload:

```json
{
  "page_path": "/key-tasks/ai-and-motherduck/dives/embedding-dives/",
  "page_title": "Embedding Dives in your web application",
  "text": "<the user's feedback, max 2000 characters>",
  "source": "<optional identifier for your interface, for example 'claude.ai' or 'chatgpt'>"
}
```

`page_path` and `text` are required; `page_title` and `source` are optional. Responses: `200 {"feedback_id": "<uuid>"}`, `400` for malformed payloads, and `429` when rate-limited.
