Embedding Dives in your web application
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
- A Dive you want to embed, with its data shared to the target service account that the embedded Dive will run as
- A backend server that can make authenticated API calls
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:
- 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.
- Your frontend renders a sandboxed iframe that loads the Dive from
embed-motherduck.com, passing the session string. - MotherDuck loads the Dive and runs live SQL queries.
Your end-users see an interactive dashboard without needing a MotherDuck account.
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.
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.
Never expose your access token in client-side code. The access token stays on your backend. Only the session string reaches the browser.
- Node.js
- Python
const DIVE_ID = "<your_dive_id>";
const VERSION = 12;
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,
// Optional: render a specific Dive version.
version: VERSION,
}),
}
);
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
import httpx
DIVE_ID = "<your_dive_id>"
VERSION = 12
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,
# Optional: render a specific Dive version.
"version": VERSION,
},
)
response.raise_for_status()
session = response.json()["session"]
# Return this session string to your frontend
Replace <your_dive_id> with the ID of your Dive. You can find this in Settings > Dives or through the list_dives MCP tool.
To render a specific version of a Dive, for example for an embedded Dive in a production environment, pass version when you create the embed session. MotherDuck validates that the requested version exists for the Dive before returning the session. If you omit version, the embedded Dive renders the latest saved version when it loads.
The version value maps to the Dive version number, not the version UUID. Use the current_version value from MD_LIST_DIVES, the version value from MD_LIST_DIVE_VERSIONS, or the version argument to MD_GET_DIVE_VERSION.
Your application owns which version to embed. Store the approved version alongside your own release or customer configuration, then pass it when generating sessions for that embed.
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.
Customize the embed session (optional)
POST /v1/dives/<dive_id>/embed-session accepts optional fields that let you tailor each session.
Override required databases
By default, an embedded Dive uses the REQUIRED_DATABASES declared in the Dive's source code. To point the same Dive at different databases on a per-session basis — for example, to render the same dashboard for each of your tenants against their own database — pass a required_resources array when creating the embed session:
- Node.js
- Python
body: JSON.stringify({
username: SERVICE_ACCOUNT_USERNAME,
required_resources: [
{
url: "md:_share/tenant_a_data/<share_uuid>",
alias: "tenant_data",
},
],
}),
json={
"username": SERVICE_ACCOUNT_USERNAME,
"required_resources": [
{
"url": "md:_share/tenant_a_data/<share_uuid>",
"alias": "tenant_data",
},
],
},
Each entry describes one database:
| Field | Required | Description |
|---|---|---|
url | Yes | Share URL (md:_share/<database>/<share_uuid>) or owned database identifier (md:<database_name>). |
alias | No | Local alias used in the Dive's SQL. Defaults to the database name from the URL. |
When you set required_resources, it replaces the Dive's source-declared REQUIRED_DATABASES for that session. Omit the field to use the source-declared list.
Preconfigure the starting UI state
Dives can use the useDiveState hook from @motherduck/react-sql-query to store interactive state such as filters, sort order, selected views, and drill-downs. To seed that state for a given session — for example, to render the same Dive against each customer's selected date range — pass an initial_state object when creating the embed session:
- Node.js
- Python
body: JSON.stringify({
username: SERVICE_ACCOUNT_USERNAME,
initial_state: {
region: "emea",
dateRange: { start: "2026-01-01", end: "2026-03-31" },
},
}),
json={
"username": SERVICE_ACCOUNT_USERNAME,
"initial_state": {
"region": "emea",
"dateRange": {"start": "2026-01-01", "end": "2026-03-31"},
},
},
Each key in initial_state matches a key used in useDiveState(key, ...) inside the Dive's code. Values must be JSON-serializable. Keys absent from initial_state fall back to the initialValue declared in the Dive's source.
Viewer interactions update the Dive's UI state, but those changes are not persisted server-side. To capture viewer changes from your host page, listen for dive-state-update messages.
required_resources is capped at 8 KB on the encoded session. initial_state is capped at 64 KB; bags larger than 8 KB are stored server-side rather than inlined on the session.
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:
<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.
URL structure
| Part | Description |
|---|---|
embed-motherduck.com/sandbox/ | The MotherDuck embed host |
#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:
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:
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");
});
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:
<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:
https://embed-motherduck.com/settings/members
Use absolute URLs instead:
<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.
The parent page receives these message types:
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:
<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>
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";
});
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.
Handle Dive state updates from embedded Dives
When a viewer interacts with a Dive built using the useDiveState hook, the embed sends a dive-state-update message to the parent page each time the state changes. MotherDuck does not persist these changes server-side — the parent page decides whether to capture the snapshot. A common use is to save it to your backend so the viewer's selections survive across sessions; you can then seed the next session with the saved bag.
The message has the following shape:
type DiveStateUpdate = {
type: "dive-state-update";
state: Record<string, unknown>;
};
state is the full snapshot of every key the Dive holds, not a delta. Dropped or out-of-order messages are safe to ignore — the next snapshot supersedes them. MotherDuck debounces updates (~100 ms) to limit chatter during rapid interactions.
The following example saves each snapshot to localStorage keyed by the Dive ID. Replace the storage with whatever persistence layer fits your application:
const iframe = document.querySelector("#motherduck-dive");
const motherduckEmbedOrigin = new URL(iframe.src).origin;
const STORAGE_KEY = `dive-state:${DIVE_ID}`;
window.addEventListener("message", (event) => {
if (event.origin !== motherduckEmbedOrigin) return;
if (event.source !== iframe.contentWindow) return;
if (event.data?.type !== "dive-state-update") return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(event.data.state));
});
To replay the saved snapshot on the viewer's next visit, pass it as initial_state when creating the next embed session.
Treat dive-state-update payloads as untrusted content from sandboxed Dive code. After validating the event origin and source, only use the bag for storage or to seed the next session — do not interpret it as a command, attach it to other accounts, or feed it into backend workflows that grant permissions.
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 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, add embed-motherduck.com to your frame-src directive:
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 |
| "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 the conflicting database on the service account. See 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 | Listen for export-file messages in the parent page and offer the file for download. |
| 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 |