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
Section titled “The iframe tag”<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
Section titled “The iframe URL”iframe_url comes from POST /start-iframe-session.
- Set it as
srcat 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_urlopens one iframe. For a reopen, start a session again.
The host snippet
Section titled “The host snippet”This is the code Flume's own demo page runs. Copy it as is.
// 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;}// 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:
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:
- Starts the ready timer: 15 seconds. If the iframe is not ready by then, it calls
showError. - Adds the
messagelistener before it setssrc. - Sets
srctoiframeUrl. - Drops any message that fails one of four checks:
event.originis the iframe's origin,event.sourceis this iframe's window,versionis1,session_uuidis this session's.
- Handles the four message types.
- Replies to a save with the
save_uuidthe iframe sent, to the exact iframe origin, never"*". - Returns
stop: a function that removes the listener and the timer.
The handlers you write
Section titled “The handlers you write”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.
Read this diagram as text
- Starting: your page set the iframe src and runs a ready timer of 15 seconds. Before that, nothing is mounted.
- Starting to Ready: flume:iframe_ready arrives. Clear the timer.
- 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.
- 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.
- 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.
- Ready to Closed: flume:close_iframe with reason done or closed. Remove the iframe.
- Ready to Expired: flume:close_iframe with reason expired. Start a new session and mount again.
- 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.
- 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
Section titled “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
Section titled “Example: call the snippet”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.
Sizing
Section titled “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.
#flume { position: fixed; inset: 0 0 0 auto; width: min(470px, 100vw); height: 100dvh; border: 0;}Develop on localhost
Section titled “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:3000andhttp://127.0.0.1:3000are 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
Section titled “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_iframewith reasonclosed. - Return focus to your button after you remove the iframe.