Skip to content

Set up your page

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

<iframe
id="flume"
title="Flume: find alternatives"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
></iframe>
AttributeRule
srcLeave it out. The host snippet sets it
titleRequired for screen readers
sandboxExactly the five tokens above
allowDo not set it
loadingDo not set lazy

What each sandbox token is for:

TokenWhy
allow-scriptsThe iframe is an application
allow-same-originIt calls the Flume API from its own origin
allow-formsIt 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.

iframe_url comes from POST /start-iframe-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.

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

mount-flume.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;
}

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:

  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 iframe on your page, state by stateWhat your page is doing at each moment, and the message or timer that moves it on. The same content is listed as text under the diagram.Failed to loadStartingExpiredExchangingReadyClosedSavingready timer,15 suser retriesflume:iframe_readyflume:user_connectedtoken storedflume:save_itemreply, or 60 sflume:close_iframedone or closedflume:close_iframeexpirednew sessionpostMessageA person acts

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
  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.
HandlerCalled whenWhat to do
hideSpinner()The iframe is readyRemove your loading state
showError()The ready timer ran outCall stop(), remove the iframe, then show an error whose retry starts a new session
exchangeConnectCode(connectCode)The user verified their emailSend the code to your backend. Exchange it within 120 seconds
saveItem(payload)The user savedWrite the record and return a SavedReply. Throw if the write fails
close(reason)The iframe wants to closeRemove the iframe. Act on the reason

How saveItem failures are reported:

Your saveItemThe 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 elseflume:item_save_failed with reason error, and the error's message

Close reasons:

reasonMeaningDo this
doneThe user finished after a saveRemove the iframe. Refresh your own data
expiredThe session ended, or the user retried after a failed startStart a session again and remount
closedThe user pressed X or EscapeRemove the iframe

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.

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.

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;
}
  • 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.
  • 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.