This is a complete iframe integration in three files: a page, the host snippet, and a server. It has no dependencies. Run it to see the iframe open on your machine.
Before you begin
Section titled “Before you begin”- A sandbox partner key. Get a sandbox key.
http://localhost:3000as one of your allowed origins. Ask Flume for it with your key.- Node.js 20 or later, or Python 3.9 or later.
1. Get the three files
Section titled “1. Get the three files”Make a folder. Save the host snippet into it:
mkdir flume-example && cd flume-examplecurl -sSO "https://docs.tryflume.ai/samples/mount-flume.js"Save this page as index.html:
<!doctype html><html lang="en"> <head> <meta charset="utf-8" /> <title>Flume iframe example</title> <style> body { font-family: system-ui, sans-serif; padding: 2rem; } #flume { position: fixed; inset: 0 0 0 auto; width: min(470px, 100vw); height: 100dvh; border: 0; display: none; } #flume.open { display: block; } #status { color: gray; } </style> </head> <body> <h1>Item T-1: Calacatta look porcelain 24 x 48</h1> <button id="open">Find alternatives</button> <p id="status" role="status"></p> <ul id="saved"></ul>
<iframe id="flume" title="Flume: find alternatives" sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox" ></iframe>
<script type="module"> import { mountFlume } from "./mount-flume.js";
const frame = document.querySelector("#flume"); const button = document.querySelector("#open"); const status = document.querySelector("#status"); let stop = null;
const post = async (path, body) => { const response = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); // Show what the server said: it names the Flume error code. if (!response.ok) throw new Error(`${path} ${response.status} ${(await response.text()).slice(0, 200)}`); return response.json(); };
// Remove the iframe and the snippet's listener and timer. function close(message = "") { stop?.(); stop = null; frame.classList.remove("open"); frame.removeAttribute("src"); status.textContent = message; button.focus(); }
async function open() { close("Opening Flume…"); let session; try { session = await post("/flume/session", { itemId: "T-1" }); } catch { return close("Could not start a session. Try again."); } frame.classList.add("open"); stop = mountFlume({ frame, iframeUrl: session.iframe_url, sessionUuid: session.session_uuid, flumeOrigin: new URL(session.iframe_url).origin, handlers: { hideSpinner: () => (status.textContent = ""), showError: () => close("Flume did not load. Try again."), exchangeConnectCode: (connectCode) => // A failed exchange leaves no token: say so. The user verifies again on the next open. post("/flume/connect", { connectCode }).catch((error) => { console.error(error); status.textContent = "Could not finish connecting. You will be asked to verify again next time."; }), saveItem: async (payload) => { // A thrown error becomes a failure reply: the snippet adds save_uuid and a reason. const row = await post("/flume/save", payload); const line = document.createElement("li"); line.textContent = `${row.id}: ${payload.product.name}`; document.querySelector("#saved").append(line); return { save_uuid: payload.save_uuid, item_ref: row.id }; }, close: (reason) => { close(reason === "done" ? "Saved." : ""); if (reason === "expired") open(); }, }, }); }
button.addEventListener("click", open); </script> </body></html>Save one of these servers.
// server.mjs: Node.js 20 or later, no dependencies.// FLUME_PARTNER_KEY=... node server.mjs then open http://localhost:3000import { readFile } from "node:fs/promises";import { createServer } from "node:http";
const API = "https://sandbox-api.tryflume.ai/partner/v1";const KEY = process.env.FLUME_PARTNER_KEY;const HOST_ORIGIN = process.env.HOST_ORIGIN ?? "http://localhost:3000"; // must be agreed with Flumeconst USER = { external_ref: "example-user-1", email: process.env.EMAIL ?? "you@yourcompany.com" };
const tokens = new Map(); // external_ref -> user access token. Use your database, encrypted.const saves = new Map(); // save_uuid -> your record
async function flume(path, body, userToken) { const response = await fetch(API + path, { method: "POST", headers: { "X-Flume-Partner-Key": KEY, "Content-Type": "application/json", ...(userToken ? { "X-Flume-User-Token": userToken } : {}), }, body: JSON.stringify(body), }); // An error can come back without JSON (a proxy, a network-level limit). Read text first. const text = await response.text(); let answer = {}; try { answer = JSON.parse(text); } catch { answer = { error: { code: "NOT_JSON", message: text.slice(0, 200) } }; } return { status: response.status, body: answer };}
async function startSession() { const body = { user: USER, project: { external_ref: "example-project-1", name: "Example project" }, item: { external_id: "T-1", label: "T-1", name: "Calacatta look porcelain 24 x 48", item_type: "Tile" }, host_origin: HOST_ORIGIN, }; let answer = await flume("/start-iframe-session", body, tokens.get(USER.external_ref)); if (answer.status === 401 && answer.body.error?.code === "USER_TOKEN_INVALID") { tokens.delete(USER.external_ref); // the stored token stopped working: open without it answer = await flume("/start-iframe-session", body); } return answer;}
async function connect({ connectCode }) { const answer = await flume("/users/exchange-connect-code", { connect_code: connectCode }); // Store the token only for the user who is signed in to YOUR product. if (answer.status === 200 && answer.body.external_ref === USER.external_ref) { tokens.set(USER.external_ref, answer.body.access_token); // replaces any earlier token } return { status: answer.status === 200 ? 200 : 502, body: {} };}
function save(payload) { // save_uuid is the idempotency key: a retried save returns the first record. if (!saves.has(payload.save_uuid)) { saves.set(payload.save_uuid, { id: `row_${saves.size + 1}`, name: payload.product.name, url: payload.product_url }); } return { status: 200, body: saves.get(payload.save_uuid) };}
const routes = { "/flume/session": startSession, "/flume/connect": connect, "/flume/save": save };const files = { "/": ["index.html", "text/html"], "/mount-flume.js": ["mount-flume.js", "text/javascript"] };
createServer(async (request, response) => { try { if (request.method === "GET" && files[request.url]) { const [name, type] = files[request.url]; return response.writeHead(200, { "Content-Type": type }).end(await readFile(name)); } if (request.method === "POST" && routes[request.url]) { let raw = ""; for await (const chunk of request) raw += chunk; const { status, body } = await routes[request.url](raw ? JSON.parse(raw) : {}); return response.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify(body)); } response.writeHead(404).end(); } catch (error) { response.writeHead(500, { "Content-Type": "application/json" }).end(JSON.stringify({ error: String(error) })); }}).listen(3000, () => console.log("http://localhost:3000"));# server.py: Python 3.9 or later, no dependencies.# FLUME_PARTNER_KEY=... python server.py then open http://localhost:3000import jsonimport osimport urllib.errorimport urllib.requestfrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
API = "https://sandbox-api.tryflume.ai/partner/v1"KEY = os.environ["FLUME_PARTNER_KEY"]HOST_ORIGIN = os.environ.get("HOST_ORIGIN", "http://localhost:3000") # must be agreed with FlumeUSER = {"external_ref": "example-user-1", "email": os.environ.get("EMAIL", "you@yourcompany.com")}
tokens = {} # external_ref -> user access token. Use your database, encrypted.saves = {} # save_uuid -> your record
def flume(path, body, user_token=None): headers = {"X-Flume-Partner-Key": KEY, "Content-Type": "application/json"} if user_token: headers["X-Flume-User-Token"] = user_token request = urllib.request.Request(API + path, json.dumps(body).encode(), headers, method="POST") try: with urllib.request.urlopen(request, timeout=30) as response: return response.status, json.load(response) except urllib.error.HTTPError as error: # An error can come back without JSON (a proxy, a network-level limit). text = error.read().decode("utf-8", "replace") try: return error.code, json.loads(text) except ValueError: return error.code, {"error": {"code": "NOT_JSON", "message": text[:200]}}
def start_session(_body): body = { "user": USER, "project": {"external_ref": "example-project-1", "name": "Example project"}, "item": { "external_id": "T-1", "label": "T-1", "name": "Calacatta look porcelain 24 x 48", "item_type": "Tile", }, "host_origin": HOST_ORIGIN, } status, answer = flume("/start-iframe-session", body, tokens.get(USER["external_ref"])) if status == 401 and answer.get("error", {}).get("code") == "USER_TOKEN_INVALID": tokens.pop(USER["external_ref"], None) # the stored token stopped working: open without it status, answer = flume("/start-iframe-session", body) return status, answer
def connect(body): status, answer = flume("/users/exchange-connect-code", {"connect_code": body["connectCode"]}) # Store the token only for the user who is signed in to YOUR product. if status == 200 and answer["external_ref"] == USER["external_ref"]: tokens[USER["external_ref"]] = answer["access_token"] # replaces any earlier token return (200 if status == 200 else 502), {}
def save(payload): # save_uuid is the idempotency key: a retried save returns the first record. record = saves.setdefault( payload["save_uuid"], { "id": f"row_{len(saves) + 1}", "name": payload["product"]["name"], "url": payload["product_url"], }, ) return 200, record
ROUTES = {"/flume/session": start_session, "/flume/connect": connect, "/flume/save": save}FILES = { "/": ("index.html", "text/html"), "/mount-flume.js": ("mount-flume.js", "text/javascript"),}
class Handler(BaseHTTPRequestHandler): def reply(self, status, content_type, data): self.send_response(status) self.send_header("Content-Type", content_type) self.end_headers() self.wfile.write(data)
def do_GET(self): if self.path not in FILES: return self.reply(404, "text/plain", b"") name, content_type = FILES[self.path] with open(name, "rb") as file: self.reply(200, content_type, file.read())
def do_POST(self): if self.path not in ROUTES: return self.reply(404, "text/plain", b"") raw = self.rfile.read(int(self.headers.get("Content-Length", 0))) status, body = ROUTES[self.path](json.loads(raw or b"{}")) self.reply(status, "application/json", json.dumps(body).encode())
print("http://localhost:3000")ThreadingHTTPServer(("localhost", 3000), Handler).serve_forever()2. Run it
Section titled “2. Run it”export FLUME_PARTNER_KEY="paste-your-sandbox-key"export EMAIL="you@yourcompany.com"node server.mjsexport FLUME_PARTNER_KEY="paste-your-sandbox-key"export EMAIL="you@yourcompany.com"python server.pyOpen http://localhost:3000 and press Find alternatives.
3. What you should see
Section titled “3. What you should see”- The iframe opens in a drawer on the right.
- The first time, it asks you to verify
EMAILwith a code. The sandbox sends a real email. - You search by photo, pick a product, and save it.
- The page lists the saved item, and the drawer closes.
- Press the button again. The iframe opens without the email step, because the server kept your token.
What each part shows
Section titled “What each part shows”| Part | Shows |
|---|---|
startSession | The body of POST /start-iframe-session, and the retry without a token after 401 USER_TOKEN_INVALID |
connect | The exchange, the check on external_ref, and storing the new token over the old one |
save | save_uuid as the idempotency key |
open in the page | flumeOrigin read from iframe_url, and the five handlers |
close in the page | stop(), removing src, and reopening on expired |
Before you copy it into your product
Section titled “Before you copy it into your product”- The example keeps tokens and saves in memory. Use your database, and encrypt tokens.
- It has one hard-coded user. Use the user who is signed in to your product.
- It trusts the payload the browser sends to
/flume/save. See what to trust. - It copies no image bytes. See Save payload.
If it does not work
Section titled “If it does not work”| You see | Cause |
|---|---|
| "Could not start a session" | The key is wrong, or http://localhost:3000 is not one of your allowed origins. The server's response says which |
| A blank drawer, then "Flume did not load" | Your origin is not allowed to frame the iframe. Check the browser console |
| "Flume didn't open." inside the drawer | The iframe_url was used late or twice. Press the button again |
More causes: Timeouts and expiry.