---
title: "Full example"
description: "A complete, runnable Flume iframe integration in three small files. One HTML page, the host snippet and a server in Node.js or Python, with no dependencies."
url: https://docs.tryflume.ai/embed/example
updated: 2026-11-11
---

# Full example

> A complete, runnable Flume iframe integration in three small files. One HTML page, the host snippet and a server in Node.js or Python, with no dependencies.

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

- A sandbox partner key. [Get a sandbox key](https://docs.tryflume.ai/get-a-key.md).
- `http://localhost:3000` as 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

Make a folder. Save the host snippet into it:

```sh
mkdir flume-example && cd flume-example
curl -sSO "https://docs.tryflume.ai/samples/mount-flume.js"
```

Save this page as `index.html`:

```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.

**Node.js**
```js
// server.mjs: Node.js 20 or later, no dependencies.
//   FLUME_PARTNER_KEY=... node server.mjs      then open http://localhost:3000
import { 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 Flume
const 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"));
```
**Python**
```python
# server.py: Python 3.9 or later, no dependencies.
#   FLUME_PARTNER_KEY=... python server.py      then open http://localhost:3000
import json
import os
import urllib.error
import urllib.request
from 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 Flume
USER = {"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

**Node.js**
```sh
export FLUME_PARTNER_KEY="paste-your-sandbox-key"
export EMAIL="you@yourcompany.com"
node server.mjs
```
**Python**
```sh
export FLUME_PARTNER_KEY="paste-your-sandbox-key"
export EMAIL="you@yourcompany.com"
python server.py
```

Open `http://localhost:3000` and press **Find alternatives**.

## 3. What you should see

1. The iframe opens in a drawer on the right.
2. The first time, it asks you to verify `EMAIL` with a code. The sandbox sends a real email.
3. You search by photo, pick a product, and save it.
4. The page lists the saved item, and the drawer closes.
5. Press the button again. The iframe opens without the email step, because the server kept your token.

## 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

- 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](https://docs.tryflume.ai/embed/security.md#can-you-trust-the-save-payload).
- It copies no image bytes. See [Save payload](https://docs.tryflume.ai/concepts/save-payload.md#what-must-you-do-with-the-payload).

## 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](https://docs.tryflume.ai/embed/timeouts.md#the-iframe-did-not-load).
