---
title: "Set up your page"
description: "What your page needs to frame the Flume iframe. The iframe tag and its sandbox attribute, the host snippet in TypeScript and JavaScript, and sizing."
url: https://docs.tryflume.ai/embed/host-page
updated: 2026-11-11
---

# Set up your page

> What your page needs to frame the Flume iframe. The iframe tag and its sandbox attribute, the host snippet in TypeScript and JavaScript, and sizing.

Your page needs three things to frame the Flume iframe: an iframe tag with the right `sandbox` attribute, the host snippet, and a size.

## The iframe tag

```html
<iframe
  id="flume"
  title="Flume: find alternatives"
  sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
></iframe>
```

| Attribute | Rule |
|---|---|
| `src` | Leave it out. The host snippet sets it |
| `title` | Required for screen readers |
| `sandbox` | Exactly the five tokens above |
| `allow` | Do not set it |
| `loading` | Do not set `lazy` |

What each `sandbox` token is for:

| Token | Why |
|---|---|
| `allow-scripts` | The iframe is an application |
| `allow-same-origin` | It calls the Flume API from its own origin |
| `allow-forms` | It has form fields |
| `allow-popups`, `allow-popups-to-escape-sandbox` | "Open in Flume" opens the Flume app in a new tab |

Do not add `allow-top-navigation`.

## The iframe URL

`iframe_url` comes from [`POST /start-iframe-session`](https://docs.tryflume.ai/embed/server-calls.md#start-a-session).

- Set it as `src` at once. Its launch code works once, within 120 seconds.
- Do not store, log, rebuild or edit it.
- It works only inside a frame. Opened in a tab of its own, it shows a notice and does not start.
- Read the iframe's origin from it: `new URL(iframe_url).origin`.
- One `iframe_url` opens one iframe. For a reopen, start a session again.

## The host snippet

This is the code Flume's own demo page runs. Copy it as is.

**JavaScript**
```js
// How long the iframe has to say it is ready before your page shows an error.
export const READY_WATCHDOG_MS = 15_000;

// The iframe element's sandbox attribute.
export const FRAME_SANDBOX = "allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox";

// The reasons a failed save may state.
export const SAVE_FAILURE_REASONS = ["permission", "conflict", "storage", "error"];

// Call once per open. Returns a function that removes the listener and the timer.
export function mountFlume(options) {
    const { frame, iframeUrl, sessionUuid, flumeOrigin, handlers } = options;
    const FLUME_ORIGIN = flumeOrigin;
    const readyWatchdog = setTimeout(() => {
        cleanup();
        handlers.showError();
    }, READY_WATCHDOG_MS);
    window.addEventListener("message", onFlumeMessage);
    frame.src = iframeUrl;
    function onFlumeMessage(e) {
        if (e.origin !== FLUME_ORIGIN || e.source !== frame.contentWindow)
            return;
        const m = e.data;
        if (!m || m.version !== 1 || typeof m.type !== "string" || m.session_uuid !== sessionUuid)
            return;
        switch (m.type) {
            case "flume:iframe_ready":
                clearTimeout(readyWatchdog);
                handlers.hideSpinner();
                frame.focus();
                break;
            case "flume:user_connected":
                handlers.exchangeConnectCode(m.payload.connect_code);
                break;
            case "flume:save_item": {
                // Every reply names the save it answers. A handler that throws anything else (an Error
                // from a failed fetch, a synchronous throw) still becomes a proper failure reply.
                const saveUuid = m.payload.save_uuid;
                Promise.resolve()
                    .then(() => handlers.saveItem(m.payload))
                    .then((ok) => reply("flume:item_saved", { ...ok, save_uuid: saveUuid }), (err) => reply("flume:item_save_failed", saveFailure(saveUuid, err)));
                break;
            }
            case "flume:close_iframe":
                cleanup();
                handlers.close(m.payload.reason);
                break;
        }
    }
    function saveFailure(saveUuid, err) {
        const stated = err && typeof err === "object" && "reason" in err ? err : null;
        const reason = stated && SAVE_FAILURE_REASONS.includes(stated.reason) ? stated.reason : "error";
        const text = stated ? stated.message : err instanceof Error ? err.message : undefined;
        return { save_uuid: saveUuid, reason, ...(typeof text === "string" ? { message: text.slice(0, 500) } : {}) };
    }
    function reply(type, payload) {
        frame.contentWindow?.postMessage({ version: 1, type, session_uuid: sessionUuid, payload }, FLUME_ORIGIN);
    }
    function cleanup() {
        clearTimeout(readyWatchdog);
        window.removeEventListener("message", onFlumeMessage);
    }
    return cleanup;
}
```
**TypeScript**
```ts
// Messages the iframe sends, and the two replies your page sends back.
export type CloseReason = "done" | "expired" | "closed";

export type SavedReply = { save_uuid: string; item_ref?: string; item_label?: string };
export type SaveFailedReply = {
  save_uuid: string;
  reason: "permission" | "conflict" | "storage" | "error";
  message?: string;
};

export type FlumeMessage =
  | { version: 1; type: "flume:iframe_ready"; session_uuid: string; payload?: unknown }
  | { version: 1; type: "flume:user_connected"; session_uuid: string; payload: { connect_code: string } }
  | { version: 1; type: "flume:save_item"; session_uuid: string; payload: { save_uuid: string } & Record<string, unknown> }
  | { version: 1; type: "flume:close_iframe"; session_uuid: string; payload: { reason: CloseReason } };

// How long the iframe has to say it is ready before your page shows an error.
export const READY_WATCHDOG_MS = 15_000;

// The iframe element's sandbox attribute.
export const FRAME_SANDBOX = "allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox";

// The reasons a failed save may state.
export const SAVE_FAILURE_REASONS: readonly SaveFailedReply["reason"][] = ["permission", "conflict", "storage", "error"];

// What your page supplies.
export type HostHandlers = {
  hideSpinner(): void;
  showError(): void;
  exchangeConnectCode(connectCode: string): void;
  saveItem(payload: { save_uuid: string } & Record<string, unknown>): Promise<SavedReply>;
  close(reason: CloseReason): void;
};

// Call once per open. Returns a function that removes the listener and the timer.
export function mountFlume(options: {
  frame: HTMLIFrameElement;
  iframeUrl: string;
  sessionUuid: string;
  flumeOrigin: string;
  handlers: HostHandlers;
}): () => void {
  const { frame, iframeUrl, sessionUuid, flumeOrigin, handlers } = options;
  const FLUME_ORIGIN = flumeOrigin;

  const readyWatchdog = setTimeout(() => {
    cleanup();
    handlers.showError();
  }, READY_WATCHDOG_MS);
  window.addEventListener("message", onFlumeMessage);
  frame.src = iframeUrl;

  function onFlumeMessage(e: MessageEvent): void {
    if (e.origin !== FLUME_ORIGIN || e.source !== frame.contentWindow) return;
    const m = e.data as FlumeMessage | null;
    if (!m || m.version !== 1 || typeof m.type !== "string" || m.session_uuid !== sessionUuid) return;
    switch (m.type) {
      case "flume:iframe_ready":
        clearTimeout(readyWatchdog);
        handlers.hideSpinner();
        frame.focus();
        break;
      case "flume:user_connected":
        handlers.exchangeConnectCode(m.payload.connect_code);
        break;
      case "flume:save_item": {
        // Every reply names the save it answers. A handler that throws anything else (an Error
        // from a failed fetch, a synchronous throw) still becomes a proper failure reply.
        const saveUuid = m.payload.save_uuid;
        Promise.resolve()
          .then(() => handlers.saveItem(m.payload))
          .then(
            (ok) => reply("flume:item_saved", { ...ok, save_uuid: saveUuid }),
            (err: unknown) => reply("flume:item_save_failed", saveFailure(saveUuid, err)),
          );
        break;
      }
      case "flume:close_iframe":
        cleanup();
        handlers.close(m.payload.reason);
        break;
    }
  }

  function saveFailure(saveUuid: string, err: unknown): SaveFailedReply {
    const stated = err && typeof err === "object" && "reason" in err ? (err as Partial<SaveFailedReply>) : null;
    const reason = stated && SAVE_FAILURE_REASONS.includes(stated.reason as SaveFailedReply["reason"]) ? stated.reason! : "error";
    const text = stated ? stated.message : err instanceof Error ? err.message : undefined;
    return { save_uuid: saveUuid, reason, ...(typeof text === "string" ? { message: text.slice(0, 500) } : {}) };
  }

  function reply(type: "flume:item_saved" | "flume:item_save_failed", payload: SavedReply | SaveFailedReply): void {
    frame.contentWindow?.postMessage({ version: 1, type, session_uuid: sessionUuid, payload }, FLUME_ORIGIN);
  }

  function cleanup(): void {
    clearTimeout(readyWatchdog);
    window.removeEventListener("message", onFlumeMessage);
  }

  return cleanup;
}
```

Download either file:

```sh
curl -sSO "https://docs.tryflume.ai/samples/mount-flume.js"
curl -sSO "https://docs.tryflume.ai/samples/mount-flume.ts"
```

What it does, in order:

1. Starts the ready timer: 15 seconds. If the iframe is not ready by then, it calls `showError`.
2. Adds the `message` listener **before** it sets `src`.
3. Sets `src` to `iframeUrl`.
4. Drops any message that fails one of four checks:
   - `event.origin` is the iframe's origin,
   - `event.source` is this iframe's window,
   - `version` is `1`,
   - `session_uuid` is this session's.
5. Handles the four message types.
6. Replies to a save with the `save_uuid` the iframe sent, to the exact iframe origin, never `"*"`.
7. Returns `stop`: a function that removes the listener and the timer.

## The handlers you write

**Diagram: The iframe on your page, state by state.** What your page is doing at each moment, and the message or timer that moves it on.

```mermaid
flowchart LR
  failed["Failed to load"]
  starting["Starting"]
  expired["Expired"]
  exchanging["Exchanging"]
  ready["Ready"]
  closed["Closed"]
  saving["Saving"]
  starting -->|"ready timer, 15 s"| failed
  failed -->|"user retries"| starting
  starting -.->|"flume:iframe_ready"| ready
  ready -.->|"flume:user_connected"| exchanging
  exchanging -->|"token stored"| ready
  ready -.->|"flume:save_item"| saving
  saving -.->|"reply, or 60 s"| ready
  ready -.->|"flume:close_iframe done or closed"| closed
  ready -.->|"flume:close_iframe expired"| expired
  expired -->|"new session"| starting
```

1. Starting: your page set the iframe src and runs a ready timer of 15 seconds. Before that, nothing is mounted.
2. Starting to Ready: flume:iframe_ready arrives. Clear the timer.
3. Starting to Failed to load: the timer fires first. Remove the iframe and show an error with a retry. A retry starts a new session.
4. Ready to Exchanging and back: flume:user_connected arrives, your backend exchanges the connect code and stores the token. The iframe does not wait for it.
5. Ready to Saving and back: flume:save_item arrives, and your page replies. After 60 seconds without a reply the iframe treats the save as failed.
6. Ready to Closed: flume:close_iframe with reason done or closed. Remove the iframe.
7. Ready to Expired: flume:close_iframe with reason expired. Start a new session and mount again.
8. flume:close_iframe can arrive at any moment after Starting, also while a save or an exchange is running. Remove the iframe all the same.
9. An iframe that could not start still posts flume:iframe_ready, shows its own error, and posts flume:close_iframe with reason expired when the user asks to try again.

| Handler | Called when | What to do |
|---|---|---|
| `hideSpinner()` | The iframe is ready | Remove your loading state |
| `showError()` | The ready timer ran out | Call `stop()`, remove the iframe, then show an error whose retry starts a new session |
| `exchangeConnectCode(connectCode)` | The user verified their email | Send the code to your backend. Exchange it within 120 seconds |
| `saveItem(payload)` | The user saved | Write the record and return a `SavedReply`. Throw if the write fails |
| `close(reason)` | The iframe wants to close | Remove the iframe. Act on the reason |

How `saveItem` failures are reported:

| Your `saveItem` | The snippet replies |
|---|---|
| Returns `{ item_ref, item_label }` | `flume:item_saved`, with the save's `save_uuid` |
| Throws `{ reason: "storage" }` | `flume:item_save_failed` with that reason |
| Throws an `Error`, or anything else | `flume:item_save_failed` with reason `error`, and the error's message |

Close reasons:

| `reason` | Meaning | Do this |
|---|---|---|
| `done` | The user finished after a save | Remove the iframe. Refresh your own data |
| `expired` | The session ended, or the user retried after a failed start | Start a session again and remount |
| `closed` | The user pressed X or Escape | Remove the iframe |

## When to call stop

`mountFlume` returns `stop`. Call it every time your page removes the iframe itself:

- your own close button or drawer,
- a route change in a single-page app,
- inside `showError`.

You do not need it after `flume:close_iframe`: the snippet has already removed its listener.

## Example: call the snippet

```js
import { mountFlume } from "./mount-flume.js";

const frame = document.querySelector("#flume");
let stop = null;

function removeFrame() {
  stop?.();
  stop = null;
  frame.removeAttribute("src");
  openButton.focus();
}

async function open(itemId) {
  const { iframe_url, session_uuid } = await startSessionOnYourBackend(itemId);
  stop = mountFlume({
    frame,
    iframeUrl: iframe_url,
    sessionUuid: session_uuid,
    flumeOrigin: new URL(iframe_url).origin,
    handlers: {
      hideSpinner: () => spinner.remove(),
      showError: () => {
        removeFrame();
        showRetry("Flume did not load. Try again.");
      },
      exchangeConnectCode: (connectCode) => postToYourBackend("/flume/connect", { connectCode }),
      saveItem: async (payload) => {
        const row = await postToYourBackend("/flume/save", payload);
        return { save_uuid: payload.save_uuid, item_ref: row.id, item_label: row.label };
      },
      close: (reason) => {
        removeFrame();
        if (reason === "expired") open(itemId);
        if (reason === "done") refreshYourData();
      },
    },
  });
}
```

The names `openButton`, `spinner`, `startSessionOnYourBackend`, `postToYourBackend`, `showRetry` and `refreshYourData` are yours. A complete page is in the [full example](https://docs.tryflume.ai/embed/example.md).

## Sizing

The iframe fills the element you give it. Nothing is negotiated, and there is no resize message.

- Without CSS, an iframe is 300 by 150 pixels. Always size it.
- Width: it lays out from 320 px up.
- Past 760 px wide, its content keeps to a centered column.
- Height: it fills the element's height and scrolls inside itself.
- A right-hand drawer about 470 px wide, at full viewport height, is the layout Flume's demo uses.
- On a phone, give it the whole screen.

```css
#flume {
  position: fixed;
  inset: 0 0 0 auto;
  width: min(470px, 100vw);
  height: 100dvh;
  border: 0;
}
```

## Develop on localhost

- A local origin can be an allowed origin in the sandbox. Ask Flume to add it, for example `http://localhost:3000`.
- The match is exact: scheme, host and port. `http://localhost:3000` and `http://127.0.0.1:3000` are different origins.
- Send the same value as `host_origin`.
- Your backend still calls the sandbox API from your machine. No tunnel is needed.

## Focus and keyboard

- The snippet moves focus into the iframe when it is ready.
- Escape inside the iframe closes it. Your page gets `flume:close_iframe` with reason `closed`.
- Return focus to your button after you remove the iframe.

## See also

- [Full example](https://docs.tryflume.ai/embed/example.md)
- [Iframe messages](https://docs.tryflume.ai/embed/messages.md)
- [Security and CSP](https://docs.tryflume.ai/embed/security.md)
- [Timeouts and expiry](https://docs.tryflume.ai/embed/timeouts.md)
