# Flume Docs: full text > Developer docs for integrating Flume: embed the Flume iframe (recommended) or call the Partner API, so your users find tile and vinyl flooring by photo, get a delivered price, and save the result into your product. Every page of the site, in the order an integration is built. Values are for the sandbox. Request samples are curl only: each page's own Markdown file (its URL plus `.md`) also has Node.js and Python. The rules every integration must keep are on the Security and CSP page. --- # Integrate Flume Source: https://docs.tryflume.ai/ > Two ways to add Flume to your product. Embed the Flume iframe, recommended and fastest, or call the Partner API and build the screens yourself. Flume finds tile and vinyl flooring that match a photo, prices it delivered to a US ZIP code, and hands the chosen product back to your product. There are two ways to integrate. - [Embed the iframe (recommended)](https://docs.tryflume.ai/quickstart/embed.md): The fastest way. Flume draws every screen. Your backend makes two calls and your page runs one snippet. - [Call the API](https://docs.tryflume.ai/quickstart/api.md): You build the screens. Your backend calls verify, search, product, quote and save. ## Which one should you use? Start with the iframe unless you need your own screens. It is the shortest path to a working integration, and Flume keeps the search and pricing screens up to date for you. | | Embed the iframe (recommended) | Call the API | |---|---|---| | Time to integrate | Shortest. Two server calls and one snippet | Longer. You build and test every screen | | Who builds the screens | Flume | You | | Server calls you make | 2: start a session, exchange a connect code | The [six steps](https://docs.tryflume.ai/quickstart/api.md#how-it-fits-together): verify, search, product, quote, link into Flume | | Email verification | In the iframe. You build nothing | Your own screens, with two calls | | Search by photo and instant quote | In the iframe | `POST /search`, `POST /request-instant-quote` | | The result in your product | A `flume:save_item` message | The `POST /save-item` response | | New Flume features | Appear in the iframe | You add each one | | Fits when | You want it live fast | You need Flume inside your own design | **Diagram: Two ways to integrate, side by side.** With the iframe, Flume draws the screens and your backend makes two calls. With the API, you draw them and your backend makes every call. ```mermaid flowchart LR i-screens["Flume iframe / verify, search, quote, save"] i-page["Your page / frames it, handles messages"] i-backend["Your backend / 2 server calls"] a-screens["Your screens / you build each one"] a-backend["Your backend / the six API steps"] a-flume["Flume servers / Partner API"] i-screens -.->|"4 messages"| i-page i-page -.->|"2 replies"| i-screens i-page -->|"your routes"| i-backend a-screens -->|"your routes"| a-backend a-backend -->|"every step"| a-flume ``` 1. Embed the iframe, recommended: Flume draws the verify, search, quote and save screens inside an iframe. 2. Your page frames the iframe and handles its messages: four from the iframe, two replies from your page. 3. Your backend makes two server calls: start a session, and exchange a connect code. 4. Call the API: you build every screen yourself. 5. Your backend makes every call to the Partner API, in six steps: verify (two calls), search by photo, product detail, instant quote, and a link into Flume. 6. Both use the same partner key, users and save payload, and you can combine them. - Both use the same partner key, the same users and the same sandbox. - The save payload is the same object on both paths. - You can combine them. The iframe itself relies on two of the same server calls, and a product saved in the iframe can be read or linked with the API. ## How it fits together **Diagram: Who talks to whom.** Four parts and one person. Your page and the Flume iframe share the browser. Only your backend calls Flume with your key. ```mermaid flowchart LR user["User"] page["Your page / frames the iframe"] backend["Your backend / holds the partner key"] iframe["Flume iframe / draws every screen"] flume["Flume servers"] user -->|"clicks"| page user -->|"searches, prices, saves"| iframe page -->|"your routes"| backend backend -->|"server calls, partner key"| flume page -.-> iframe iframe -.->|"postMessage: 4 to you, 2 back"| page iframe -->|"its own calls"| flume ``` 1. The user clicks a button on your page. 2. Your page calls your own backend. It never calls Flume. 3. Your backend calls Flume over HTTPS, with your partner key. These are the server calls. 4. Your page frames the Flume iframe. The two talk only with postMessage: four message types from the iframe, two from your page. 5. The user searches, prices and saves inside the iframe. The iframe makes its own calls to Flume with its own short-lived credential. 6. Your partner key and user access tokens stay on your backend. They never reach the browser. 1. Flume gives you a **partner key**. It identifies your platform. 2. Each **user** verifies their email once. You get a **user access token** for them. 3. With both, your backend acts for that user: it opens the iframe, or it searches, prices and saves through the API. 4. Everything a user does also appears in their own Flume account. Read [Authentication and keys](https://docs.tryflume.ai/concepts/authentication.md) and [Users and verification](https://docs.tryflume.ai/concepts/users.md) first. They apply to both paths. Every term is in the [glossary](https://docs.tryflume.ai/concepts/glossary.md), and every picture is on [Diagrams](https://docs.tryflume.ai/diagrams.md). ## What exists today | Capability | Status | |---|---| | Sandbox | Open to partners with a sandbox key. Base URL `https://sandbox-api.tryflume.ai/partner/v1` | | Live | Early access. Not yet open to new partners; Flume enables it by request, one partner at a time. Build in the sandbox. See [Environments](https://docs.tryflume.ai/concepts/environments.md) | | Regions | On live, requests from some countries are refused before they reach the API | | Categories you can search | Tile and vinyl flooring | | Delivery | US ZIP codes. Prices are in USD | | Keys | Issued by Flume. You rotate and revoke them yourself | | OpenAPI document | [`/openapi.json`](https://docs.tryflume.ai/openapi.json), the file the [API reference](https://docs.tryflume.ai/api.md) is built from | | Webhooks | Not available. Poll, or use the iframe's messages | | SDKs and client libraries | Not available. Call the HTTP API directly | | Self-serve keys or a dashboard | Not available. Flume issues the first key | | A status page | Not available. Write to Flume | | Sandbox test data | Not available. The sandbox sends real verification emails | | Rate limit headers | Not available. A `429` from the API carries `Retry-After` | | Reading or changing your allowed origins and hosts | Not available. Ask Flume | | Calls from a browser | Not supported. Server to server only | ## Start here - [Get a sandbox key](https://docs.tryflume.ai/get-a-key.md): What to send Flume, and what you get back. - [Iframe quickstart](https://docs.tryflume.ai/quickstart/embed.md): Recommended. Open the iframe and receive a saved item. - [API quickstart](https://docs.tryflume.ai/quickstart/api.md): Verify a user, search by photo, get a quote. - [API reference](https://docs.tryflume.ai/api.md): Every endpoint, field and error. ## For language models and agents - Every page has a Markdown version. Add `.md` to its URL. - [`/llms.txt`](https://docs.tryflume.ai/llms.txt) lists every page, with a reading order and the rules in brief. [`/llms-full.txt`](https://docs.tryflume.ai/llms-full.txt) holds the whole site in one file. - [`/openapi.json`](https://docs.tryflume.ai/openapi.json) is the OpenAPI 3.1 document for the Partner API. - Every page has a menu: copy it as Markdown, or open it in ChatGPT or Claude. --- # Diagrams Source: https://docs.tryflume.ai/diagrams > Every diagram in these docs on one page, in reading order. Who talks to whom, each flow step by step, the iframe states, and where secrets live. Every diagram in these docs, in the order to read them. Each one also sits on the page that explains it, linked under its heading. Every diagram has a text version: open "Read this diagram as text". ## How to read them | Mark | Meaning | |---|---| | A box outlined in ink | Yours: your page or your backend | | A filled box | Flume's: the iframe or Flume's servers | | A capsule | A person, or a state | | A thick solid arrow | A server call over HTTPS | | A dashed arrow | A postMessage between the iframe and your page | | A thin grey arrow | A call the iframe makes on its own | | A dotted arrow | Something a person does | ## The two ways to integrate From the [overview](https://docs.tryflume.ai/index.md#which-one-should-you-use). **Diagram: Two ways to integrate, side by side.** With the iframe, Flume draws the screens and your backend makes two calls. With the API, you draw them and your backend makes every call. ```mermaid flowchart LR i-screens["Flume iframe / verify, search, quote, save"] i-page["Your page / frames it, handles messages"] i-backend["Your backend / 2 server calls"] a-screens["Your screens / you build each one"] a-backend["Your backend / the six API steps"] a-flume["Flume servers / Partner API"] i-screens -.->|"4 messages"| i-page i-page -.->|"2 replies"| i-screens i-page -->|"your routes"| i-backend a-screens -->|"your routes"| a-backend a-backend -->|"every step"| a-flume ``` 1. Embed the iframe, recommended: Flume draws the verify, search, quote and save screens inside an iframe. 2. Your page frames the iframe and handles its messages: four from the iframe, two replies from your page. 3. Your backend makes two server calls: start a session, and exchange a connect code. 4. Call the API: you build every screen yourself. 5. Your backend makes every call to the Partner API, in six steps: verify (two calls), search by photo, product detail, instant quote, and a link into Flume. 6. Both use the same partner key, users and save payload, and you can combine them. ## The big picture From the [overview](https://docs.tryflume.ai/index.md#how-it-fits-together). **Diagram: Who talks to whom.** Four parts and one person. Your page and the Flume iframe share the browser. Only your backend calls Flume with your key. ```mermaid flowchart LR user["User"] page["Your page / frames the iframe"] backend["Your backend / holds the partner key"] iframe["Flume iframe / draws every screen"] flume["Flume servers"] user -->|"clicks"| page user -->|"searches, prices, saves"| iframe page -->|"your routes"| backend backend -->|"server calls, partner key"| flume page -.-> iframe iframe -.->|"postMessage: 4 to you, 2 back"| page iframe -->|"its own calls"| flume ``` 1. The user clicks a button on your page. 2. Your page calls your own backend. It never calls Flume. 3. Your backend calls Flume over HTTPS, with your partner key. These are the server calls. 4. Your page frames the Flume iframe. The two talk only with postMessage: four message types from the iframe, two from your page. 5. The user searches, prices and saves inside the iframe. The iframe makes its own calls to Flume with its own short-lived credential. 6. Your partner key and user access tokens stay on your backend. They never reach the browser. ## The order to build in From [Get a sandbox key](https://docs.tryflume.ai/get-a-key.md#what-happens-next). **Diagram: From a key to live, step by step.** The order to build in. Each step has a page. ```mermaid flowchart LR r1["1. Get a sandbox key / and an allowed origin"] r2["2. Start a session / your backend"] r3["3. Mount the iframe / tag and host snippet"] r4["4. Exchange the code / store the user token"] r5["5. Write the saved item / once per save_uuid"] r6["6. Close, expiry, errors / every message handled"] r7["7. Go-live checklist / in the sandbox"] r8["8. Ask for live access / then switch URL and key"] r1 --> r2 r2 --> r3 r3 --> r4 r4 --> r5 r5 --> r6 r6 --> r7 r7 --> r8 ``` 1. Get a sandbox key, and have Flume set your page origin as an allowed origin. 2. Start a session from your backend with POST /start-iframe-session. 3. Mount the iframe on your page: the iframe tag and the host snippet. 4. Exchange the connect code on your backend and store the user access token. 5. Write the saved item, once per save_uuid, and reply to the iframe. 6. Handle close, expiry, the ready timer and errors. 7. Finish the go-live checklist in the sandbox. 8. Ask Flume for live access, then switch the base URL and the key. ## The API path From the [API quickstart](https://docs.tryflume.ai/quickstart/api.md). **Diagram: The API path: six steps.** An API integration makes these six calls, in this order. Every call carries the partner key; steps 3 to 6 also carry the user access token. ```mermaid sequenceDiagram participant backend as Your backend participant flume as Flume servers backend->>flume: POST /users/trigger-verification backend->>flume: POST /users/confirm-verification backend->>flume: POST /search backend->>flume: GET /products/sku_uuid backend->>flume: POST /request-instant-quote backend->>flume: POST /deep-links ``` 1. Verify a user, send the code: POST /users/trigger-verification. Flume emails the user a 6-digit code. 2. Verify a user, confirm the code: POST /users/confirm-verification returns the user access token. 3. Search by photo: POST /search returns two lists and a search_session_uuid. GET /filters lists the filter options. 4. Product detail: GET /products/{sku_uuid} returns photos, price, specification and shipping. 5. Instant quote: POST /request-instant-quote returns the delivered price. A quote that is still pricing is read with GET /quotes/{quote_uuid}. 6. Open Flume, signed in: POST /deep-links returns a one-time link into the user's Flume account. ## The API path, flow by flow From [Verify a user](https://docs.tryflume.ai/guides/verify-a-user.md) and [Find products by photo](https://docs.tryflume.ai/guides/find-products-by-photo.md). **Diagram: Steps 1 and 2: verify a user through the API.** You build the code screen. Two calls, with the partner key only. The second returns the user access token. ```mermaid sequenceDiagram participant user as User participant page as Your page participant backend as Your backend participant flume as Flume servers page->>backend: email backend->>flume: POST /users/trigger-verification flume->>user: email, 6 digits user->>page: types the code page->>backend: code backend->>flume: POST /users/confirm-verification flume->>backend: access_token Note over backend: store the token ``` 1. Your screen sends the user's email to your backend. 2. Your backend calls POST /users/trigger-verification with external_ref and email. 3. Flume emails the user a 6-digit code, valid for 10 minutes. 4. The user types the code on your screen. 5. Your screen sends the code to your backend. 6. Your backend calls POST /users/confirm-verification with the email and the code. 7. Flume returns the user access token, once. 8. Your backend stores the token against that user and sends it on every call made for them. **Diagram: Steps 3 to 5: search, product, quote.** Each call carries both headers. The search returns a search_session_uuid that the later calls carry. A quote that is still pricing is polled. ```mermaid sequenceDiagram participant page as Your page participant backend as Your backend participant flume as Flume servers backend->>flume: GET /filters page->>backend: photo, filters backend->>flume: POST /search flume->>backend: results, search_session_uuid backend->>flume: GET /products/sku_uuid backend->>flume: POST /request-instant-quote flume->>backend: READY, or still pricing backend->>flume: GET /quotes/quote_uuid (not every time) backend->>page: prices ``` 1. Your backend calls GET /filters and builds your filter menus from the answer. 2. Your screen sends the photo and the chosen filters to your backend. 3. Your backend calls POST /search with the photo. 4. Flume returns up to 24 results a page and a search_session_uuid. 5. Your backend calls GET /products/{sku_uuid} for the product the user opens. 6. Your backend calls POST /request-instant-quote with the product, a quantity, a ZIP code and the search_session_uuid. It carries an Idempotency-Key. 7. Flume answers within 60 seconds: READY with prices, or FAILED with a reason. 8. Only when the reason is TEMPORARILY_UNAVAILABLE: your backend polls GET /quotes/{quote_uuid} until the quote is READY. 9. Your backend returns the prices to your screen. ## One open of the iframe, end to end From [How the iframe works](https://docs.tryflume.ai/embed.md#what-is-the-lifecycle). **Diagram: The whole flow, from a click to a saved item.** One open of the iframe, end to end. Lighter steps happen only the first time a user opens it. ```mermaid sequenceDiagram participant iframe as Flume iframe participant page as Your page participant backend as Your backend participant flume as Flume servers page->>backend: open item backend->>flume: POST /start-iframe-session flume->>backend: iframe_url, session_uuid backend->>page: iframe_url, session_uuid page->>iframe: iframe.src = iframe_url iframe-->>page: flume:iframe_ready iframe-->>page: flume:user_connected (not every time) page->>backend: connect_code (not every time) backend->>flume: POST /users/exchange-connect-code (not every time) iframe-->>page: flume:save_item page-->>iframe: flume:item_saved iframe-->>page: flume:close_iframe ``` 1. The user clicks your button. Your page asks your backend to start a session. 2. Your backend calls POST /start-iframe-session with the user, project and item. 3. Flume returns iframe_url and session_uuid. 4. Your backend passes both to your page. 5. Your page starts the ready timer, adds the message listener, then sets the iframe src. 6. The iframe posts flume:iframe_ready. Your page clears the timer. 7. First time only: the user verifies their email, and the iframe posts flume:user_connected with a connect code. 8. First time only: your page sends the connect code to your backend. 9. First time only: your backend exchanges the code for the user access token and stores it. 10. The user saves a product. The iframe posts flume:save_item with the save payload. 11. Your page writes its record and replies flume:item_saved, or flume:item_save_failed. 12. The iframe posts flume:close_iframe. Your page removes the iframe. ## Every message From [Messages](https://docs.tryflume.ai/embed/messages.md#all-messages). **Diagram: Every message, and which way it goes.** All six postMessage types between the iframe and your page, in the order one open usually sends them. Four come to your page, two go back. ```mermaid sequenceDiagram participant iframe as Flume iframe participant page as Your page iframe-->>page: flume:iframe_ready iframe-->>page: flume:user_connected (not every time) iframe-->>page: flume:save_item page-->>iframe: flume:item_saved page-->>iframe: flume:item_save_failed (not every time) iframe-->>page: flume:close_iframe ``` 1. The iframe posts flume:iframe_ready once it has painted, with an empty payload. Clear the ready timer and show the iframe. 2. First open only: the iframe posts flume:user_connected with a connect_code. Send it to your backend at once. The iframe expects no reply. 3. The iframe posts flume:save_item with the save payload, which carries save_uuid. It waits 60 seconds for your reply. 4. Your page replies flume:item_saved with the same save_uuid, and item_ref and item_label when you have them. 5. Or your page replies flume:item_save_failed with the same save_uuid and a reason: permission, conflict, storage or error. 6. The iframe posts flume:close_iframe with a reason of done, closed or expired. Remove the iframe. On expired, start a new session. ## Starting a session From [Server calls](https://docs.tryflume.ai/embed/server-calls.md#start-a-session). **Diagram: Start a session and mount the iframe.** From the click to a loaded iframe. A user access token, when you hold one, skips the email step. ```mermaid sequenceDiagram participant user as User participant iframe as Flume iframe participant page as Your page participant backend as Your backend participant flume as Flume servers user->>page: clicks your button page->>backend: your route backend->>flume: POST /start-iframe-session flume->>backend: iframe_url, session_uuid backend->>page: iframe_url, session_uuid Note over page: ready timer, 15 s page->>iframe: iframe.src iframe->>flume: launch code, once iframe-->>page: flume:iframe_ready ``` 1. The user clicks your button for one item. 2. Your page asks your backend to start a session. 3. Your backend calls POST /start-iframe-session with the user, project, item and host_origin. It sends the stored user access token when it holds one. 4. Flume returns iframe_url and session_uuid. 5. Your backend returns only those two values to your page. 6. Your page starts a ready timer of 15 seconds and adds the message listener. 7. Your page sets iframe_url as the iframe src, at once. 8. The iframe spends the launch code with Flume. It works once, within 120 seconds. 9. The iframe posts flume:iframe_ready. Your page clears the timer and hides its spinner. The iframe also posts it when it could not start: it then shows its own error, and its Try again posts flume:close_iframe with reason expired. ## The first open From [Users and verification](https://docs.tryflume.ai/concepts/users.md#how-does-verification-work-in-the-iframe). **Diagram: The first open: the user verifies, you get a token.** Happens once per user. You build no screen for it: the iframe asks for the code. ```mermaid sequenceDiagram participant user as User participant iframe as Flume iframe participant page as Your page participant backend as Your backend participant flume as Flume servers user->>iframe: clicks Connect iframe->>flume: send a code flume->>user: email, 6 digits user->>iframe: types the code iframe->>flume: check the code iframe-->>page: flume:user_connected page->>backend: connect_code backend->>flume: POST /users/exchange-connect-code flume->>backend: access_token Note over backend: store the token ``` 1. The iframe opened without a user access token, so it shows the user their name and email. The user clicks Connect. 2. The iframe asks Flume to email a code. 3. Flume emails a 6-digit code to the user. It is valid for 10 minutes. 4. The user types the code in the iframe. 5. The iframe checks the code with Flume. Any token you held for this user stops working now. 6. The iframe posts flume:user_connected with a connect code. 7. Your page sends the connect code to your backend, at once. 8. Your backend calls POST /users/exchange-connect-code. The code works once, within 120 seconds. 9. Flume returns the user access token, once. 10. Your backend checks external_ref, then stores the token over any older one. It sends it on the next POST /start-iframe-session. ## A later open From [Users and verification](https://docs.tryflume.ai/concepts/users.md#how-does-verification-work-in-the-iframe). **Diagram: A later open, for a user whose token you hold.** No email step and no flume:user_connected. Lighter steps happen only when the stored token has stopped working. ```mermaid sequenceDiagram participant user as User participant iframe as Flume iframe participant page as Your page participant backend as Your backend participant flume as Flume servers user->>page: clicks your button page->>backend: your route backend->>flume: POST /start-iframe-session flume->>backend: 401 USER_TOKEN_INVALID (not every time) Note over backend: delete the token (not every time) backend->>flume: POST /start-iframe-session (not every time) flume->>backend: iframe_url, session_uuid backend->>page: iframe_url, session_uuid page->>iframe: iframe.src iframe->>flume: launch code, once iframe-->>page: flume:iframe_ready ``` 1. The user clicks your button for one item. 2. Your page asks your backend to start a session. 3. Your backend calls POST /start-iframe-session and sends the user access token it stored for this user. 4. Only when the token no longer works, because the user verified again or you revoked it: Flume answers 401 USER_TOKEN_INVALID. 5. Then your backend deletes the stored token. 6. Then your backend calls POST /start-iframe-session again without a token. The user verifies in the iframe, as on a first open. 7. Flume returns iframe_url and session_uuid. 8. Your backend returns those two values to your page. 9. Your page starts the ready timer, adds the message listener and sets the iframe src. 10. The iframe spends the launch code with Flume. 11. The iframe posts flume:iframe_ready and opens with the user already verified. ## Saving an item From [Save payload](https://docs.tryflume.ai/concepts/save-payload.md#what-is-the-flow) and [Messages](https://docs.tryflume.ai/embed/messages.md#what-does-the-user-see-around-a-save). **Diagram: Save an item.** The user saves in the iframe, your side writes the record, and your page answers. Your backend calls Flume at no point. ```mermaid sequenceDiagram participant user as User participant iframe as Flume iframe participant page as Your page participant backend as Your backend participant flume as Flume servers user->>iframe: confirms a save iframe->>flume: build the save payload iframe-->>page: flume:save_item page->>backend: save payload Note over backend: write once per save_uuid backend->>page: your record id page-->>iframe: flume:item_saved iframe->>flume: record the result iframe->>user: saved screen ``` 1. The user confirms a save in the iframe. 2. The iframe asks Flume for the save payload. If that fails, the iframe shows its own error and your page hears nothing. 3. The iframe posts flume:save_item with the save payload and its save_uuid. 4. Your page sends the payload to your backend. 5. Your backend writes one record per save_uuid, and copies the photo bytes if you keep a photo. 6. Your backend answers with your record, or with a failure. 7. Your page replies flume:item_saved with the same save_uuid, within 60 seconds. On a failed write it replies flume:item_save_failed instead. 8. The iframe tells Flume how the save ended. You make no call for this. 9. The iframe shows the saved screen. **Diagram: Every way a save can end.** Three outcomes of one flume:save_item. Two of them show the user the same message, and a retry repeats the same save_uuid. ```mermaid flowchart LR request["flume:save_item / carries save_uuid"] ok["flume:item_saved / your write worked"] failed["flume:item_save_failed / permission, conflict, / storage or error"] silent["No reply / within 60 seconds"] saved["Saved screen / shows item_label"] retry["Could not save / Close, or Try again"] request --> ok request --> failed request --> silent ok --> saved failed --> retry silent --> retry ``` 1. The iframe posts flume:save_item with a save_uuid. 2. Your page replies flume:item_saved: the user sees the saved screen, with your item_label when you send one. 3. Your page replies flume:item_save_failed with a reason of permission, conflict, storage or error: the user sees one message with Close and Try again. The reason is not shown. 4. Your page does not reply within 60 seconds: the user sees the same message. 5. Try again posts flume:save_item again with the same save_uuid. Your write must be safe to repeat. ## Opening a saved item in Flume From [Server calls](https://docs.tryflume.ai/embed/server-calls.md#create-a-link-that-opens-flume). **Diagram: Open in Flume, for a saved item.** Optional. A one-time link, created on the click and never stored. ```mermaid sequenceDiagram participant user as User participant page as Your page participant backend as Your backend participant flume as Flume servers user->>page: clicks Open in Flume Note over page: open a blank tab page->>backend: your route backend->>flume: POST /deep-links flume->>backend: url backend->>page: url page->>user: tab goes to the url ``` 1. The user clicks "Open in Flume" on a saved item. 2. Your page opens a blank tab in the click handler, so the browser does not block it. 3. Your page asks your backend for a link. 4. Your backend calls POST /deep-links with the user access token, and the search_session_uuid and sku_uuid it kept from the save payload. 5. Flume returns a link that works once, within 7 days. 6. Your backend returns the link. It stores it nowhere. 7. Your page points the tab at the link. Flume opens with the user signed in. With no token, link to product_url instead. ## The states of the iframe on your page From [Set up your page](https://docs.tryflume.ai/embed/host-page.md#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. ## Where secrets live From [Security and CSP](https://docs.tryflume.ai/embed/security.md#where-do-credentials-live). **Diagram: Where each secret lives and travels.** Long-lived secrets stay on servers. Only single-use, short-lived codes pass through the browser. ```mermaid flowchart LR page["Your page / checks origin, source, / version, session_uuid"] backend["Your backend / partner key / user access tokens"] iframe["Flume iframe / posts only to host_origin / its own short credential"] flume["Flume servers / allowed origins / per partner"] backend -->|"iframe_url (launch code)"| page page -->|"connect_code"| backend backend -->|"partner key, user access token"| flume flume -->|"iframe_url, access_token"| backend page -.->|"save replies"| iframe iframe -.->|"connect_code, save payload"| page iframe -->|"launch code, once"| flume ``` 1. Your backend holds the partner key and each user access token. It sends them only to Flume, over HTTPS. 2. Flume returns iframe_url, which carries a launch code. The launch code works once, within 120 seconds. 3. Your backend hands iframe_url to your page. Your page sets it as the iframe src and stores it nowhere. 4. The iframe spends the launch code with Flume and then uses its own short-lived credential. 5. When a user verifies, the iframe posts a connect code to your page. It works once, within 120 seconds. 6. Your page passes the connect code to your backend, which exchanges it for the user access token. 7. Your page accepts a message only when its origin, source, version and session_uuid are right. The iframe posts only to the host_origin you sent. ## What you store From [How the iframe works](https://docs.tryflume.ai/embed.md#what-must-you-store). **Diagram: What you store, and what you never store.** Two things are worth keeping. Everything else is used once and dropped. ```mermaid flowchart LR s-exchange["Exchange response / POST /users/exchange-connect-code"] s-save["flume:save_item / the save payload"] s-start["Session response / POST /start-iframe-session"] s-connected["flume:user_connected"] k-token["User access token / per external_ref, encrypted"] k-item["Your saved item / keyed by save_uuid: your fields, / photo bytes, product_url, two ids"] n-url["iframe_url / set as the iframe src at once"] n-code["connect_code / exchange at once"] s-exchange -->|"access_token"| k-token s-save -->|"mapped fields"| k-item s-start -->|"use once"| n-url s-connected -->|"use once"| n-code ``` 1. From the exchange response, store the user access token, per external_ref, encrypted, on your server. 2. From flume:save_item, store your own record keyed by save_uuid: the fields you map, the photo bytes, product_url, and the two ids an Open in Flume link needs (match.search_session_uuid and product.sku_uuid). 3. Never store iframe_url or its launch code. Set it as the iframe src at once. 4. Never store a connect code. Exchange it at once. 5. Also never store image links or an Open in Flume link: both expire. Log session_uuid with your item id and user id. ## How long things last From [Limits](https://docs.tryflume.ai/concepts/limits.md#how-long-do-things-last). **Diagram: How long each thing lasts.** Every timer, code and link in the integration, shortest first. The scale is logarithmic. 1. Ready timer: 15 seconds. On your page. When it fires, remove the iframe and offer a retry. 2. Reply to a save: 60 seconds. After that the iframe treats the save as failed. 3. Launch code: 120 seconds, one use. Inside iframe_url. Set the iframe src at once. 4. Connect code: 120 seconds, one use. Exchange it at once. 5. Verification code: 10 minutes. The emailed code the user types in the iframe. 6. Iframe session: 12 hours. From its first open. Then flume:close_iframe with reason expired. 7. Image links: about 24 hours. In the save payload. Copy the bytes when you save. 8. Open in Flume link: 7 days, one use. Create it on the click. Never store it. 9. Instant quote prices: 30 days. Until pricing.valid_until in the save payload. 10. Unused session: 30 days. A session nobody reopens is dropped. The next open starts fresh. 11. User access token: no expiry. Replaced when the user verifies again, or revoked by you. ## When a server call fails From [Handle errors](https://docs.tryflume.ai/guides/handle-errors.md). **Diagram: What to do when a server call fails.** Read the status and error.code on the left, then take the one action beside it. ```mermaid flowchart LR c-key["401 PARTNER_KEY_INVALID"] a-key["Fix the key or the base URL / do not retry"] c-token["401 USER_TOKEN_INVALID"] a-token["Delete the stored token / start again without it"] c-code["401 CONNECT_CODE_INVALID"] a-code["Store nothing / the user verifies on the next open"] c-403["403 USER_NOT_CONNECTED"] a-403["Verify the user / then send the user access token"] c-4xx["400, 404, 413, 422 / and any other 403 or 409"] a-4xx["Fix the request / field names what is wrong"] c-409["409 IDEMPOTENCY_IN_PROGRESS"] a-409["Retry with the same key / after Retry-After"] c-429["429"] a-429["Wait for Retry-After, then retry / a daily limit resets at 00:00 UTC"] c-5xx["500, 502, 503, timeout"] a-5xx["Retry with a growing wait / same Idempotency-Key, up to 3 retries"] c-key --> a-key c-token --> a-token c-code --> a-code c-403 --> a-403 c-4xx --> a-4xx c-409 --> a-409 c-429 --> a-429 c-5xx --> a-5xx ``` 1. 401 PARTNER_KEY_INVALID: fix the key, or the base URL it belongs to. Do not retry. 2. 401 USER_TOKEN_INVALID: delete the stored token and start the session again without it. The user verifies again in the iframe. 3. 401 CONNECT_CODE_INVALID: store nothing and do not retry. The user verifies again on the next open. 4. 403 USER_NOT_CONNECTED: the call needs a verified user. Verify the user, then send the user access token. 5. 400, 404, 413, 422, and any other 403 or 409: fix the request. On a 422, field names what is wrong. Do not retry unchanged. 6. 409 IDEMPOTENCY_IN_PROGRESS: the first request with this Idempotency-Key is still running. Retry with the same key after Retry-After. 7. 429: wait for the number of seconds in Retry-After, then retry. For a daily limit that is the time until 00:00 UTC, so stop for the day. 8. 500, 502, 503 or a timeout: retry with a growing wait and the same Idempotency-Key, up to 3 retries. ## What has a table instead - Every message with its payload fields and what to do: [Messages](https://docs.tryflume.ai/embed/messages.md#all-messages). - Sandbox and live, side by side: [Environments](https://docs.tryflume.ai/concepts/environments.md). - Limits: [Limits](https://docs.tryflume.ai/concepts/limits.md). --- # Get a sandbox key Source: https://docs.tryflume.ai/get-a-key > Flume issues sandbox keys by request. Email Flume your company, your page origins and your image hosts, and you get back a key for the sandbox. Flume issues sandbox keys by request. Send one email with the details below. There is no sign-up form and no dashboard today. ## Ask for a key Email support@tryflume.ai with: ```text Subject: Sandbox key request Company name: Your name and work email: What you are building (iframe, API, or both): Page origins that will frame the iframe (for example https://app.yourcompany.com, http://localhost:3000): Image hosts your item photos are served from (for example cdn.yourcompany.com): Redirect hosts for deep links, if any: ``` ## What should you send? | Detail | Why Flume needs it | Example | |---|---|---| | Company name | To name your platform | Acme Design | | Your name and work email | To send the key | | | What you are building | Iframe, API, or both | Iframe | | Page origins | Only these pages may frame the iframe | `https://app.acme.example`, `http://localhost:3000` | | Image hosts | Flume fetches item photos only from these hosts | `cdn.acme.example` | | Redirect hosts | A deep link may return the user only to these hosts | `app.acme.example` | - An origin is a scheme, a host and a port. It has no path and no trailing slash. - A local origin such as `http://localhost:3000` can be an allowed origin in the sandbox. Ask for it. The port must match exactly. ## What do you get back? | You get | Notes | |---|---| | A sandbox partner key | It starts with `sk_dev_`. Flume cannot show it again. Store it in a secret manager | | Your partner slug | The short name inside your key and your links | | The origins and hosts Flume set for you | Check them against what you asked for | ## What happens next? **Diagram: From a key to live, step by step.** The order to build in. Each step has a page. ```mermaid flowchart LR r1["1. Get a sandbox key / and an allowed origin"] r2["2. Start a session / your backend"] r3["3. Mount the iframe / tag and host snippet"] r4["4. Exchange the code / store the user token"] r5["5. Write the saved item / once per save_uuid"] r6["6. Close, expiry, errors / every message handled"] r7["7. Go-live checklist / in the sandbox"] r8["8. Ask for live access / then switch URL and key"] r1 --> r2 r2 --> r3 r3 --> r4 r4 --> r5 r5 --> r6 r6 --> r7 r7 --> r8 ``` 1. Get a sandbox key, and have Flume set your page origin as an allowed origin. 2. Start a session from your backend with POST /start-iframe-session. 3. Mount the iframe on your page: the iframe tag and the host snippet. 4. Exchange the connect code on your backend and store the user access token. 5. Write the saved item, once per save_uuid, and reply to the iframe. 6. Handle close, expiry, the ready timer and errors. 7. Finish the go-live checklist in the sandbox. 8. Ask Flume for live access, then switch the base URL and the key. 1. Flume reads your request and replies by email. 2. You store the key and run the [quickstart](https://docs.tryflume.ai/quickstart/embed.md). 3. After that you rotate and revoke keys yourself. See [Rotate your key](https://docs.tryflume.ai/guides/rotate-keys.md). 4. When your integration works in the sandbox, follow the [go-live checklist](https://docs.tryflume.ai/go-live.md) to ask for live access. Flume does not publish a response time for key requests. ## What a sandbox key cannot do - It does not work on live. See [Environments](https://docs.tryflume.ai/concepts/environments.md). - It cannot change your allowed origins, image hosts or redirect hosts. [Ask Flume](https://docs.tryflume.ai/support.md). --- # Quickstart Source: https://docs.tryflume.ai/quickstart/embed > Open the Flume iframe from your page with two backend calls and a short host snippet, then receive the item your user saved as a message. Open the Flume iframe for one item, let the user find and price an alternative, and receive the result as a message. Your backend makes two calls. Your page runs one snippet. To run a working page first, copy the [full example](https://docs.tryflume.ai/embed/example.md). ## Before you begin - A sandbox partner key. [Get a sandbox key](https://docs.tryflume.ai/get-a-key.md). - Your page's origin, set as an allowed origin by Flume. For local work, ask for `http://localhost:3000`. - Only if you send item photos: your image host, set by Flume. Flume sets your allowed origins and image hosts. You cannot change them yourself today. See [Security and CSP](https://docs.tryflume.ai/embed/security.md). ## 1. Start a session from your backend Call this when the user clicks your "Find alternatives" button. Send the user, the project and the item. **curl** ```sh curl -X POST "https://sandbox-api.tryflume.ai/partner/v1/start-iframe-session" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "Content-Type: application/json" \ -d '{ "user": { "external_ref": "u_8812", "email": "sarah.chen@example.com", "first_name": "Sarah", "last_name": "Chen" }, "project": { "external_ref": "proj_7781", "name": "Harbor Lofts - L2", "city": "McLean", "state": "VA", "destination_zip": "22102" }, "item": { "external_id": "48213", "label": "T-1", "name": "Calacatta look porcelain 24 x 48", "item_type": "Tile", "quantity": 6000, "uom": "Sq. Ft." }, "host_origin": "https://app.acme.example" }' ``` Response `201`: ```json { "session_uuid": "c1f0a6de-3b52-4d8e-9c47-2f8a1b6d0e93", "iframe_url": "https://?partner=acme#launch_code=launch_example&session=c1f0a6de-3b52-4d8e-9c47-2f8a1b6d0e93" } ``` Change `host_origin` to the exact origin of your own page before you run the sample. Every field is on [Server calls](https://docs.tryflume.ai/embed/server-calls.md#start-a-session). Then: - Return `iframe_url` and `session_uuid` to your page. - Use `iframe_url` at once. Its launch code works once, within 120 seconds. - Never build or edit `iframe_url` yourself. ## 2. Add the iframe to your page Create the iframe with this `sandbox` attribute and no `src`. The snippet sets `src`. ```html ``` Give it a size. Without CSS an iframe is 300 by 150 pixels. ```css #flume { position: fixed; inset: 0 0 0 auto; width: min(470px, 100vw); height: 100dvh; border: 0; } ``` ## 3. Mount it with the host snippet Save the host snippet next to your page: ```sh curl -sSO "https://docs.tryflume.ai/samples/mount-flume.js" ``` Then call it from a module script. `mountFlume` returns a function, `stop`, that removes its listener and timer. ```js import { mountFlume } from "./mount-flume.js"; const frame = document.querySelector("#flume"); const { iframe_url, session_uuid } = await startSessionOnYourBackend(); function removeFrame() { stop(); frame.removeAttribute("src"); } const stop = mountFlume({ frame, iframeUrl: iframe_url, sessionUuid: session_uuid, flumeOrigin: new URL(iframe_url).origin, handlers: { hideSpinner() {}, showError() { removeFrame(); showRetry("Flume did not load.", openFlumeAgain); }, exchangeConnectCode(connectCode) { sendToYourBackend("/flume/connect", { connectCode }); }, async saveItem(payload) { const row = await sendToYourBackend("/flume/save", payload); return { save_uuid: payload.save_uuid, item_ref: row.id, item_label: row.label }; }, close(reason) { removeFrame(); if (reason === "expired") openFlumeAgain(); }, }, }); ``` `startSessionOnYourBackend`, `sendToYourBackend`, `showRetry` and `openFlumeAgain` are your own functions. `openFlumeAgain` starts a new session and mounts again. - If `saveItem` throws, the snippet replies `flume:item_save_failed` with reason `error`. Throw `{ reason: "storage" }` to state a reason. - Call `stop()` whenever your page removes the iframe itself. > **Take the origin from iframe_url:** Do not hardcode the iframe's origin. Read it with `new URL(iframe_url).origin`. It differs between the two environments, and it can change. ## 4. Exchange the connect code The first time, the user verifies their email inside the iframe. Your page then receives `flume:user_connected` with a `connect_code`. Send it to your backend and exchange it within 120 seconds. **curl** ```sh curl -X POST "https://sandbox-api.tryflume.ai/partner/v1/users/exchange-connect-code" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "Content-Type: application/json" \ -d '{ "connect_code": "connect_example_code_0000" }' ``` Response `200`: ```json { "external_ref": "u_8812", "email": "sarah.chen@example.com", "access_token": "ut_dev_acme_example_token", "token_type": "user_token", "verified_at": "2026-10-16T09:02:11Z", "account": "LINKED" } ``` 1. Check that `external_ref` is the user who is signed in to your product. 2. Store `access_token` against that user. It replaces any token you held for them. 3. Send it as `X-Flume-User-Token` on the next `POST /start-iframe-session`. The user then skips the email step. ## 5. Save the item When the user saves, your page receives `flume:save_item`. Its payload is the [save payload](https://docs.tryflume.ai/concepts/save-payload.md). 1. Write it into your own records. Use `save_uuid` as your idempotency key. 2. Return `{ save_uuid, item_ref }` from `saveItem` on success. 3. Throw on failure. The reasons are `permission`, `conflict`, `storage` and `error`. Reply within 60 seconds. After that the iframe treats the save as failed. ## It worked Your page opened the iframe, the user verified their email, and one saved item reached your records. ## Next steps - [Full example](https://docs.tryflume.ai/embed/example.md): one page and one server you can run. - [Iframe messages](https://docs.tryflume.ai/embed/messages.md): every message and its payload. - [Save payload](https://docs.tryflume.ai/concepts/save-payload.md): every field you can map. - [Timeouts and expiry](https://docs.tryflume.ai/embed/timeouts.md): what to do when a session ends. - [Security and CSP](https://docs.tryflume.ai/embed/security.md): what to allow on your page, and what to trust. --- # API quickstart Source: https://docs.tryflume.ai/quickstart/api > The Flume Partner API in six steps, from a terminal. Verify a user, search by photo, read a product, get an instant quote and open Flume signed in. Call the Partner API from a terminal, in the six steps of the Partner API. You finish with a delivered price and a link into the user's Flume account. Every command runs as pasted. Values from one step are kept in shell variables for the next. Each step links to its reference page, which has the same call in Python and Node.js. **Diagram: The API path: six steps.** An API integration makes these six calls, in this order. Every call carries the partner key; steps 3 to 6 also carry the user access token. ```mermaid sequenceDiagram participant backend as Your backend participant flume as Flume servers backend->>flume: POST /users/trigger-verification backend->>flume: POST /users/confirm-verification backend->>flume: POST /search backend->>flume: GET /products/sku_uuid backend->>flume: POST /request-instant-quote backend->>flume: POST /deep-links ``` 1. Verify a user, send the code: POST /users/trigger-verification. Flume emails the user a 6-digit code. 2. Verify a user, confirm the code: POST /users/confirm-verification returns the user access token. 3. Search by photo: POST /search returns two lists and a search_session_uuid. GET /filters lists the filter options. 4. Product detail: GET /products/{sku_uuid} returns photos, price, specification and shipping. 5. Instant quote: POST /request-instant-quote returns the delivered price. A quote that is still pricing is read with GET /quotes/{quote_uuid}. 6. Open Flume, signed in: POST /deep-links returns a one-time link into the user's Flume account. ## How it fits together | Step | Call | |---|---| | 1. Verify a user: send the code | `POST /users/trigger-verification` | | 2. Verify a user: confirm the code | `POST /users/confirm-verification` | | 3. Search by photo | `POST /search` | | 4. Product detail | `GET /products/{sku_uuid}` | | 5. Instant quote | `POST /request-instant-quote` | | 6. Open Flume, signed in | `POST /deep-links` | Supporting calls: `GET /filters` (filter options), `GET /quotes/{quote_uuid}` (a quote that is still pricing), `DELETE /users/{external_ref}/token` (revoke a user's token), and `GET /keys`, `POST /keys`, `DELETE /keys/{key_id}` (your own keys). ## Two headers - Every call carries your partner key as `X-Flume-Partner-Key`. - Steps 3 to 6 also carry the user access token as `X-Flume-User-Token`. Neither works alone. - Start with a sandbox key (`sk_dev_`…), then a user access token once you have one. - Prices are US dollars, sent as text with 2 decimals, for example `"6.40"`. ## Before you begin - A sandbox partner key. [Get a sandbox key](https://docs.tryflume.ai/get-a-key.md). - An email inbox you can read. The sandbox sends a real verification email. - `curl` and [`jq`](https://jqlang.github.io/jq/). Set three variables. Use your own key and your own email address. ```sh export FLUME_PARTNER_KEY="paste-your-sandbox-key" export EMAIL="you@yourcompany.com" export USER_REF="quickstart-user-1" ``` Download a sample photo of a tile. Any JPEG, PNG or WebP up to 6,000,000 bytes works. ```sh curl -sSO "https://docs.tryflume.ai/samples/tile.jpg" ``` ## 1. Verify a user: send the code Your first call for a new user. Flume emails them a 6-digit code. `external_ref` is your own permanent id for the user. Reference: [`POST /users/trigger-verification`](https://docs.tryflume.ai/api/trigger-verification.md). ```sh curl -sS -X POST "https://sandbox-api.tryflume.ai/partner/v1/users/trigger-verification" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "Content-Type: application/json" \ -d "{\"external_ref\": \"$USER_REF\", \"email\": \"$EMAIL\", \"method\": \"email_code\"}" ``` **Response (202)** ```json { "external_ref": "u_8812", "masked_email": "s···@example.com", "expires_in_s": 600, "resend_after_s": 30 } ``` Carry forward: nothing. Read the code from the email. ## 2. Verify a user: confirm the code The right code opens the user's Flume account and returns their user access token. It does not expire. Reference: [`POST /users/confirm-verification`](https://docs.tryflume.ai/api/confirm-verification.md). ```sh CODE="123456" # the digits from the email export FLUME_USER_TOKEN=$(curl -sS -X POST "https://sandbox-api.tryflume.ai/partner/v1/users/confirm-verification" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "Content-Type: application/json" \ -d "{\"email\": \"$EMAIL\", \"code\": \"$CODE\"}" | jq -r .access_token) echo "${FLUME_USER_TOKEN:0:12}..." ``` - The token is returned once. In your product, store it as a secret, against the user. - If the output is `null...`, the code was wrong or expired. Run step 1 again. Carry forward: `FLUME_USER_TOKEN`, on every call from here on. Keep it against the user: it does not expire. ## 3. Search by photo A photo and the user's filters give two lists, strong matches and other similar options, plus a `search_session_uuid`. Reference: [`POST /search`](https://docs.tryflume.ai/api/search.md). To build your filter menus first, read the options with [`GET /filters`](https://docs.tryflume.ai/api/list-filters.md): ```sh curl -sS "https://sandbox-api.tryflume.ai/partner/v1/filters" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "X-Flume-User-Token: $FLUME_USER_TOKEN" | jq '.tile | keys' ``` Then search. Send the photo and a JSON `payload` as `multipart/form-data`. `category` is required. ```sh SEARCH=$(curl -sS -X POST "https://sandbox-api.tryflume.ai/partner/v1/search" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "X-Flume-User-Token: $FLUME_USER_TOKEN" \ -F "image=@tile.jpg" \ -F 'payload={"category": "TILE"}') echo "$SEARCH" | jq '{search_session_uuid, total, first: .strong_matches[0].name}' ``` Keep the search's id, and pick the first product that has an instant price. ```sh SEARCH_ID=$(echo "$SEARCH" | jq -r .search_session_uuid) SKU=$(echo "$SEARCH" | jq -r '[.strong_matches[], .other_similar_options[]] | map(select(.price_on_request == false)) | .[0].sku_uuid') echo "search=$SEARCH_ID product=$SKU" ``` **Response (200)** ```json { "search_session_uuid": "5f0c1d2e-8a7b-4c3d-9e1f-2a3b4c5d6e7f", "total": 31, "strong_match_count": 9, "other_similar_count": 22, "page": 1, "page_size": 24, "has_more": true, "strong_matches": [ { "sku_uuid": "7f3c2a91-4b0e-4c8d-9a21-1f6b2d5e7c30", "name": "Porcelain 24×48″ Matte", "image_url": "https://sandbox-api.tryflume.ai/partner/v1/images/example-image-main", "more_image_urls": [ "https://sandbox-api.tryflume.ai/partner/v1/images/example-image-2", "https://sandbox-api.tryflume.ai/partner/v1/images/example-image-3" ], "size": "24×48 in", "thickness": "9 mm", "material": "Porcelain", "as_low_as_per_sqft": "6.40", "price_on_request": false, "lead_times": [ { "source": "US_WAREHOUSE", "lead_time_min_days": 14, "lead_time_max_days": 30 }, { "source": "OVERSEAS_DIRECT", "lead_time_min_days": 90, "lead_time_max_days": 120 } ], "suitable_application_areas": [ { "code": "INTERIOR_DRY_FLOOR", "label": "Interior Dry Floor" }, { "code": "INTERIOR_WALL_BACKSPLASH", "label": "Interior Wall / Backsplash" } ], "saving_vs_spec": null } ], "other_similar_options": [ { "sku_uuid": "0b6f9d34-2e71-4c5a-8f10-3d9a7e2b6c41", "name": "Porcelain 24×48″ Natural", "image_url": "https://sandbox-api.tryflume.ai/partner/v1/images/example-image-main", "more_image_urls": [ "https://sandbox-api.tryflume.ai/partner/v1/images/example-image-2", "https://sandbox-api.tryflume.ai/partner/v1/images/example-image-3" ], "size": "24×48 in", "thickness": "9 mm", "material": "Porcelain", "as_low_as_per_sqft": "5.90", "price_on_request": false, "lead_times": [ { "source": "US_WAREHOUSE", "lead_time_min_days": 14, "lead_time_max_days": 30 }, { "source": "OVERSEAS_DIRECT", "lead_time_min_days": 90, "lead_time_max_days": 120 } ], "suitable_application_areas": [ { "code": "INTERIOR_DRY_FLOOR", "label": "Interior Dry Floor" }, { "code": "INTERIOR_WALL_BACKSPLASH", "label": "Interior Wall / Backsplash" } ], "saving_vs_spec": null } ], "project": { "external_ref": "proj_7781", "status": "FILED", "reason": null, "missing": [], "message": null } } ``` - A product with `price_on_request: true` has no instant quote. The filter above skips those. - If `product=null`, no result has an instant price. Try another photo. - What a search returns depends on the photo. The sandbox has no fixed test results. Carry forward: `SEARCH_ID` and `SKU`. ## 4. Product detail One product's photos, price, specification and shipping options. Reference: [`GET /products/{sku_uuid}`](https://docs.tryflume.ai/api/get-product.md). ```sh curl -sS "https://sandbox-api.tryflume.ai/partner/v1/products/$SKU" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "X-Flume-User-Token: $FLUME_USER_TOKEN" | jq '{name, as_low_as_per_sqft, price_on_request}' ``` **Response (200)** ```json { "sku_uuid": "7f3c2a91-4b0e-4c8d-9a21-1f6b2d5e7c30", "category": "TILE", "name": "Porcelain 24×48″ Matte", "as_low_as_per_sqft": "6.40", "price_on_request": false, "image_urls": [ "https://sandbox-api.tryflume.ai/partner/v1/images/example-image-main", "https://sandbox-api.tryflume.ai/partner/v1/images/example-image-2" ], "at_a_glance": [ { "label": "Size", "value": "24×48″" }, { "label": "Thickness", "value": "9 mm" }, { "label": "Finish", "value": "Matte" } ], "specification": [ { "group": "Slip resistance", "rows": [ { "label": "DCOF", "value": "> 0.42", "test_method": "ANSI A326.3" } ] } ], "shipping_options": [ { "source": "US_WAREHOUSE", "lead_time_min_days": 14, "lead_time_max_days": 30, "ships_from_country": "US", "minimum_order_sqft": 1000 }, { "source": "OVERSEAS_DIRECT", "lead_time_min_days": 90, "lead_time_max_days": 120, "ships_from_country": "ES", "minimum_order_sqft": 5000 } ], "suitable_application_areas": [ { "code": "INTERIOR_DRY_FLOOR", "label": "Interior Dry Floor" } ] } ``` Carry forward: nothing new. ## 5. Instant quote The delivered price for a quantity and a ZIP code: here 6,000 square feet to ZIP code 22102. `Idempotency-Key` is required. Reference: [`POST /request-instant-quote`](https://docs.tryflume.ai/api/request-instant-quote.md). ```sh QUOTE=$(curl -sS -X POST "https://sandbox-api.tryflume.ai/partner/v1/request-instant-quote" \ --max-time 75 \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "X-Flume-User-Token: $FLUME_USER_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d "{\"sku_uuid\": \"$SKU\", \"quantity_sqft\": 6000, \"destination_zip\": \"22102\", \"search_session_uuid\": \"$SEARCH_ID\"}") echo "$QUOTE" | jq '{status, failure_reason, quote_uuid}' QUOTE_ID=$(echo "$QUOTE" | jq -r .quote_uuid) ``` **Response (200)** ```json { "status": "READY", "failure_reason": null, "quote_uuid": "9c1e4b7a-2f3d-4e5b-8a6c-7d8e9f0a1b2c", "quote": { "quote_uuid": "9c1e4b7a-2f3d-4e5b-8a6c-7d8e9f0a1b2c", "quote_number": "Q-7KD2-M4PA", "sku_uuid": "7f3c2a91-4b0e-4c8d-9a21-1f6b2d5e7c30", "destination_zip": "22102", "currency": "USD", "created_at": "2026-10-16T09:14:22Z", "valid_until": "2026-10-23T09:14:22Z", "options": [ { "source": "OVERSEAS_DIRECT", "lead_time_min_days": 90, "lead_time_max_days": 120, "ships_from_country": "ES", "quantity_sqft": "6000.00", "price_per_sqft": "6.40", "subtotal": "38400.00", "sales_tax": "3168.00", "total": "41568.00" }, { "source": "US_WAREHOUSE", "lead_time_min_days": 14, "lead_time_max_days": 30, "ships_from_country": "US", "quantity_sqft": "6000.00", "price_per_sqft": "8.10", "subtotal": "48600.00", "sales_tax": "4009.50", "total": "52609.50" } ] } } ``` - `status` is `READY`: `quote.options` holds one price per shipping option, cheapest first. - `status` is `FAILED`: read `failure_reason`. See [Get an instant quote](https://docs.tryflume.ai/guides/get-an-instant-quote.md). - The call can hold for up to 60 seconds. Keep the read timeout at 75 seconds. - The quote can still be pricing when the call returns: `status` is `FAILED` and `failure_reason` is `TEMPORARILY_UNAVAILABLE`. Read the result with [`GET /quotes/{quote_uuid}`](https://docs.tryflume.ai/api/get-quote.md): ```sh curl -sS "https://sandbox-api.tryflume.ai/partner/v1/quotes/$QUOTE_ID" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "X-Flume-User-Token: $FLUME_USER_TOKEN" | jq '{status, failure_reason}' ``` Carry forward: nothing. The quote is in the user's Flume account. ## 6. Open Flume, signed in A one-time link into the user's Flume account, where every search and quote you made for them is waiting. Reference: [`POST /deep-links`](https://docs.tryflume.ai/api/create-deep-link.md). ```sh curl -sS -X POST "https://sandbox-api.tryflume.ai/partner/v1/deep-links" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "X-Flume-User-Token: $FLUME_USER_TOKEN" \ -H "Content-Type: application/json" \ -d "{\"search_session_uuid\": \"$SEARCH_ID\", \"sku_uuid\": \"$SKU\"}" | jq ``` **Response (201)** ```json { "url": "https://app.tryflume.ai/?utm_source=acme&utm_medium=partner#/l/dlk_q3Vh8yT…", "expires_at": "2026-10-23T09:14:22Z" } ``` Create the link when the user clicks, and open it at once. It works once. See [Open Flume signed in](https://docs.tryflume.ai/guides/open-flume-signed-in.md). ## It worked You verified a user, searched by photo, read a product, priced it and made a link into Flume: the six steps of the Partner API. ## The whole flow as one script The same six steps, in one file. It reads `FLUME_PARTNER_KEY` and `EMAIL`, and asks for the code. ```sh #!/usr/bin/env bash set -euo pipefail API="https://sandbox-api.tryflume.ai/partner/v1" KEY=(-H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY") JSON=(-H "Content-Type: application/json") USER_REF="${USER_REF:-quickstart-user-1}" # 1. Verify a user: send the code curl -sS -X POST "$API/users/trigger-verification" "${KEY[@]}" "${JSON[@]}" \ -d "{\"external_ref\": \"$USER_REF\", \"email\": \"$EMAIL\", \"method\": \"email_code\"}" > /dev/null read -r -p "Code from the email: " CODE # 2. Verify a user: confirm the code TOKEN=$(curl -sS -X POST "$API/users/confirm-verification" "${KEY[@]}" "${JSON[@]}" \ -d "{\"email\": \"$EMAIL\", \"code\": \"$CODE\"}" | jq -r .access_token) USER=(-H "X-Flume-User-Token: $TOKEN") # 3. Search by photo SEARCH=$(curl -sS -X POST "$API/search" "${KEY[@]}" "${USER[@]}" \ -F "image=@tile.jpg" -F 'payload={"category": "TILE"}') SEARCH_ID=$(echo "$SEARCH" | jq -r .search_session_uuid) SKU=$(echo "$SEARCH" | jq -r '[.strong_matches[], .other_similar_options[]] | map(select(.price_on_request == false)) | .[0].sku_uuid') # 4. Product detail curl -sS "$API/products/$SKU" "${KEY[@]}" "${USER[@]}" | jq -r .name # 5. Instant quote curl -sS -X POST "$API/request-instant-quote" --max-time 75 \ "${KEY[@]}" "${USER[@]}" "${JSON[@]}" -H "Idempotency-Key: $(uuidgen)" \ -d "{\"sku_uuid\": \"$SKU\", \"quantity_sqft\": 6000, \"destination_zip\": \"22102\", \"search_session_uuid\": \"$SEARCH_ID\"}" | jq '{status, failure_reason, quote_uuid}' # 6. Open Flume, signed in curl -sS -X POST "$API/deep-links" "${KEY[@]}" "${USER[@]}" "${JSON[@]}" \ -d "{\"search_session_uuid\": \"$SEARCH_ID\", \"sku_uuid\": \"$SKU\"}" | jq -r .url ``` ## Next steps - [Verify a user](https://docs.tryflume.ai/guides/verify-a-user.md), [Find products by photo](https://docs.tryflume.ai/guides/find-products-by-photo.md), [Get an instant quote](https://docs.tryflume.ai/guides/get-an-instant-quote.md), [Open Flume signed in](https://docs.tryflume.ai/guides/open-flume-signed-in.md): each step in depth. - [Rotate keys](https://docs.tryflume.ai/guides/rotate-keys.md) and [Handle errors](https://docs.tryflume.ai/guides/handle-errors.md). - [API reference](https://docs.tryflume.ai/api.md): every endpoint, field and error. - Want Flume to draw the screens instead? See the [iframe quickstart](https://docs.tryflume.ai/quickstart/embed.md). It is the faster way to integrate. --- # How the iframe works Source: https://docs.tryflume.ai/embed > The Flume iframe runs the whole find, price and save flow inside your page. Your backend starts a session, and your page exchanges six messages with it. The Flume iframe is a page Flume hosts and you frame. It runs the whole flow: verify the user, search by photo, get an instant quote, save. Your backend starts it. Your page listens to it. ## Who does what? | Actor | Responsibility | |---|---| | Your backend | Starts a session. Exchanges the connect code. Stores the user access token. Holds the partner key | | Your page | Frames the iframe. Checks and handles its messages. Writes the saved item | | Flume iframe | Draws every screen. Calls the Flume API with its own short-lived credential | | Flume API | Verifies users, searches, prices, builds the save payload | Your partner key and the user access token never reach the browser. ## What is the lifecycle? **Diagram: The whole flow, from a click to a saved item.** One open of the iframe, end to end. Lighter steps happen only the first time a user opens it. ```mermaid sequenceDiagram participant iframe as Flume iframe participant page as Your page participant backend as Your backend participant flume as Flume servers page->>backend: open item backend->>flume: POST /start-iframe-session flume->>backend: iframe_url, session_uuid backend->>page: iframe_url, session_uuid page->>iframe: iframe.src = iframe_url iframe-->>page: flume:iframe_ready iframe-->>page: flume:user_connected (not every time) page->>backend: connect_code (not every time) backend->>flume: POST /users/exchange-connect-code (not every time) iframe-->>page: flume:save_item page-->>iframe: flume:item_saved iframe-->>page: flume:close_iframe ``` 1. The user clicks your button. Your page asks your backend to start a session. 2. Your backend calls POST /start-iframe-session with the user, project and item. 3. Flume returns iframe_url and session_uuid. 4. Your backend passes both to your page. 5. Your page starts the ready timer, adds the message listener, then sets the iframe src. 6. The iframe posts flume:iframe_ready. Your page clears the timer. 7. First time only: the user verifies their email, and the iframe posts flume:user_connected with a connect code. 8. First time only: your page sends the connect code to your backend. 9. First time only: your backend exchanges the code for the user access token and stores it. 10. The user saves a product. The iframe posts flume:save_item with the save payload. 11. Your page writes its record and replies flume:item_saved, or flume:item_save_failed. 12. The iframe posts flume:close_iframe. Your page removes the iframe. ## What is a session? An **iframe session** is one user working on one item of one project. Your backend starts it with [`POST /start-iframe-session`](https://docs.tryflume.ai/embed/server-calls.md#start-a-session). Flume keeps its state on the server. | You send | Required fields | Purpose | |---|---|---| | `user` | `external_ref`, `email` | Who is working | | `project` | `external_ref` | Where the result is filed in the user's Flume account | | `item` | `external_id` | What the user wants a product for | | `host_origin` | Yes | The exact origin of the page that frames the iframe | | You get back | Use | |---|---| | `session_uuid` | The session's id. Filter messages by it. Log it | | `iframe_url` | Set it as the iframe `src` at once. It holds the **launch code** | The launch code works once, for 120 seconds. Never store, rebuild or reuse `iframe_url`. ## When is a session reused? Flume reuses a session when the user, the project and the item are the same. - The item is the same when its `external_id`, `name`, `item_type`, `labels`, photo links and room names are unchanged. - A change to one of those starts a new session. - Quantity, unit, ZIP code and unit price are not compared. A reopened session restores the first of these that exists: 1. A save that is still waiting for your page's reply. 2. The instant quote. 3. The search, at its last step. 4. The category detection. ## When does a session end? | Event | What happens | |---|---| | The user closes the iframe | The session stays. A reopen resumes it | | 12 hours pass since the session's first open | The iframe posts `flume:close_iframe` with reason `expired`. Start a session again | | You revoke the user's access token | The session ends at its next call | | The user's access token is replaced | The session ends at its next call | | 30 days pass with no use | The session is dropped. The next open starts fresh | - Reopening the same item does not restart the 12 hours. - Inside those hours the iframe renews its own credential. You do nothing. ## What must you build? | Piece | Where | |---|---| | A call to start a session | Backend | | A call to exchange a connect code | Backend | | A handler that writes a saved item | Backend or page | | The iframe tag and the [host snippet](https://docs.tryflume.ai/embed/host-page.md#the-host-snippet) | Page | The [full example](https://docs.tryflume.ai/embed/example.md) has all four in three small files. ## What must you store? **Diagram: What you store, and what you never store.** Two things are worth keeping. Everything else is used once and dropped. ```mermaid flowchart LR s-exchange["Exchange response / POST /users/exchange-connect-code"] s-save["flume:save_item / the save payload"] s-start["Session response / POST /start-iframe-session"] s-connected["flume:user_connected"] k-token["User access token / per external_ref, encrypted"] k-item["Your saved item / keyed by save_uuid: your fields, / photo bytes, product_url, two ids"] n-url["iframe_url / set as the iframe src at once"] n-code["connect_code / exchange at once"] s-exchange -->|"access_token"| k-token s-save -->|"mapped fields"| k-item s-start -->|"use once"| n-url s-connected -->|"use once"| n-code ``` 1. From the exchange response, store the user access token, per external_ref, encrypted, on your server. 2. From flume:save_item, store your own record keyed by save_uuid: the fields you map, the photo bytes, product_url, and the two ids an Open in Flume link needs (match.search_session_uuid and product.sku_uuid). 3. Never store iframe_url or its launch code. Set it as the iframe src at once. 4. Never store a connect code. Exchange it at once. 5. Also never store image links or an Open in Flume link: both expire. Log session_uuid with your item id and user id. | What | Why | |---|---| | The user access token, per user, on your server | Send it on the next open so the user skips the email step | | A stable `external_ref` per user | Flume finds the user's earlier work by it | | A stable `project.external_ref` and `item.external_id` | A reopen of the same item resumes where the user was | | `session_uuid`, with your item id and user id | Flume support asks for it | | `save_uuid` with your own record | So a retried save writes nothing twice | | The mapped payload and copies of the image bytes | Image links expire | | `product_url` | The durable link back to the product in Flume | ## What does the iframe not do? - It does not resize itself. You size the element. See [Set up your page](https://docs.tryflume.ai/embed/host-page.md#sizing). - It does not send an error message. See [Timeouts and expiry](https://docs.tryflume.ai/embed/timeouts.md) for how failures show. - It does not navigate your page. The iframe tag grants no top navigation. - It does not call your backend. All it sends you are the [messages](https://docs.tryflume.ai/embed/messages.md). - It has no theming options you can set from your page. - There is no npm package for the host snippet. Copy the file from [Set up your page](https://docs.tryflume.ai/embed/host-page.md). ## Next - [Quickstart](https://docs.tryflume.ai/quickstart/embed.md) - [Set up your page](https://docs.tryflume.ai/embed/host-page.md) - [Iframe messages](https://docs.tryflume.ai/embed/messages.md) --- # Server calls Source: https://docs.tryflume.ai/embed/server-calls > Every call your backend makes to Flume for the iframe: start a session, exchange a connect code, fetch an image, and three optional calls. With fields and errors. Your backend makes two calls for every iframe integration, and a few more if you want what they offer. Your page never calls Flume: only your server does. | Call | Needed | When | |---|---|---| | [`POST /start-iframe-session`](#start-a-session) | Required | The user clicks your button | | [`POST /users/exchange-connect-code`](#exchange-a-connect-code) | Required | Your page receives `flume:user_connected` | | [`GET` an image link](#fetch-an-image) | Required if you keep photos | Your save handler runs | | [`POST /deep-links`](#create-a-link-that-opens-flume) | Optional | The user clicks "Open in Flume" on a saved item | | [`DELETE /users/{external_ref}/token`](#revoke-a-users-access-token) | Optional | A user leaves, or a token may have leaked | | [`GET /keys`, `POST /keys`, `DELETE /keys/{key_id}`](#rotate-your-key) | Key rotation | On your rotation schedule, or after a leak | ## Rules for every call - Base URL: `https://sandbox-api.tryflume.ai/partner/v1` in the sandbox. See [Environments](https://docs.tryflume.ai/concepts/environments.md). - Send your partner key in `X-Flume-Partner-Key`. Read it from configuration, never from source code. - Bodies and responses are JSON. Times are UTC. An unknown field in a request returns `422`. - Every response carries `X-Request-Id`. Log it. - An error has one shape. See [Errors](https://docs.tryflume.ai/api/errors.md). - Samples read `FLUME_PARTNER_KEY` and `FLUME_USER_TOKEN` from the environment. Ids in samples are examples. - Calls from a browser are blocked. See [Keys and tokens](https://docs.tryflume.ai/concepts/authentication.md). ## Start a session **Diagram: Start a session and mount the iframe.** From the click to a loaded iframe. A user access token, when you hold one, skips the email step. ```mermaid sequenceDiagram participant user as User participant iframe as Flume iframe participant page as Your page participant backend as Your backend participant flume as Flume servers user->>page: clicks your button page->>backend: your route backend->>flume: POST /start-iframe-session flume->>backend: iframe_url, session_uuid backend->>page: iframe_url, session_uuid Note over page: ready timer, 15 s page->>iframe: iframe.src iframe->>flume: launch code, once iframe-->>page: flume:iframe_ready ``` 1. The user clicks your button for one item. 2. Your page asks your backend to start a session. 3. Your backend calls POST /start-iframe-session with the user, project, item and host_origin. It sends the stored user access token when it holds one. 4. Flume returns iframe_url and session_uuid. 5. Your backend returns only those two values to your page. 6. Your page starts a ready timer of 15 seconds and adds the message listener. 7. Your page sets iframe_url as the iframe src, at once. 8. The iframe spends the launch code with Flume. It works once, within 120 seconds. 9. The iframe posts flume:iframe_ready. Your page clears the timer and hides its spinner. The iframe also posts it when it could not start: it then shows its own error, and its Try again posts flume:close_iframe with reason expired. `POST /start-iframe-session`. Call it each time a user opens Flume for an item. It returns the URL you set as the iframe `src`. - Send the user's stored access token in `X-Flume-User-Token` whenever you hold one. The iframe then opens without the email step. - Opening the same user, project and item again continues the same work. You still get a new `iframe_url` each time. - Use `iframe_url` at once. Its launch code works once, within 120 seconds. Never store, log or edit it. - Send what you hold about the item. `name`, `quantity`, `uom`, `image_urls` and `project.destination_zip` save the user typing. **curl** ```sh curl -X POST "https://sandbox-api.tryflume.ai/partner/v1/start-iframe-session" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "X-Flume-User-Token: $FLUME_USER_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "user": { "external_ref": "u_8812", "email": "sarah.chen@example.com", "first_name": "Sarah", "last_name": "Chen" }, "project": { "external_ref": "proj_7781", "name": "Harbor Lofts - L2", "city": "McLean", "state": "VA", "destination_zip": "22102" }, "item": { "external_id": "48213", "label": "T-1", "name": "Calacatta look porcelain 24 x 48", "item_type": "Tile", "quantity": 6000, "uom": "Sq. Ft." }, "host_origin": "https://app.acme.example" }' ``` Leave the `X-Flume-User-Token` header out when you hold no token for the user. Response `201`: ```json { "session_uuid": "c1f0a6de-3b52-4d8e-9c47-2f8a1b6d0e93", "iframe_url": "https://?partner=acme#launch_code=launch_example&session=c1f0a6de-3b52-4d8e-9c47-2f8a1b6d0e93" } ``` **Headers** | Header | Required | Notes | |---|---|---| | `X-Flume-Partner-Key` | Yes | Your partner key. | | `X-Flume-User-Token` | No | The user access token you hold for this user. Send it whenever you hold one: the iframe then opens without the email step. | | `Content-Type` | Yes | `application/json`. | **Request fields** | Field | Type | Required | Notes | |---|---|---|---| | `user` | object | Yes | Who is working. | | `user.external_ref` | string | Yes | Your permanent id for the user. Never reuse one. 1 to 128 characters. | | `user.email` | string | Yes | The user's email. Flume sends the verification code there. | | `user.first_name` | string | No | Up to 128 characters. | | `user.last_name` | string | No | Up to 128 characters. | | `user.phone` | string | No | A US number in any common format. | | `user.org` | object | No | The user's company. | | `user.org.external_ref` | string | No | Your id for the company. | | `user.org.name` | string | No | Up to 255 characters. | | `user.org.website_domain` | string | No | For example `acme.example`. | | `user.user_type` | string | No | The kind of user: `DEVELOPER`, `GC`, `SUBCONTRACTOR`, `ARCHITECT_DESIGNER` or `OTHER`. | | `project` | object | Yes | Where the result is filed in the user's Flume account. | | `project.external_ref` | string | Yes | Your permanent id for the project. 1 to 128 characters. | | `project.name` | string | No | Up to 255 characters. | | `project.project_type` | string | No | `RESIDENTIAL`, `MULTIFAMILY`, `COMMERCIAL`, `HOSPITALITY`, `HEALTHCARE`, `EDUCATION`, `RETAIL` or `OTHER`. | | `project.city` | string | No | Up to 128 characters. | | `project.state` | string | No | The state, for example `TX`. | | `project.destination_zip` | string | No | The delivery ZIP code. The iframe uses it to price. Without it, the user types one. | | `project.estimated_area_sqft` | integer | No | The project's total area in square feet. | | `project.unit_count` | integer | No | Homes, units or rooms. | | `project.target_install_date` | string | No | A date, `YYYY-MM-DD`. | | `project.budget` | object | No | The project's budget per unit. | | `project.budget.amount` | string | Yes | The budget, as a decimal string. | | `project.budget.currency` | string | Yes | A 3-letter currency code, for example `USD`. | | `project.budget.unit` | string | No | The unit the amount is per. Match the quantity's unit. | | `project.start_date` | string | No | A date, `YYYY-MM-DD`. | | `project.attributes` | object | No | Anything else you hold. Flume stores it and does not act on it. | | `item` | object | Yes | What the user wants a product for. | | `item.external_id` | string | Yes | Your id for the item. 1 to 128 characters. It comes back in the save payload. | | `item.label` | string | No | Your short label for the item, for example `T-1`. | | `item.name` | string | No | The item's name. The iframe shows it and searches with it. | | `item.item_type` | string | No | Your own word for the kind of item, for example `Tile`. | | `item.description` | string | No | Free text about the item. | | `item.labels` | array | No | Label and value pairs you hold for the item. Up to 100. | | `item.labels[].label` | string | Yes | The label, for example `Finish`. | | `item.labels[].value` | string | No | Its value, for example `Matte`. | | `item.rooms` | array | No | Rooms the item is used in. Up to 100. | | `item.rooms[].name` | string | Yes | The room's name. | | `item.rooms[].quantity` | number | No | The quantity for that room. | | `item.image_urls` | array of strings | No | `https` links to photos of the item, on an image host Flume has set for you. Up to 20. Leave it out until Flume has set your image host. | | `item.document_urls` | array of strings | No | `https` links to documents for the item. Up to 20. | | `item.quantity` | number | No | How much the user needs. The iframe uses it to price. | | `item.uom` | string | No | The unit of `quantity`, for example `Sq. Ft.`. | | `item.unit_price` | object | No | The unit price of the product the item names today. The iframe uses it only to show a saving on each result. | | `item.unit_price.amount` | string | Yes | The price, as a decimal string. | | `item.unit_price.currency` | string | Yes | A 3-letter currency code, for example `USD`. | | `item.unit_price.unit` | string | No | The unit the amount is per. Match the quantity's unit. | | `item.budget` | object | No | The item's budget per unit. | | `item.budget.amount` | string | Yes | The budget, as a decimal string. | | `item.budget.currency` | string | Yes | A 3-letter currency code, for example `USD`. | | `item.budget.unit` | string | No | The unit the amount is per. Match the quantity's unit. | | `item.need_by_date` | string | No | A date, `YYYY-MM-DD`. | | `item.attributes` | object | No | Anything else you hold. Flume stores it and does not act on it. | | `host_origin` | string | Yes | The exact origin of the page that frames the iframe, for example `https://app.acme.example`. It must be one of your allowed origins. | **Response fields** | Field | Type | Notes | |---|---|---| | `session_uuid` | string (UUID) | The session's id. Every message from the iframe carries it. Log it. | | `iframe_url` | string | Set it as the iframe `src` at once. It holds a launch code that works once. | **Errors** | Status | Code | When | Do this | |---|---|---|---| | 401 | `PARTNER_KEY_INVALID` | The partner key is missing, unknown, revoked or expired. | Check the key and the environment it belongs to. | | 401 | `USER_TOKEN_INVALID` | The user access token you sent is unknown, replaced or revoked. | Delete the stored token. Start the session again without it. The user verifies again in the iframe. | | 422 | `VALIDATION_FAILED` | A field is missing or unusable. `field` names it: `host_origin` when the origin is not an allowed origin, `item.image_urls.` when a photo link is not `https` on an image host Flume has set, `user.external_ref` when the token you sent belongs to another user. | Fix the field. Ask Flume to add an origin or an image host. Send each user their own token. | ## Exchange a connect code `POST /users/exchange-connect-code`. Call it when your page receives `flume:user_connected`. It turns the single-use `connect_code` into the user's access token. - Call it at once. The code works once, within 120 seconds. Never log it. - Check that `external_ref` is the user signed in to your product. If it is not, store nothing. - Store `access_token` on your server, over any token you held for that user. The old one no longer works. - Send the stored token on every later `POST /start-iframe-session` for that user. **curl** ```sh curl -X POST "https://sandbox-api.tryflume.ai/partner/v1/users/exchange-connect-code" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "Content-Type: application/json" \ -d '{ "connect_code": "connect_example_code_0000" }' ``` Response `200`: ```json { "external_ref": "u_8812", "email": "sarah.chen@example.com", "access_token": "ut_dev_acme_example_token", "token_type": "user_token", "verified_at": "2026-10-16T09:02:11Z", "account": "LINKED" } ``` **Headers** | Header | Required | Notes | |---|---|---| | `X-Flume-Partner-Key` | Yes | Your partner key. | | `Content-Type` | Yes | `application/json`. | **Request fields** | Field | Type | Required | Notes | |---|---|---|---| | `connect_code` | string | Yes | The `connect_code` from the `flume:user_connected` message. | **Response fields** | Field | Type | Notes | |---|---|---| | `external_ref` | string | Your id for the user the token belongs to. Check that it is the user signed in to your product. | | `email` | string | The email the user verified. | | `access_token` | string | The user access token. Shown once, in this response. Store it on your server. | | `token_type` | string | Always `user_token`. | | `verified_at` | string | UTC time of the verification. | | `account` | string | `CREATED`: Flume opened a Flume account for the user. `LINKED`: the email already had one. | **Errors** | Status | Code | When | Do this | |---|---|---|---| | 401 | `PARTNER_KEY_INVALID` | The partner key is missing, unknown, revoked or expired. | Check the key and the environment it belongs to. | | 401 | `CONNECT_CODE_INVALID` | The connect code is used, expired, unknown or another partner's, or the user's verification no longer holds. | Nothing to store. The user verifies again on the next open. | | 422 | `VALIDATION_FAILED` | A field is missing, unknown or has an unusable value. `field` names it. | Fix the field and send again. | ## Fetch an image The save payload holds image links under `product.gallery[].urls`. They expire after about 24 hours. To keep a photo, your server fetches the bytes when it handles the save. - A plain `GET` on the link. No header and no key: the link itself is the permission. - `urls` is keyed by the longest edge in pixels, as a string. The sizes differ per image, up to `960`. Take the largest key at or under the size you show, and never assume a key exists. - The response is the image bytes, with its `Content-Type`. - Fetch from your server, store the bytes, and serve your own copy. - In the sandbox a link can stop working earlier. Treat a failed fetch as "no photo", not as a failed save. ```sh curl -sS -o photo.jpg "$IMAGE_LINK" ``` **Errors** | Status | Code | When | Do this | |---|---|---|---| | 404 | `NOT_FOUND` | The image link expired, or the image is gone. | Nothing to retry. Keep the record without that photo. | ## Create a link that opens Flume **Diagram: Open in Flume, for a saved item.** Optional. A one-time link, created on the click and never stored. ```mermaid sequenceDiagram participant user as User participant page as Your page participant backend as Your backend participant flume as Flume servers user->>page: clicks Open in Flume Note over page: open a blank tab page->>backend: your route backend->>flume: POST /deep-links flume->>backend: url backend->>page: url page->>user: tab goes to the url ``` 1. The user clicks "Open in Flume" on a saved item. 2. Your page opens a blank tab in the click handler, so the browser does not block it. 3. Your page asks your backend for a link. 4. Your backend calls POST /deep-links with the user access token, and the search_session_uuid and sku_uuid it kept from the save payload. 5. Flume returns a link that works once, within 7 days. 6. Your backend returns the link. It stores it nowhere. 7. Your page points the tab at the link. Flume opens with the user signed in. With no token, link to product_url instead. `POST /deep-links`. Optional. It returns a one-time link that opens the saved product in the Flume app, with the user already signed in. Use it for an "Open in Flume" button on a saved item. - Create the link when the user clicks, and open it at once. It works once, within 7 days. Never store it. - Take `search_session_uuid` from the save payload's `match.search_session_uuid`, and `sku_uuid` from `product.sku_uuid`. Store both with your record at save time. - Send `target: "product"` for a saved item. - It needs the user's access token. If you hold none, link to the save payload's `product_url` instead. That link does not expire, and the user signs in to Flume themselves. - Open a blank tab in the click handler, then set its location when your server answers. Otherwise the browser blocks the popup. **curl** ```sh curl -X POST "https://sandbox-api.tryflume.ai/partner/v1/deep-links" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "X-Flume-User-Token: $FLUME_USER_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "search_session_uuid": "5f0c1d2e-8a7b-4c3d-9e1f-2a3b4c5d6e7f", "sku_uuid": "7f3c2a91-4b0e-4c8d-9a21-1f6b2d5e7c30", "target": "product" }' ``` Response `201`: ```json { "url": "https:///", "expires_at": "2026-11-18T10:00:00Z" } ``` **Headers** | Header | Required | Notes | |---|---|---| | `X-Flume-Partner-Key` | Yes | Your partner key. | | `X-Flume-User-Token` | Yes | The user access token of the user who clicked. | | `Idempotency-Key` | No | One value per click, 1 to 255 characters. A retry with the same value does not return the link again: `url` is `null`. | | `Content-Type` | Yes | `application/json`. | **Request fields** | Field | Type | Required | Notes | |---|---|---|---| | `search_session_uuid` | string (UUID) | Yes | The search that found the product. Take it from the save payload: `match.search_session_uuid`. | | `sku_uuid` | string (UUID) | No | The product to open. Take it from the save payload: `product.sku_uuid`. | | `quote_uuid` | string (UUID) | No | An instant quote to open, with `sku_uuid`. | | `target` | string | No | What the link opens: `product`, `search` or `quote`. Send `product` for a saved item. | | `utm_content` | string | No | A name for the button that asked, for your own reporting. Up to 64 characters. | | `redirect_uri` | string | No | Where Flume sends the user back to. `https`, on a redirect host Flume has set for you. | **Response fields** | Field | Type | Notes | |---|---|---| | `url` | string or null | The link. Open it at once. It works once. `null` on a retry with the same `Idempotency-Key`. | | `expires_at` | string | UTC time the link stops working. | **Errors** | Status | Code | When | Do this | |---|---|---|---| | 401 | `PARTNER_KEY_INVALID` | The partner key is missing, unknown, revoked or expired. | Check the key and the environment it belongs to. | | 401 | `USER_TOKEN_INVALID` | The user access token you sent is unknown, replaced or revoked. | Delete the stored token. Start the session again without it. The user verifies again in the iframe. | | 403 | `USER_NOT_CONNECTED` | The call carried no user access token. | Send the stored token. If you hold none, the user opens the iframe and verifies first. | | 404 | `SESSION_NOT_FOUND` | The search is not this user's. | Use the ids from this user's own save payload. | | 409 | `IDEMPOTENCY_IN_PROGRESS` | A call with the same `Idempotency-Key` is still running. | Wait a moment, then retry. | | 422 | `SKU_NOT_IN_SEARCH` | The product was not a result of that search. | Send the `search_session_uuid` and `sku_uuid` from the same save payload. | | 422 | `VALIDATION_FAILED` | A field is missing, unknown or has an unusable value. `field` names it. | Fix the field and send again. | | 429 | `RATE_LIMITED` | Your daily limit of links is used up. | Wait for the number of seconds in `Retry-After`. | ## Revoke a user's access token `DELETE /users/{external_ref}/token`. Optional. It stops the user's access token at once, and ends every iframe session opened with it. Use it when a user leaves your product or a token may have leaked. Then delete your stored token. The user verifies again on the next open. **curl** ```sh curl -X DELETE "https://sandbox-api.tryflume.ai/partner/v1/users/u_8812/token" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" ``` Response `204`, with no body. **Headers** | Header | Required | Notes | |---|---|---| | `X-Flume-Partner-Key` | Yes | Your partner key. | **Errors** | Status | Code | When | Do this | |---|---|---|---| | 401 | `PARTNER_KEY_INVALID` | The partner key is missing, unknown, revoked or expired. | Check the key and the environment it belongs to. | | 404 | `NOT_FOUND` | You have no user with that `external_ref`. | Nothing to revoke. Delete your stored token. | ## Rotate your key Flume issues your first key. After that you rotate it yourself, with three calls. 1. `POST /keys` issues a new key. The key you call with keeps working for `overlap_days`. 2. Store the new `secret`. It is shown once. Deploy it. 3. `GET /keys` lists your keys. Find the old key's `key_id`. 4. `DELETE /keys/{key_id}` revokes the old key at once, when nothing uses it. | Limit | Sandbox | Live | |---|---|---| | Active keys | 5 | 2 | | Keys issued in 24 hours | 10 | 1 | If a key leaks: rotate with `overlap_days: 0`, deploy the new key, and tell [Flume](https://docs.tryflume.ai/support.md). ### Issue a new key **curl** ```sh curl -X POST "https://sandbox-api.tryflume.ai/partner/v1/keys" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "label": "rotation 2026-11", "overlap_days": 7 }' ``` Response `201`: ```json { "key_id": "pk_3f9a1c2e5b7d4a6f8e0c1b2d3a4f5e6d", "prefix": "sk_dev_acme_9d2", "label": "rotation 2026-11", "status": "ACTIVE", "created_at": "2026-11-11T10:00:00Z", "last_used_at": null, "expires_at": null, "secret": "sk_dev_acme_example_key" } ``` **Headers** | Header | Required | Notes | |---|---|---| | `X-Flume-Partner-Key` | Yes | Your partner key. | | `Idempotency-Key` | Yes | A new value for each rotation, for example a UUID. | | `Content-Type` | Yes | `application/json`. | **Request fields** | Field | Type | Required | Notes | |---|---|---|---| | `label` | string | No | A name for the new key. Up to 128 characters. | | `overlap_days` | integer | No | How many days the key you call with keeps working. Default 7, at most 30. | **Response fields** | Field | Type | Notes | |---|---|---| | `key_id` | string | The key's id. Use it to revoke the key. Not a secret. | | `prefix` | string | How the key starts. Safe to log. | | `label` | string or null | The label you gave it. | | `status` | string | `ACTIVE`, `EXPIRING`, `REVOKED` or `EXPIRED`. An `EXPIRING` key was replaced and works until `expires_at`. | | `created_at` | string | UTC time. | | `last_used_at` | string or null | UTC time of the last call made with it. | | `expires_at` | string or null | UTC time the key stops working, when it has one. | | `secret` | string or null | The new key. Shown once, in this response. `null` on a retry with the same `Idempotency-Key`. | **Errors** | Status | Code | When | Do this | |---|---|---|---| | 401 | `PARTNER_KEY_INVALID` | The partner key is missing, unknown, revoked or expired. | Check the key and the environment it belongs to. | | 409 | `KEY_LIMIT_REACHED` | You already have the most active keys allowed. | Revoke a key you no longer use. | | 429 | `KEY_ROTATION_RATE_LIMITED` | A key was issued too recently. The current key keeps working. | Wait for the number of seconds in `Retry-After`. | | 422 | `VALIDATION_FAILED` | A field is missing, unknown or has an unusable value. `field` names it. | Fix the field and send again. | ### List your keys **curl** ```sh curl -X GET "https://sandbox-api.tryflume.ai/partner/v1/keys" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" ``` Response `200`: ```json { "keys": [ { "key_id": "pk_3f9a1c2e5b7d4a6f8e0c1b2d3a4f5e6d", "prefix": "sk_dev_acme_9d2", "label": "rotation 2026-11", "status": "ACTIVE", "created_at": "2026-11-11T10:00:00Z", "last_used_at": null, "expires_at": null } ] } ``` **Headers** | Header | Required | Notes | |---|---|---| | `X-Flume-Partner-Key` | Yes | Your partner key. | **Response fields** | Field | Type | Notes | |---|---|---| | `keys` | array | Your keys, with their status. Never a secret. | | `keys[].key_id` | string | The key's id. Use it to revoke the key. Not a secret. | | `keys[].prefix` | string | How the key starts. Safe to log. | | `keys[].label` | string or null | The label you gave it. | | `keys[].status` | string | `ACTIVE`, `EXPIRING`, `REVOKED` or `EXPIRED`. An `EXPIRING` key was replaced and works until `expires_at`. | | `keys[].created_at` | string | UTC time. | | `keys[].last_used_at` | string or null | UTC time of the last call made with it. | | `keys[].expires_at` | string or null | UTC time the key stops working, when it has one. | **Errors** | Status | Code | When | Do this | |---|---|---|---| | 401 | `PARTNER_KEY_INVALID` | The partner key is missing, unknown, revoked or expired. | Check the key and the environment it belongs to. | ### Revoke a key **curl** ```sh curl -X DELETE "https://sandbox-api.tryflume.ai/partner/v1/keys/pk_3f9a1c2e5b7d4a6f8e0c1b2d3a4f5e6d" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" ``` Response `204`, with no body. **Headers** | Header | Required | Notes | |---|---|---| | `X-Flume-Partner-Key` | Yes | Your partner key. | **Errors** | Status | Code | When | Do this | |---|---|---|---| | 401 | `PARTNER_KEY_INVALID` | The partner key is missing, unknown, revoked or expired. | Check the key and the environment it belongs to. | | 404 | `NOT_FOUND` | You have no key with that `key_id`. | Read the id from the list of keys. | | 409 | `LAST_ACTIVE_KEY` | It is your only working key. | Rotate first, then revoke the old key. | ## What else can your backend call? These are the calls the iframe needs. The same key also opens the rest of the [Partner API](https://docs.tryflume.ai/api.md): search, products, instant quotes and saves from your own backend. You can combine the two, for example the iframe for search and the API to read a saved quote again. --- # Set up your page Source: https://docs.tryflume.ai/embed/host-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 ``` | 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. 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) --- # Iframe messages Source: https://docs.tryflume.ai/embed/messages > The Flume iframe and your page talk with six postMessage types. Four go from the iframe to your page and two go back. This page lists each payload. The iframe and your page talk with `window.postMessage`. There are six message types: four from the iframe to your page, two from your page to the iframe. ## The envelope Every message, in both directions, has this shape. ```json { "version": 1, "type": "flume:iframe_ready", "session_uuid": "c1f0a6de-3b52-4d8e-9c47-2f8a1b6d0e93", "payload": {} } ``` | Field | Type | Notes | |---|---|---| | `version` | number | Always `1` | | `type` | string | One of the six types below | | `session_uuid` | string | The session from `POST /start-iframe-session` | | `payload` | object | Depends on `type` | ## Checks your page must make Drop a message unless all four hold. | Check | Why | |---|---| | `event.origin` equals the iframe's origin | Another frame cannot pose as Flume | | `event.source` is your iframe's `contentWindow` | Another window on that origin cannot pose as this iframe | | `data.version` is `1` | A future version is ignored safely | | `data.session_uuid` is your session's | A stale iframe cannot write to a new item | Ignore any `type` you do not know. The [host snippet](https://docs.tryflume.ai/embed/host-page.md#the-host-snippet) does all of this. ## All messages **Diagram: Every message, and which way it goes.** All six postMessage types between the iframe and your page, in the order one open usually sends them. Four come to your page, two go back. ```mermaid sequenceDiagram participant iframe as Flume iframe participant page as Your page iframe-->>page: flume:iframe_ready iframe-->>page: flume:user_connected (not every time) iframe-->>page: flume:save_item page-->>iframe: flume:item_saved page-->>iframe: flume:item_save_failed (not every time) iframe-->>page: flume:close_iframe ``` 1. The iframe posts flume:iframe_ready once it has painted, with an empty payload. Clear the ready timer and show the iframe. 2. First open only: the iframe posts flume:user_connected with a connect_code. Send it to your backend at once. The iframe expects no reply. 3. The iframe posts flume:save_item with the save payload, which carries save_uuid. It waits 60 seconds for your reply. 4. Your page replies flume:item_saved with the same save_uuid, and item_ref and item_label when you have them. 5. Or your page replies flume:item_save_failed with the same save_uuid and a reason: permission, conflict, storage or error. 6. The iframe posts flume:close_iframe with a reason of done, closed or expired. Remove the iframe. On expired, start a new session. | Type | Direction | Payload | When | |---|---|---|---| | [`flume:iframe_ready`](#flumeiframe_ready) | Iframe to page | Empty | The iframe has loaded | | [`flume:user_connected`](#flumeuser_connected) | Iframe to page | `connect_code` | The user verified their email | | [`flume:save_item`](#flumesave_item) | Iframe to page | The save payload | The user saved a product | | [`flume:close_iframe`](#flumeclose_iframe) | Iframe to page | `reason` | The iframe should be removed | | [`flume:item_saved`](#flumeitem_saved) | Page to iframe | `save_uuid`, your ids | Your write worked | | [`flume:item_save_failed`](#flumeitem_save_failed) | Page to iframe | `save_uuid`, `reason` | Your write failed | ## From the iframe to your page ### flume:iframe_ready The iframe has loaded and drawn its first screen. - Payload: `{}`. - Do this: clear your ready timer, remove your spinner, move focus into the iframe. - It is also sent when the iframe loaded but could not start, for example with a used launch code. The iframe then shows its own error, with a retry and a close button. Your page cannot tell the two cases apart, and does not need to: clear the timer in both. - After a failed start, the iframe's close button sends `flume:close_iframe` with `closed`, and its retry sends `expired`. ### flume:user_connected The user verified their email inside the iframe. | Payload field | Type | Notes | |---|---|---| | `connect_code` | string | Single use. Valid for 120 seconds | ```json { "version": 1, "type": "flume:user_connected", "session_uuid": "c1f0a6de-3b52-4d8e-9c47-2f8a1b6d0e93", "payload": { "connect_code": "connect_example_code_0000" } } ``` - Do this: send `connect_code` to your backend. Call [`POST /users/exchange-connect-code`](https://docs.tryflume.ai/embed/server-calls.md#exchange-a-connect-code). Store the `access_token` over any token you held for the user. - Any token you held for this user stopped working when they verified. See [Users and verification](https://docs.tryflume.ai/concepts/users.md#what-happens-when-a-user-verifies-in-the-iframe). - If you skip the exchange, you hold no working token. The user verifies again on the next open. ### flume:save_item The user confirmed a save. The payload is the full [save payload](https://docs.tryflume.ai/concepts/save-payload.md), one JSON object. | Payload field | Type | Notes | |---|---|---| | `save_uuid` | string (UUID) | Identifies this save. A retry carries the same value | | `schema_version` | string | `"1.0"` | | Other fields | | See [Save payload](https://docs.tryflume.ai/concepts/save-payload.md) | - Do this: write the item into your records, then reply with `flume:item_saved` or `flume:item_save_failed`. - Reply within 60 seconds. Silence counts as a failure. - If the user presses "Try again", you get the same `save_uuid` again. Do not write a second record. ### flume:close_iframe The iframe is done. Remove it from your page. | Payload field | Type | Values | |---|---|---| | `reason` | string | `done`, `expired` or `closed` | | `reason` | Meaning | Do this | |---|---|---| | `done` | The user finished after a save | Remove the iframe. Refresh your data | | `expired` | The session ended, or the user pressed Try again after a failed start | Start a session again and remount | | `closed` | The user pressed X or Escape | Remove the iframe | ```json { "version": 1, "type": "flume:close_iframe", "session_uuid": "c1f0a6de-3b52-4d8e-9c47-2f8a1b6d0e93", "payload": { "reason": "done" } } ``` ## From your page to the iframe Post replies to the iframe's window, with the iframe's origin as the target origin. Never use `"*"`. ```js frame.contentWindow.postMessage( { version: 1, type: "flume:item_saved", session_uuid: sessionUuid, payload }, new URL(iframeUrl).origin, ); ``` The iframe accepts a reply only when all of these hold: - it comes from the page that framed it, - that page's origin is the `host_origin` you sent, - it carries the session's `session_uuid`, - its `save_uuid` is the save the iframe is waiting on. A reply that fails one of these is ignored, and the save waits out its 60 seconds. ### flume:item_saved Your write worked. | Payload field | Type | Required | Notes | |---|---|---|---| | `save_uuid` | string (UUID) | Yes | From the `flume:save_item` you are answering | | `item_ref` | string | No | Your id for the record. Max 128 characters | | `item_label` | string | No | Your label for it. Max 200 characters | ```json { "version": 1, "type": "flume:item_saved", "session_uuid": "c1f0a6de-3b52-4d8e-9c47-2f8a1b6d0e93", "payload": { "save_uuid": "2b9d6f1c-7a34-4e58-b0c2-9d1e3f5a7b6c", "item_ref": "row_9931", "item_label": "T-1.A" } } ``` ### flume:item_save_failed Your write failed. The iframe shows the user a retry. | Payload field | Type | Required | Notes | |---|---|---|---| | `save_uuid` | string (UUID) | Yes | From the `flume:save_item` you are answering | | `reason` | string | Yes | `permission`, `conflict`, `storage` or `error` | | `message` | string | No | Detail for Flume's logs. Cut at 500 characters. Never shown to the user | | `reason` | Use it when | |---|---| | `permission` | The user may not edit that record | | `conflict` | The record changed or is locked | | `storage` | Your write failed | | `error` | Anything else | The payload must be a plain object. A thrown `Error` cannot be sent as it is. The host snippet turns one into a proper payload. ## What does the user see around a save? **Diagram: Every way a save can end.** Three outcomes of one flume:save_item. Two of them show the user the same message, and a retry repeats the same save_uuid. ```mermaid flowchart LR request["flume:save_item / carries save_uuid"] ok["flume:item_saved / your write worked"] failed["flume:item_save_failed / permission, conflict, / storage or error"] silent["No reply / within 60 seconds"] saved["Saved screen / shows item_label"] retry["Could not save / Close, or Try again"] request --> ok request --> failed request --> silent ok --> saved failed --> retry silent --> retry ``` 1. The iframe posts flume:save_item with a save_uuid. 2. Your page replies flume:item_saved: the user sees the saved screen, with your item_label when you send one. 3. Your page replies flume:item_save_failed with a reason of permission, conflict, storage or error: the user sees one message with Close and Try again. The reason is not shown. 4. Your page does not reply within 60 seconds: the user sees the same message. 5. Try again posts flume:save_item again with the same save_uuid. Your write must be safe to repeat. | Case | The user sees | |---|---| | You reply `flume:item_saved` | A saved screen. It shows `item_label` when you send one, as "Saved as ...". Otherwise your item's own label | | You reply `flume:item_save_failed`, with any `reason` | One message: the save to your product failed, with **Close** and **Try again**. `reason` and `message` are never shown | | You do not reply within 60 seconds | The same message | | Your reply arrives after that | A late reply for the same `save_uuid` can still be applied while that save is the current one. Do not rely on it: reply within the time limit | | Flume could not build the payload | An error from Flume. Your page receives no `flume:save_item` | - **Try again** sends `flume:save_item` again with the same `save_uuid`. A slow write of yours may have finished meanwhile, so the write must be safe to repeat. - After a save, the user can press **Done**, which sends `flume:close_iframe` with `done`, or open the product in Flume, which keeps the iframe open. ## Rules the iframe follows - It posts only to the `host_origin` you sent when you started the session. If your page is on another origin, no message reaches it. - It reports each save's result to Flume itself. Your backend makes no call for it. - One exception: when the iframe cannot start, it has no session. It then posts only `flume:iframe_ready` and `flume:close_iframe`, to the page that framed it. ## What is not sent? - No resize message. You size the iframe. - No error message. The iframe shows its own error screen when it cannot start. Your ready timer covers a frame that never loads. - No message per search or per quote. Only a save reaches your page. ## Versioning `version` is `1`. A message with another version, or a type you do not know, must be ignored. --- # Full example Source: https://docs.tryflume.ai/embed/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 Flume iframe example

Item T-1: Calacatta look porcelain 24 x 48

    ``` Save one of these servers. ## 2. Run it 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). --- # Security and CSP Source: https://docs.tryflume.ai/embed/security > The Flume iframe frames only on origins you agreed with Flume. This page covers allowed origins, the headers Flume sets, your CSP and what to trust. The Flume iframe loads only inside pages on origins you agreed with Flume. Your partner key and user access tokens stay on your server. This page lists what Flume enforces, what your page must allow, and what to trust. ## What do you agree with Flume? Flume holds three lists per partner. Flume sets them. You cannot read or change them yourself today. | List | Format | Used for | |---|---|---| | Allowed origins | Exact origins: scheme, host and port, no path, no trailing slash | Which pages may frame the iframe. Checked against `host_origin` on every session start | | Image hosts | Exact host names | Which hosts Flume may fetch `item.image_urls` from | | Redirect hosts | Exact host names | Which hosts a deep link's `redirect_uri` may name | - To add or change an entry, [ask Flume](https://docs.tryflume.ai/support.md). - A change to allowed origins can take up to 60 seconds to apply. - Sandbox and live each have their own lists. - A local origin such as `http://localhost:3000` can be an allowed origin in the sandbox. See [Develop on localhost](https://docs.tryflume.ai/embed/host-page.md#develop-on-localhost). ## What does Flume enforce on the iframe? Flume sends these headers with the iframe document. | Header | Value | Effect | |---|---|---| | `Content-Security-Policy` | `frame-ancestors` followed by your allowed origins | Only your pages can frame it | | `Content-Security-Policy` | `frame-ancestors 'none'` for an unknown or inactive partner | The frame stays blank | | `Cache-Control` | `no-store` | The document is never cached | | `Referrer-Policy` | `no-referrer` | The iframe URL is not leaked in a referrer | | `X-Content-Type-Options` | `nosniff` | The browser does not guess content types | Also: - The iframe runs only when framed. Opened in its own tab, it does not start. - The iframe's own policy allows scripts only from Flume's own hosts and its analytics host, and blocks nested frames. - The launch code travels in the URL fragment, which browsers do not send to a server. - The iframe removes the launch code from its URL as soon as it has read it. ## What must your page's CSP allow? If your page sends a `Content-Security-Policy`, add these. | Directive | Add | Why | |---|---|---| | `frame-src` | The iframe origin | To frame the iframe | | `img-src` | The origin of the image links | Only if your page shows Flume image links directly | | `connect-src` | The origin of the image links | Only if your page fetches image bytes with `fetch` | - The iframe origin is `new URL(iframe_url).origin`. Read it once per environment from a real response, then add it to your policy. - The origin of the image links is the origin of a link in a real payload, for example `new URL(payload.product.gallery[0].urls[key]).origin`. Read it once per environment. - Do not assume either origin equals the host your server calls. In the sandbox they can differ. - Ask Flume before go-live whether those origins are about to change. - If your page sends `Cross-Origin-Embedder-Policy: require-corp`, the browser blocks the iframe. ## What must always hold? Twelve rules. An integration that breaks one is not finished. | # | Rule | Detail | |---|---|---| | 1 | The partner key and every user access token stay on your server. Never in a browser, a client bundle, a log or a commit | [Keys and tokens](https://docs.tryflume.ai/concepts/authentication.md) | | 2 | Only your server calls Flume. A server call cannot be made from a browser | [Server calls](https://docs.tryflume.ai/embed/server-calls.md) | | 3 | Every server call sends `X-Flume-Partner-Key`. `POST /start-iframe-session` also sends `X-Flume-User-Token` whenever you hold a token for the user | [Server calls](https://docs.tryflume.ai/embed/server-calls.md#start-a-session) | | 4 | `iframe_url`, its launch code and the connect code are single use and short lived. Use each at once. Never store, log, rebuild or edit them | [Timeouts and expiry](https://docs.tryflume.ai/embed/timeouts.md) | | 5 | Read the iframe's origin from `iframe_url`. Never hardcode it | [Set up your page](https://docs.tryflume.ai/embed/host-page.md) | | 6 | Drop a message unless its origin, source, `version` and `session_uuid` are right. Ignore types you do not know | [Messages](https://docs.tryflume.ai/embed/messages.md#checks-your-page-must-make) | | 7 | Reply to the iframe with its origin as the target origin, never `"*"` | [Messages](https://docs.tryflume.ai/embed/messages.md#from-your-page-to-the-iframe) | | 8 | Answer every `flume:save_item` within 60 seconds, with `flume:item_saved` or `flume:item_save_failed`, carrying its `save_uuid`. A repeated `save_uuid` writes nothing twice | [Messages](https://docs.tryflume.ai/embed/messages.md#flumesave_item) | | 9 | Exchange every connect code at once. Store the new token over the old one | [Users and verification](https://docs.tryflume.ai/concepts/users.md) | | 10 | Run a ready timer of 15 seconds. When it fires, remove the iframe and show an error with a retry | [Timeouts and expiry](https://docs.tryflume.ai/embed/timeouts.md) | | 11 | On `401` `USER_TOKEN_INVALID`, delete the stored token and start again without it. On `429`, wait for `Retry-After`. Branch on `error.code`, never on `error.message` | [Errors](https://docs.tryflume.ai/api/errors.md) | | 12 | The save payload came through the browser. Render its text as text, and do not treat a saved price as a commitment | [Below](#can-you-trust-the-save-payload) | The [host snippet](https://docs.tryflume.ai/embed/host-page.md#the-host-snippet) keeps rules 5 to 8 and 10 for you when you copy it unchanged. ## What must your page check? Your `message` listener receives every message sent to your window. Make the [four checks](https://docs.tryflume.ai/embed/messages.md#checks-your-page-must-make) on each one, and reply with the iframe's origin as the target, never `"*"`. The [host snippet](https://docs.tryflume.ai/embed/host-page.md#the-host-snippet) does both. ## Can you trust the save payload? The save payload reaches your backend through the user's browser. Treat it like any other browser input. - The four message checks prove the message came from the Flume iframe. They do not stop a user from changing a request to your own backend. - Flume offers no server call to read the payload again. So do not treat a saved price as an order or a commitment. It is what the user saw, valid until `pricing.valid_until`. - Check on your server that the item being written belongs to the signed-in user. - Render every string as text. See [Text rules](https://docs.tryflume.ai/concepts/save-payload.md#text-rules). ## Which user does a token belong to? `POST /users/exchange-connect-code` returns `external_ref`: your own id for the user who verified. 1. Compare it with the user who is signed in to your product. 2. Store the token only when they match. 3. If they differ, discard the token. ## Where do credentials live? **Diagram: Where each secret lives and travels.** Long-lived secrets stay on servers. Only single-use, short-lived codes pass through the browser. ```mermaid flowchart LR page["Your page / checks origin, source, / version, session_uuid"] backend["Your backend / partner key / user access tokens"] iframe["Flume iframe / posts only to host_origin / its own short credential"] flume["Flume servers / allowed origins / per partner"] backend -->|"iframe_url (launch code)"| page page -->|"connect_code"| backend backend -->|"partner key, user access token"| flume flume -->|"iframe_url, access_token"| backend page -.->|"save replies"| iframe iframe -.->|"connect_code, save payload"| page iframe -->|"launch code, once"| flume ``` 1. Your backend holds the partner key and each user access token. It sends them only to Flume, over HTTPS. 2. Flume returns iframe_url, which carries a launch code. The launch code works once, within 120 seconds. 3. Your backend hands iframe_url to your page. Your page sets it as the iframe src and stores it nowhere. 4. The iframe spends the launch code with Flume and then uses its own short-lived credential. 5. When a user verifies, the iframe posts a connect code to your page. It works once, within 120 seconds. 6. Your page passes the connect code to your backend, which exchanges it for the user access token. 7. Your page accepts a message only when its origin, source, version and session_uuid are right. The iframe posts only to the host_origin you sent. | Credential | Where it lives | Reaches the browser | |---|---|---| | Partner key | Your server | Never | | User access token | Your server | Never | | Connect code | Passes through your page once | Yes. Single use, 120 seconds | | Launch code, in `iframe_url` | Passes through your page once | Yes. Single use, 120 seconds | | The iframe's own credential | Inside the iframe | Yes, inside the iframe only. Your page cannot read it | Flume stores every key, token and code as a hash only. ## How does Flume fetch your item photos? When you send `item.image_urls`, Flume's server fetches the first photo. - `https` only. - The host must be on your image host list. - Redirects are not followed. - 10 second timeout, 25 MB limit. - The links must stay reachable for the session's life. An entry that breaks a rule is a `422` `VALIDATION_FAILED` naming `item.image_urls.`. ## What should you treat as untrusted? - Every string in the [save payload](https://docs.tryflume.ai/concepts/save-payload.md). Flume removes control characters and angle brackets only. Quotes and `&` remain. - Render strings as text. Escape them on output. Never build HTML or an HTML attribute from them by joining strings. - Every message, until it passes the four checks above. ## How do you report a security problem? [Contact Flume](https://docs.tryflume.ai/support.md). Send the key prefix and `X-Request-Id` values. Never send a key or a token. --- # Timeouts and expiry Source: https://docs.tryflume.ai/embed/timeouts > Every timer in the Flume iframe integration, in one table. How long each code and session lasts, and what your page does when one runs out. The iframe integration has seven timers. Each has one clear action when it runs out. ## All timers | Timer | Length | Who enforces it | When it runs out | |---|---|---|---| | Launch code, in `iframe_url` | 120 seconds, one use | Flume | The iframe shows "Flume didn't open." with a retry | | Ready timer | 15 seconds | Your page | Remove the iframe. Show an error with a retry | | Connect code | 120 seconds, one use | Flume | `401` `CONNECT_CODE_INVALID`. The user verifies again on the next open | | Save reply | 60 seconds | The iframe | The iframe treats the save as failed | | Instant quote wait in the iframe | About 18 seconds, then polling | The iframe | The iframe keeps the user informed. You do nothing | | Iframe session | 12 hours from the session's first open | Flume | `flume:close_iframe` with reason `expired` | | Idle session state | 30 days | Flume | The next open starts fresh | ## The iframe did not load There are two kinds of failure. They show in different ways. While the iframe opens, show your own way to close it, for example a Close button after a few seconds. The user is never stuck behind a blank frame. **The frame never loads.** No `flume:iframe_ready` arrives, and your ready timer fires. 1. Call `stop()` and remove the iframe. A frame left in place can still finish loading with nobody listening. 2. Show "Flume did not load" with a retry button. 3. On retry, call `POST /start-iframe-session` again. Never reuse the old `iframe_url`. | Cause | Sign | |---|---| | Your page's origin is not an allowed origin | The frame is blank. The browser console shows a `frame-ancestors` error | | Your CSP has no `frame-src` for the iframe origin | The browser console shows a `frame-src` error | | `host_origin` is not this page's exact origin | The iframe shows and works, but no message reaches your page | | `flumeOrigin` is wrong in your snippet | The iframe shows, but your page drops its messages | | The network, or a slow first load | Nothing shows in time. Offer a retry | **The frame loads but cannot start.** The iframe posts `flume:iframe_ready` and shows its own error, "Flume didn't open.", with a retry. Your ready timer does not fire. | Cause | What happens next | |---|---| | `iframe_url` was used late or twice | The user presses "Try again". The iframe posts `flume:close_iframe` with reason `expired`. Start a session again | ## The session expired The iframe posts `flume:close_iframe` with `reason: "expired"` when: - 12 hours have passed since the session's first open, - the user's access token was revoked or replaced, - the user pressed "Try again" after a failed start. Do this: 1. Remove the iframe. 2. Call `POST /start-iframe-session` again with the same user, project and item. 3. Mount the new `iframe_url`. The user lands where they were. See [How the iframe works](https://docs.tryflume.ai/embed.md#when-is-a-session-reused). Reopening the same item does not restart the 12 hours. They run from the session's first open. ## The user access token stopped working `POST /start-iframe-session` returns `401` `USER_TOKEN_INVALID` when the token you sent is unknown, revoked or replaced. 1. Delete the stored token. 2. Call again without `X-Flume-User-Token`. 3. The iframe asks the user to verify. Exchange the new connect code and store the new token. ## A save got no reply If your page does not reply in 60 seconds, the iframe shows a retry. - "Try again" sends `flume:save_item` again with the **same** `save_uuid`. - If your first write did succeed, reply `flume:item_saved` without writing again. - A reply without the right `save_uuid` counts as no reply. ## The connect code expired `POST /users/exchange-connect-code` returns `401` `CONNECT_CODE_INVALID` for a code that is spent or older than 120 seconds. - The user is still verified in the open session. - You hold no working token, so the user verifies again on the next open. - Exchange the code as soon as the message arrives. Do not queue it. ## Image links expired Image links in the save payload last about 24 hours. In the sandbox they can stop working earlier. Copy the bytes when you save. See [Fetch an image](https://docs.tryflume.ai/embed/server-calls.md#fetch-an-image). ## See also - [Limits](https://docs.tryflume.ai/concepts/limits.md#how-long-do-things-last) - [Iframe messages](https://docs.tryflume.ai/embed/messages.md) --- # Verify a user Source: https://docs.tryflume.ai/guides/verify-a-user > Verify a user's email with two API calls, then store the access token Flume returns. You send the token on every later call made for that user. Verify a user in two calls: send a code to their email, then confirm the code. The second call returns the **user access token**. **Diagram: Steps 1 and 2: verify a user through the API.** You build the code screen. Two calls, with the partner key only. The second returns the user access token. ```mermaid sequenceDiagram participant user as User participant page as Your page participant backend as Your backend participant flume as Flume servers page->>backend: email backend->>flume: POST /users/trigger-verification flume->>user: email, 6 digits user->>page: types the code page->>backend: code backend->>flume: POST /users/confirm-verification flume->>backend: access_token Note over backend: store the token ``` 1. Your screen sends the user's email to your backend. 2. Your backend calls POST /users/trigger-verification with external_ref and email. 3. Flume emails the user a 6-digit code, valid for 10 minutes. 4. The user types the code on your screen. 5. Your screen sends the code to your backend. 6. Your backend calls POST /users/confirm-verification with the email and the code. 7. Flume returns the user access token, once. 8. Your backend stores the token against that user and sends it on every call made for them. ## Before you begin - A partner key. See [Authentication and keys](https://docs.tryflume.ai/concepts/authentication.md). - A stable id for the user in your system. You send it as `external_ref`. ## 1. Send the code **curl** ```sh curl -X POST "https://sandbox-api.tryflume.ai/partner/v1/users/trigger-verification" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "Content-Type: application/json" \ -d '{ "external_ref": "u_8812", "email": "sarah.chen@example.com", "first_name": "Sarah", "last_name": "Chen", "org": { "name": "NY Design Co" } }' ``` **Response (202)** ```json { "external_ref": "u_8812", "masked_email": "s···@example.com", "expires_in_s": 600, "resend_after_s": 30 } ``` - Only `external_ref` and `email` are required. - Send the name, phone and firm if you have them. Flume puts them on the user's new Flume account. - `phone` must be a US number. - `X-Flume-Client-IP` is optional. Send the user's IP address in it and Flume also limits codes per IP address. See [Users and verification](https://docs.tryflume.ai/concepts/users.md#what-are-the-rules-for-a-code). Show the user a code box. Use `masked_email` to say where the code went. ## 2. Confirm the code **curl** ```sh curl -X POST "https://sandbox-api.tryflume.ai/partner/v1/users/confirm-verification" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "Content-Type: application/json" \ -d '{ "email": "sarah.chen@example.com", "code": "482913" }' ``` **Response (200)** ```json { "external_ref": "u_8812", "email": "sarah.chen@example.com", "access_token": "ut_dev_acme_example_token", "token_type": "user_token", "verified_at": "2026-10-16T09:02:11Z", "account": "CREATED" } ``` - `access_token` is returned once. Store it encrypted, against the user. - A user has one working token at a time. This one replaces any earlier token. - `account` is `CREATED` for a new Flume account, or `LINKED` for an existing one. - Send `external_ref` in the body only if two of your users share one email. ## 3. Use the token Send it as `X-Flume-User-Token`, with your partner key, on every call made for the user. ```sh curl "https://sandbox-api.tryflume.ai/partner/v1/products/7f3c2a91-4b0e-4c8d-9a21-1f6b2d5e7c30" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "X-Flume-User-Token: $FLUME_USER_TOKEN" ``` ## Handle the common errors | You get | Do this | |---|---| | `422` `VERIFICATION_CODE_INVALID` | Let the user try again. The message says how many tries are left | | `422` `VERIFICATION_TRIES_EXHAUSTED` | Send a new code | | `422` `VERIFICATION_CODE_EXPIRED` | Send a new code | | `429` `VERIFICATION_RATE_LIMITED` | Wait `Retry-After` seconds, then resend | | `502` `VERIFICATION_EMAIL_FAILED` | Retry the send | | `404` `NOT_FOUND` on confirm | Call trigger-verification first | ## Resend a code Call `POST /users/trigger-verification` again after `resend_after_s` seconds. The new code replaces the old one. ## Revoke a token Revoke when a user leaves or a token may have leaked. **curl** ```sh curl -X DELETE "https://sandbox-api.tryflume.ai/partner/v1/users/u_8812/token" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" ``` **Response (204)**: no body. To give the user a new token, verify them again. ## Test it The sandbox has no test code. Use an inbox you can read, and confirm with the code from the real email. ## See also - [Users and verification](https://docs.tryflume.ai/concepts/users.md) - [What happens when a user verifies in the iframe?](https://docs.tryflume.ai/concepts/users.md#what-happens-when-a-user-verifies-in-the-iframe) - [Send a verification code](https://docs.tryflume.ai/api/trigger-verification.md) - [Confirm a verification code](https://docs.tryflume.ai/api/confirm-verification.md) --- # Find products by photo Source: https://docs.tryflume.ai/guides/find-products-by-photo > Search Flume by photo with POST /search. Send an image and a JSON payload, then read strong matches, similar options and each product's detail. Send a photo and a category to `POST /search`. Flume returns products that look like the photo, in two lists: strong matches, then other similar options. **Diagram: Steps 3 to 5: search, product, quote.** Each call carries both headers. The search returns a search_session_uuid that the later calls carry. A quote that is still pricing is polled. ```mermaid sequenceDiagram participant page as Your page participant backend as Your backend participant flume as Flume servers backend->>flume: GET /filters page->>backend: photo, filters backend->>flume: POST /search flume->>backend: results, search_session_uuid backend->>flume: GET /products/sku_uuid backend->>flume: POST /request-instant-quote flume->>backend: READY, or still pricing backend->>flume: GET /quotes/quote_uuid (not every time) backend->>page: prices ``` 1. Your backend calls GET /filters and builds your filter menus from the answer. 2. Your screen sends the photo and the chosen filters to your backend. 3. Your backend calls POST /search with the photo. 4. Flume returns up to 24 results a page and a search_session_uuid. 5. Your backend calls GET /products/{sku_uuid} for the product the user opens. 6. Your backend calls POST /request-instant-quote with the product, a quantity, a ZIP code and the search_session_uuid. It carries an Idempotency-Key. 7. Flume answers within 60 seconds: READY with prices, or FAILED with a reason. 8. Only when the reason is TEMPORARILY_UNAVAILABLE: your backend polls GET /quotes/{quote_uuid} until the quote is READY. 9. Your backend returns the prices to your screen. ## Before you begin - A partner key and a [user access token](https://docs.tryflume.ai/guides/verify-a-user.md). - A photo: JPEG, PNG or WebP, up to 6,000,000 bytes. ## 1. Read the filter options Build your filter menus from this call, not from a hardcoded list. New values then appear without a release. **curl** ```sh curl -X GET "https://sandbox-api.tryflume.ai/partner/v1/filters" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" ``` **Response (200)** ```json { "tile": { "material": [ { "value": "PORCELAIN", "label": "Porcelain" }, { "value": "CERAMIC", "label": "Ceramic" } ], "finish": [ { "value": "MATTE", "label": "Matte", "also_matches": [ "NATURAL", "HONED", "ANTI_SLIP" ] }, { "value": "GLOSSY_POLISHED", "label": "Glossy / Polished", "also_matches": [ "LAPPATO_SEMI_POLISHED" ] } ], "application_area": [ { "value": "INTERIOR_DRY_FLOOR", "label": "Interior Dry Floor", "group": "FLOORS" }, { "value": "SHOWER_WALL", "label": "Shower Wall", "group": "WALLS" } ] }, "flooring": { "wear_layer_mil": [ { "value": 20, "label": "20 mil" }, { "value": 28, "label": "28 mil and up" } ], "installation_method": [ { "value": "CLICK_LOCK", "label": "Click Lock" } ], "finish": [ { "value": "MATTE", "label": "Matte" } ], "rigid_core": [ { "value": "SPC", "label": "SPC" } ] } } ``` - `value` is what you send. `label` is what you show. - `also_matches` lists the similar tile finishes a finish also finds. ## 2. Search Send `multipart/form-data` with two parts: `image` and `payload`. **curl** ```sh curl -X POST "https://sandbox-api.tryflume.ai/partner/v1/search" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "X-Flume-User-Token: $FLUME_USER_TOKEN" \ -F "image=@photo.jpg" \ -F 'payload={"category":"TILE","tile_filters":{"material":["PORCELAIN"],"finish":["MATTE"],"size":{"width":24,"length":48,"unit":"in"}},"project":{"external_ref":"proj_7781","name":"Harbor Lofts - L2","destination_zip":"22102"}}' ``` **Response (200)** ```json { "search_session_uuid": "5f0c1d2e-8a7b-4c3d-9e1f-2a3b4c5d6e7f", "total": 31, "strong_match_count": 9, "other_similar_count": 22, "page": 1, "page_size": 24, "has_more": true, "strong_matches": [ { "sku_uuid": "7f3c2a91-4b0e-4c8d-9a21-1f6b2d5e7c30", "name": "Porcelain 24×48″ Matte", "image_url": "https://sandbox-api.tryflume.ai/partner/v1/images/example-image-main", "more_image_urls": [ "https://sandbox-api.tryflume.ai/partner/v1/images/example-image-2", "https://sandbox-api.tryflume.ai/partner/v1/images/example-image-3" ], "size": "24×48 in", "thickness": "9 mm", "material": "Porcelain", "as_low_as_per_sqft": "6.40", "price_on_request": false, "lead_times": [ { "source": "US_WAREHOUSE", "lead_time_min_days": 14, "lead_time_max_days": 30 }, { "source": "OVERSEAS_DIRECT", "lead_time_min_days": 90, "lead_time_max_days": 120 } ], "suitable_application_areas": [ { "code": "INTERIOR_DRY_FLOOR", "label": "Interior Dry Floor" }, { "code": "INTERIOR_WALL_BACKSPLASH", "label": "Interior Wall / Backsplash" } ], "saving_vs_spec": null } ], "other_similar_options": [ { "sku_uuid": "0b6f9d34-2e71-4c5a-8f10-3d9a7e2b6c41", "name": "Porcelain 24×48″ Natural", "image_url": "https://sandbox-api.tryflume.ai/partner/v1/images/example-image-main", "more_image_urls": [ "https://sandbox-api.tryflume.ai/partner/v1/images/example-image-2", "https://sandbox-api.tryflume.ai/partner/v1/images/example-image-3" ], "size": "24×48 in", "thickness": "9 mm", "material": "Porcelain", "as_low_as_per_sqft": "5.90", "price_on_request": false, "lead_times": [ { "source": "US_WAREHOUSE", "lead_time_min_days": 14, "lead_time_max_days": 30 }, { "source": "OVERSEAS_DIRECT", "lead_time_min_days": 90, "lead_time_max_days": 120 } ], "suitable_application_areas": [ { "code": "INTERIOR_DRY_FLOOR", "label": "Interior Dry Floor" }, { "code": "INTERIOR_WALL_BACKSPLASH", "label": "Interior Wall / Backsplash" } ], "saving_vs_spec": null } ], "project": { "external_ref": "proj_7781", "status": "FILED", "reason": null, "missing": [], "message": null } } ``` | `payload` field | Required | Notes | |---|---|---| | `category` | Yes | `TILE` or `FLOORING` | | `tile_filters` | No | Only with `TILE` | | `flooring_filters` | No | Only with `FLOORING` | | `project` | No | Files the search under a project in the user's Flume account | | `page`, `page_size` | No | Defaults 1 and 24 | | `crop_box` | No | Search on part of the photo: `x`, `y`, `w`, `h` as fractions from 0 to 1 | | `spec_unit_price` | No | Adds `saving_vs_spec` to each result. USD per `SF` only | Filter rules: - A list filter matches any of its values. - Different filters must all hold. - A filter you leave out is no constraint. - The photo ranks the products that pass the filters. ## 3. Read the results - `strong_matches`: products that meet every filter and look closest to the photo. Best first. - `other_similar_options`: close to the photo, with some filters relaxed. They start on the page where the strong matches end. - `total` is at most 50. - `as_low_as_per_sqft` is the lowest delivered price per square foot, before tax. An instant quote can be higher. - `price_on_request: true` means there is no instant quote. [Hand off to sales](https://docs.tryflume.ai/support.md). - Image links expire after about 24 hours, and earlier in the sandbox. Do not store them. Keep `search_session_uuid`. Quotes, saves and deep links for these products need it. ## 4. Get the next page Send the same photo and payload with `page: 2`. > **Each page is a new search:** Every `POST /search` call spends one unit of the daily limit and returns a new `search_session_uuid`. Page 2 included. Use the id from the page that returned the product. ## 5. Read a product **curl** ```sh curl -X GET "https://sandbox-api.tryflume.ai/partner/v1/products/7f3c2a91-4b0e-4c8d-9a21-1f6b2d5e7c30" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "X-Flume-User-Token: $FLUME_USER_TOKEN" ``` **Response (200)** ```json { "sku_uuid": "7f3c2a91-4b0e-4c8d-9a21-1f6b2d5e7c30", "category": "TILE", "name": "Porcelain 24×48″ Matte", "as_low_as_per_sqft": "6.40", "price_on_request": false, "image_urls": [ "https://sandbox-api.tryflume.ai/partner/v1/images/example-image-main", "https://sandbox-api.tryflume.ai/partner/v1/images/example-image-2" ], "at_a_glance": [ { "label": "Size", "value": "24×48″" }, { "label": "Thickness", "value": "9 mm" }, { "label": "Finish", "value": "Matte" } ], "specification": [ { "group": "Slip resistance", "rows": [ { "label": "DCOF", "value": "> 0.42", "test_method": "ANSI A326.3" } ] } ], "shipping_options": [ { "source": "US_WAREHOUSE", "lead_time_min_days": 14, "lead_time_max_days": 30, "ships_from_country": "US", "minimum_order_sqft": 1000 }, { "source": "OVERSEAS_DIRECT", "lead_time_min_days": 90, "lead_time_max_days": 120, "ships_from_country": "ES", "minimum_order_sqft": 5000 } ], "suitable_application_areas": [ { "code": "INTERIOR_DRY_FLOOR", "label": "Interior Dry Floor" } ] } ``` - `specification` leaves out any spec the product does not state. - `shipping_options` lists each way the product ships, its minimum order and its delivery window. The price comes from an [instant quote](https://docs.tryflume.ai/guides/get-an-instant-quote.md). ## File the search under a project Send `project.external_ref` and `project.name`. Flume saves the project in the user's Flume account. `project.status` in the response says what happened. | `status` | Meaning | |---|---| | `FILED` | This search created the project | | `EXISTING` | An earlier search created it | | `NOT_FILED` | Not saved. `reason` is `MISSING_FIELDS` or `PROJECT_LIMIT_REACHED` | The search runs either way. ## Handle the common errors | You get | Do this | |---|---| | `400` `IMAGE_REQUIRED` | Add the `image` part | | `413` `IMAGE_TOO_LARGE` | Send a photo under 6,000,000 bytes | | `422` `IMAGE_UNSUPPORTED` | Send a JPEG, PNG or WebP of at most 50 megapixels | | `422` `VALIDATION_FAILED` | Fix the field named in `error.field` | | `429` `RATE_LIMITED` | Wait `Retry-After` seconds | | `429` `DAILY_LIMIT_REACHED` | This user's own daily limit is used up (only when Flume set one). Tell the user when it resets: `details.reset_at`. Other users are not affected | | `503` `SEARCH_UNAVAILABLE` | Retry with backoff. The search is not counted | ## See also - [Search by photo](https://docs.tryflume.ai/api/search.md) in the API reference - [Get an instant quote](https://docs.tryflume.ai/guides/get-an-instant-quote.md) --- # Get an instant quote Source: https://docs.tryflume.ai/guides/get-an-instant-quote > Get the delivered price of a product for a quantity and a US ZIP code with POST /request-instant-quote. Freight and sales tax are included. `POST /request-instant-quote` returns the delivered price of one product for a quantity and a US ZIP code. You get one price per shipping option, cheapest first. ## Before you begin - A `sku_uuid` and the `search_session_uuid` of the search that returned it. See [Find products by photo](https://docs.tryflume.ai/guides/find-products-by-photo.md). - The product has `price_on_request: false`. ## 1. Request the quote **curl** ```sh curl -X POST "https://sandbox-api.tryflume.ai/partner/v1/request-instant-quote" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "X-Flume-User-Token: $FLUME_USER_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "sku_uuid": "7f3c2a91-4b0e-4c8d-9a21-1f6b2d5e7c30", "quantity_sqft": 6000, "destination_zip": "22102", "search_session_uuid": "5f0c1d2e-8a7b-4c3d-9e1f-2a3b4c5d6e7f" }' ``` | Field | Rule | |---|---| | `sku_uuid` | Must be a result of the search you name | | `search_session_uuid` | One of this user's searches. Searches do not expire | | `quantity_sqft` | A whole number of square feet, 1 to 10,000,000 | | `destination_zip` | A 5-digit US ZIP code | | `Idempotency-Key` header | Required. One per click | Set your HTTP read timeout to **75 seconds**. The call holds for up to 60 seconds while Flume prices. ## 2. Read the answer **Response (200)** ```json { "status": "READY", "failure_reason": null, "quote_uuid": "9c1e4b7a-2f3d-4e5b-8a6c-7d8e9f0a1b2c", "quote": { "quote_uuid": "9c1e4b7a-2f3d-4e5b-8a6c-7d8e9f0a1b2c", "quote_number": "Q-7KD2-M4PA", "sku_uuid": "7f3c2a91-4b0e-4c8d-9a21-1f6b2d5e7c30", "destination_zip": "22102", "currency": "USD", "created_at": "2026-10-16T09:14:22Z", "valid_until": "2026-10-23T09:14:22Z", "options": [ { "source": "OVERSEAS_DIRECT", "lead_time_min_days": 90, "lead_time_max_days": 120, "ships_from_country": "ES", "quantity_sqft": "6000.00", "price_per_sqft": "6.40", "subtotal": "38400.00", "sales_tax": "3168.00", "total": "41568.00" }, { "source": "US_WAREHOUSE", "lead_time_min_days": 14, "lead_time_max_days": 30, "ships_from_country": "US", "quantity_sqft": "6000.00", "price_per_sqft": "8.10", "subtotal": "48600.00", "sales_tax": "4009.50", "total": "52609.50" } ] } } ``` The call returns `200` whether or not it priced. Read `status`. | `status` | `failure_reason` | Meaning | Do this | |---|---|---|---| | `READY` | `null` | `quote` holds the prices | Show the options | | `FAILED` | `TEMPORARILY_UNAVAILABLE` | Still pricing, or a temporary problem | Poll. See step 3 | | `FAILED` | `NOT_QUOTABLE` | No price for this product, quantity and ZIP | Offer a [sales request](https://docs.tryflume.ai/support.md) | | `FAILED` | `REGION_RESTRICTED` | The product cannot be sold to that state | Ask for another ZIP, or offer sales | | `FAILED` | `INCORRECT_DESTINATION` | The ZIP code does not exist | Ask for the ZIP again | `quote_uuid` is set on every answer. ## 3. Poll while it is still pricing Call `GET /quotes/{quote_uuid}` until `status` is `READY` or the reason changes. **curl** ```sh curl -X GET "https://sandbox-api.tryflume.ai/partner/v1/quotes/9c1e4b7a-2f3d-4e5b-8a6c-7d8e9f0a1b2c" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "X-Flume-User-Token: $FLUME_USER_TOKEN" ``` - Poll every 12 seconds for the first 2 minutes. - Then every 30 seconds until 5 minutes. - Then stop and offer a sales request. - Polling uses no daily limit. - Do not retry `POST /request-instant-quote` instead. A retry counts against your daily quote limit again. > **Still pricing or failed?:** With the API, a quote that is still pricing and one that hit a temporary problem look the same: `FAILED` with `TEMPORARILY_UNAVAILABLE`. The polling schedule above covers both. ## 4. Show the options Each entry in `quote.options` is one way to ship. | Field | Meaning | |---|---| | `source` | `US_WAREHOUSE` or `OVERSEAS_DIRECT` | | `ships_from_country` | Two-letter country code, or `null` | | `lead_time_min_days`, `lead_time_max_days` | Delivery window in days | | `quantity_sqft` | The quantity priced | | `price_per_sqft` | Delivered price per square foot, before tax | | `subtotal` | Delivered price, before tax | | `sales_tax` | Sales tax, or `null` | | `total` | Including sales tax | - Money is a decimal string in USD: `"38400.00"`. Do not parse it as a float. - Show the figures as given. Do not add them up again. - Prices hold until `quote.valid_until`. That is 30 days after the quote is priced today. Always read the field. Do not compute it. - An option appears only when the quantity reaches its minimum order. ## Handle the errors | You get | Do this | |---|---| | `400` `IDEMPOTENCY_KEY_REQUIRED` | Send the `Idempotency-Key` header | | `400` `BELOW_MINIMUM_ORDER` | Raise the quantity. The message states the minimum | | `400` `NOT_INSTANTLY_QUOTABLE` | Offer a [sales request](https://docs.tryflume.ai/support.md) | | `404` `SESSION_NOT_FOUND` | Use one of this user's searches | | `404` `NOT_FOUND` | Check `sku_uuid` | | `409` `IDEMPOTENCY_IN_PROGRESS` | Wait 2 seconds, then retry with the same key | | `422` `SKU_NOT_IN_SEARCH` | Use the `search_session_uuid` of the page that returned the product | | `429` `RATE_LIMITED` | Wait `Retry-After` seconds | | `429` `DAILY_LIMIT_REACHED` | This user's own daily limit is used up (only when Flume set one). Tell the user when it resets: `details.reset_at`. Other users are not affected | ## Retry safely - A network error: retry with the **same** `Idempotency-Key`. You get the original answer. - A new click: use a **new** key. - See [Idempotency](https://docs.tryflume.ai/concepts/idempotency.md). ## See also - [Request an instant quote](https://docs.tryflume.ai/api/request-instant-quote.md) in the API reference - [Save an item](https://docs.tryflume.ai/concepts/save-payload.md) to keep the quote's prices --- # Open Flume signed in Source: https://docs.tryflume.ai/guides/open-flume-signed-in > Create a one-time deep link with POST /deep-links when your user clicks, then open it. The Flume app opens signed in, on their search, product or quote. `POST /deep-links` returns a one-time URL that opens the Flume app with the user signed in. Create it on the click, and open it at once. ## Before you begin - A `search_session_uuid` that belongs to the user. - A click from the user. Do not create links ahead of time. ## 1. Create the link on click **curl** ```sh curl -X POST "https://sandbox-api.tryflume.ai/partner/v1/deep-links" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "X-Flume-User-Token: $FLUME_USER_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "search_session_uuid": "5f0c1d2e-8a7b-4c3d-9e1f-2a3b4c5d6e7f", "sku_uuid": "7f3c2a91-4b0e-4c8d-9a21-1f6b2d5e7c30" }' ``` **Response (201)** ```json { "url": "https://app.tryflume.ai/?utm_source=acme&utm_medium=partner#/l/dlk_q3Vh8yT…", "expires_at": "2026-10-23T09:14:22Z" } ``` | To open | Send | |---|---| | The search results | `search_session_uuid` | | A product | `search_session_uuid`, `sku_uuid` | | An instant quote | `search_session_uuid`, `sku_uuid`, `quote_uuid` | ## 2. Open it Open `url` in a new tab, in the same click. ```js button.addEventListener("click", async () => { const tab = window.open("", "_blank"); if (tab) tab.opener = null; // the Flume app must not reach back into your page try { const { url } = await createDeepLinkOnYourBackend(); if (url) tab.location = url; else tab.close(); } catch { tab?.close(); } }); ``` `createDeepLinkOnYourBackend` is your own function. Opening the tab first keeps the browser's pop-up blocker from stopping it. ## Rules - The link works once, within 7 days. - Never store, log or email `url`. It signs the user in. - Do not parse or rebuild `url`. - For a link you can store, use `product_url` from the [save payload](https://docs.tryflume.ai/concepts/save-payload.md). ## Send the user back to you Add `redirect_uri` to return the user to your page afterwards. - It must be an `https` URL. - Its host must be a redirect host agreed with Flume. - Otherwise the call is a `422` `VALIDATION_FAILED` naming `redirect_uri`. ## Handle the errors | You get | Do this | |---|---| | `404` `SESSION_NOT_FOUND` | Use one of this user's searches | | `404` `NOT_FOUND` | Check `sku_uuid`, and that `quote_uuid` is one of this user's quotes | | `422` `SKU_NOT_IN_SEARCH` | Use the search that returned the product | | `422` `QUOTE_NOT_READY` | Wait until the quote is `READY` | | `422` `VALIDATION_FAILED` | Fix the field named in `error.field`. A quote for another product is refused this way | | `429` `RATE_LIMITED` | Wait `Retry-After` seconds | | `url` is `null` | You replayed an `Idempotency-Key`. Call again with a new key | ## See also - [Deep links](https://docs.tryflume.ai/concepts/deep-links.md) - [Create a deep link](https://docs.tryflume.ai/api/create-deep-link.md) --- # Rotate keys Source: https://docs.tryflume.ai/guides/rotate-keys > Rotate a Flume partner key with POST /keys. The old key keeps working for an overlap you choose, so you can switch with no downtime. `POST /keys` issues a new partner key. The key you called with keeps working for `overlap_days`, so you can deploy the new key before the old one stops. ## Before you begin - A working partner key. Flume issues your first one. See [Authentication and keys](https://docs.tryflume.ai/concepts/authentication.md). ## 1. List your keys **curl** ```sh curl -X GET "https://sandbox-api.tryflume.ai/partner/v1/keys" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" ``` **Response (200)** ```json { "keys": [ { "key_id": "pk_3f9a1c2e5b7d4a6f8e0c1b2d3a4f5e6d", "prefix": "sk_dev_acme_Qm4t", "label": "rotation 2026-Q4", "status": "ACTIVE", "created_at": "2026-10-16T08:00:00Z", "last_used_at": "2026-10-16T09:14:22Z", "expires_at": null }, { "key_id": "pk_9e1b4d7a2c5f4068b3a1d9c7e5f2a0b4", "prefix": "sk_dev_acme_8f3k", "label": null, "status": "EXPIRING", "created_at": "2026-07-02T10:30:00Z", "last_used_at": "2026-10-16T07:58:41Z", "expires_at": "2026-10-23T08:00:00Z" } ] } ``` | `status` | Meaning | |---|---| | `ACTIVE` | Working | | `EXPIRING` | Replaced by a newer key. Works until `expires_at` | | `REVOKED` | Stopped by you | | `EXPIRED` | Its overlap ran out | The list never contains a secret. ## 2. Issue the new key `Idempotency-Key` is required. **curl** ```sh curl -X POST "https://sandbox-api.tryflume.ai/partner/v1/keys" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "label": "rotation 2026-Q4", "overlap_days": 7 }' ``` **Response (201)** ```json { "key_id": "pk_3f9a1c2e5b7d4a6f8e0c1b2d3a4f5e6d", "prefix": "sk_dev_acme_Qm4t", "label": "rotation 2026-Q4", "status": "ACTIVE", "created_at": "2026-10-16T08:00:00Z", "last_used_at": null, "expires_at": null, "secret": "sk_dev_acme_Qm4tW…" } ``` - `secret` is the new key. It is shown once. - The key you called with becomes `EXPIRING`. It stops after `overlap_days`. - `overlap_days` is 0 to 30. The default is 7. - A replay of the same `Idempotency-Key` returns `secret: null`. ## 3. Deploy and revoke 1. Store the new secret in your secret manager. 2. Deploy it everywhere. 3. Check `last_used_at` on the old key with `GET /keys`. 4. Revoke the old key, or let its overlap run out. **curl** ```sh curl -X DELETE "https://sandbox-api.tryflume.ai/partner/v1/keys/pk_9e1b4d7a2c5f4068b3a1d9c7e5f2a0b4" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" ``` **Response (204)**: no body. A revoke takes effect at once. You cannot revoke your only working key. ## Limits | | Sandbox | Live | |---|---|---| | Active keys | 5 | 2 | | Keys issued in 24 hours | 10 | 1 | ## If a key leaks | The leaked key is | Do this | |---|---| | Not the one you call with | Call `DELETE /keys/{key_id}` on it. It stops at once | | The one you call with | Call `POST /keys` with `overlap_days: 0`. The old key stops at once. Deploy the new secret | Then [tell Flume](https://docs.tryflume.ai/support.md). Send the key prefix, never the key. > **On live you may not be able to rotate:** Live allows 1 new key in 24 hours, and the key Flume issued you counts. You also cannot revoke your only working key. If you cannot rotate a leaked live key, contact Flume at once and ask Flume to revoke it. ## Handle the errors | You get | Do this | |---|---| | `409` `KEY_LIMIT_REACHED` | Revoke a key you no longer use, then rotate | | `429` `KEY_ROTATION_RATE_LIMITED` | Wait. Your current key keeps working | | `409` `LAST_ACTIVE_KEY` | Issue a replacement before you revoke | | `400` `IDEMPOTENCY_KEY_REQUIRED` on `POST /keys` | Send the `Idempotency-Key` header | | `422` `VALIDATION_FAILED` on `POST /keys` | Fix the field named in `error.field`, for example `overlap_days` | | `404` `NOT_FOUND` | Check `key_id` | ## See also - [Rotate a key](https://docs.tryflume.ai/api/rotate-key.md) - [Revoke a key](https://docs.tryflume.ai/api/revoke-key.md) --- # Handle errors Source: https://docs.tryflume.ai/guides/handle-errors > Every Flume Partner API error is JSON with a stable code, a message, the field at fault and a request id. Branch on the code and retry only what is safe. Every error has the same JSON shape. Branch on `error.code`, show `error.message`, and log `error.request_id`. ## Read the envelope ```json { "error": { "code": "SKU_NOT_IN_SEARCH", "message": "This product was not a result of that search. Use the search_session_uuid of the search (or the page) that returned it.", "field": "sku_uuid", "request_id": "e19b8889-a63b-4000-9dbe-ffd5b9157bd5" } } ``` | Field | Use | |---|---| | `code` | Stable. Write your logic against it | | `message` | Written for a person. It can change. Never match on it | | `field` | The request field at fault, as a dotted path, or `null` | | `request_id` | Quote it to Flume support. Also in the `X-Request-Id` response header | | `details` | Sometimes present, with more about a refusal | ## Decide what to retry **Diagram: What to do when a server call fails.** Read the status and error.code on the left, then take the one action beside it. ```mermaid flowchart LR c-key["401 PARTNER_KEY_INVALID"] a-key["Fix the key or the base URL / do not retry"] c-token["401 USER_TOKEN_INVALID"] a-token["Delete the stored token / start again without it"] c-code["401 CONNECT_CODE_INVALID"] a-code["Store nothing / the user verifies on the next open"] c-403["403 USER_NOT_CONNECTED"] a-403["Verify the user / then send the user access token"] c-4xx["400, 404, 413, 422 / and any other 403 or 409"] a-4xx["Fix the request / field names what is wrong"] c-409["409 IDEMPOTENCY_IN_PROGRESS"] a-409["Retry with the same key / after Retry-After"] c-429["429"] a-429["Wait for Retry-After, then retry / a daily limit resets at 00:00 UTC"] c-5xx["500, 502, 503, timeout"] a-5xx["Retry with a growing wait / same Idempotency-Key, up to 3 retries"] c-key --> a-key c-token --> a-token c-code --> a-code c-403 --> a-403 c-4xx --> a-4xx c-409 --> a-409 c-429 --> a-429 c-5xx --> a-5xx ``` 1. 401 PARTNER_KEY_INVALID: fix the key, or the base URL it belongs to. Do not retry. 2. 401 USER_TOKEN_INVALID: delete the stored token and start the session again without it. The user verifies again in the iframe. 3. 401 CONNECT_CODE_INVALID: store nothing and do not retry. The user verifies again on the next open. 4. 403 USER_NOT_CONNECTED: the call needs a verified user. Verify the user, then send the user access token. 5. 400, 404, 413, 422, and any other 403 or 409: fix the request. On a 422, field names what is wrong. Do not retry unchanged. 6. 409 IDEMPOTENCY_IN_PROGRESS: the first request with this Idempotency-Key is still running. Retry with the same key after Retry-After. 7. 429: wait for the number of seconds in Retry-After, then retry. For a daily limit that is the time until 00:00 UTC, so stop for the day. 8. 500, 502, 503 or a timeout: retry with a growing wait and the same Idempotency-Key, up to 3 retries. | Status | Retry? | How | |---|---|---| | `400`, `404`, `413`, `422` | No | Fix the request | | `401` | No | Fix the key, or verify the user again | | `403` | No | Send the user access token, or verify the user | | `409` `IDEMPOTENCY_IN_PROGRESS` | Yes | After `Retry-After` seconds (2 when the header is absent), same key | | `409` other codes | No | Resolve the conflict | | `429` | Yes | After `Retry-After` seconds | | `500` `INTERNAL` | Yes | With backoff. Same `Idempotency-Key` | | `502` `VERIFICATION_EMAIL_FAILED` | Yes | With backoff | | `503` `SEARCH_UNAVAILABLE` | Yes | With backoff | Use exponential backoff with jitter. Double the wait each time, and stop after 4 tries. ## Example: a retry wrapper Keep the same `Idempotency-Key` in `init.headers` across the retries of one action. To retry an upload such as `POST /search`, build the body again for each attempt. In Python, open the file again or call `image.seek(0)`. A file that was already read uploads as empty, and the call returns `400` `IMAGE_REQUIRED`. ## Handle responses that are not the envelope - On live, a request from an unsupported country gets a `403` with no JSON body. See [Environments](https://docs.tryflume.ai/concepts/environments.md#how-do-you-get-live-access). - On live, a network-level limit per IP address can return a `429` with no JSON body and no `Retry-After`. - A network timeout has no body. Retry a write with the same `Idempotency-Key`. Always check that the body parses as JSON and has `error` before you read it. ## Show errors to users - `error.message` is written for the user. You can show it. - For `429 RATE_LIMITED`, tell the user when to try again. ## Log for support Log these for every failed call: - `X-Request-Id` - the method and path - `error.code` - the key prefix, never the key - `session_uuid`, for iframe sessions ## See also - [Errors](https://docs.tryflume.ai/api/errors.md): every code, with its status and fix - [Idempotency](https://docs.tryflume.ai/concepts/idempotency.md) - [Limits](https://docs.tryflume.ai/concepts/limits.md) --- # Authentication and keys Source: https://docs.tryflume.ai/concepts/authentication > The Flume Partner API authenticates with two headers. A partner key identifies your platform, and a user access token identifies the user you call for. Every call carries your **partner key** in `X-Flume-Partner-Key`. Calls made for a user also carry that user's **access token** in `X-Flume-User-Token`. Neither is a bearer token. ## What are the two credentials? | Credential | Header | Identifies | Lifetime | |---|---|---|---| | Partner key | `X-Flume-Partner-Key` | Your platform | Until you revoke or rotate it | | User access token | `X-Flume-User-Token` | One user of yours | No expiry. It is replaced each time the user verifies, in your screens or in the iframe | ```sh curl "https://sandbox-api.tryflume.ai/partner/v1/filters" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" ``` ## Which calls need which header? | Calls | Partner key | User access token | |---|---|---| | `POST /start-iframe-session` | Required | Optional. Always send it when you hold one | | `POST /users/trigger-verification`, `POST /users/confirm-verification` | Required | Not needed. If you send one, it must be valid | | `POST /users/exchange-connect-code`, `DELETE /users/{external_ref}/token` | Required | Not used | | `GET /filters` | Required | Not needed. If you send one, it must be valid | | `GET /keys`, `POST /keys`, `DELETE /keys/{key_id}` | Required | Not used | | `POST /detect-category`, `POST /search`, `GET /searches/{search_session_uuid}` | Required | Required | | `GET /products/{sku_uuid}`, `POST /request-instant-quote`, `GET /quotes/{quote_uuid}` | Required | Required | | `POST /save-item`, `POST /save-item/{save_uuid}/result` | Required | Required | | `POST /sales-requests`, `POST /deep-links` | Required | Required | ## What does a partner key look like? - Shape: `sk___`. - A sandbox key starts with `sk_dev_`. A live key starts with `sk_prod_`. - A key works in one environment only. A sandbox key on the live API is refused like an unknown key. - The **prefix** (up to the first 4 random characters) is safe to log. The rest is secret. - Flume stores only a hash of the key. Flume cannot show a lost key again. Rotate instead. ## How do you get a key? - Flume issues your first key. See [Get a sandbox key](https://docs.tryflume.ai/get-a-key.md). - There is no sign-up form and no dashboard today. - After that you list, rotate and revoke keys yourself with the [keys endpoints](https://docs.tryflume.ai/guides/rotate-keys.md). ## What does a user access token look like? - Shape: `ut___`. - You get it once, from `POST /users/confirm-verification` or `POST /users/exchange-connect-code`. - It has no expiry date. - A user has one working token at a time. It stops working when: - you call `DELETE /users/{external_ref}/token`, - the user verifies again on your screens (the response holds the new token), - the user verifies in the iframe (the new token comes from the connect code exchange), - the user's email changes. See [Users and verification](https://docs.tryflume.ai/concepts/users.md). ## What happens when a credential is wrong? | Situation | Status | Code | |---|---|---| | Key missing, unknown, revoked, expired or from the other environment | 401 | [`PARTNER_KEY_INVALID`](https://docs.tryflume.ai/api/errors.md#partner_key_invalid) | | User token header not sent on a call that needs it | 403 | [`USER_NOT_CONNECTED`](https://docs.tryflume.ai/api/errors.md#user_not_connected) | | User token unknown, revoked, replaced or another partner's | 401 | [`USER_TOKEN_INVALID`](https://docs.tryflume.ai/api/errors.md#user_token_invalid) | | The user's Flume account is closed | 403 | [`USER_ACCOUNT_CLOSED`](https://docs.tryflume.ai/api/errors.md#user_account_closed) | | You sent the key in an `Authorization` header | 401 | [`EMBED_TOKEN_INVALID`](https://docs.tryflume.ai/api/errors.md#embed_token_invalid) | The API gives one answer for every bad key. It does not say which part was wrong. > **Not a bearer token:** The partner key goes in `X-Flume-Partner-Key`, never in `Authorization`. A call with `Authorization: Bearer ...` and no `X-Flume-Partner-Key` returns `401` `EMBED_TOKEN_INVALID`. ## Can you call the API from a browser? No. The API is server to server. - Browser calls from your origin are blocked by CORS. - A key or a user access token in a browser is a leaked credential. - The Flume iframe holds its own short-lived credential. You never see or handle it. ## How should you store credentials? - Keep the partner key in a secret manager. Never in source control or a client bundle. - Store each user access token encrypted, against that user, on your server. - Log the key prefix and `X-Request-Id`. Never log a full key or token. - [Rotate](https://docs.tryflume.ai/guides/rotate-keys.md) a key at once if you think it leaked. --- # Environments Source: https://docs.tryflume.ai/concepts/environments > Flume has two environments, sandbox and live. They share one API and one set of docs, and differ in base URL, key prefix and who has access. Flume has two environments: **sandbox** for building and **live** for real users. The API, the iframe and these docs are the same in both. Only the values below differ. | Property | Sandbox | Live | |---|---|---| | Base URL for server calls | `https://sandbox-api.tryflume.ai/partner/v1` | `https://ssapi.tryflume.ai/partner/v1` | | Partner key starts with | `sk_dev_` | `sk_prod_` | | User access token starts with | `ut_dev_` | `ut_prod_` | | Iframe origin | The origin of the `iframe_url` you receive | The origin of the `iframe_url` you receive | | Who can use it | Open to every partner with a sandbox key. | Early access. Not yet open to new partners; by request, one at a time. | | How to get a key | By request: [Get a sandbox key](https://docs.tryflume.ai/get-a-key.md) | By request, after the [go-live checklist](https://docs.tryflume.ai/go-live.md) | | Verification emails | Real email is sent | Real email is sent | | Data | Separate keys, users and tokens | Separate keys, users and tokens | ## How do the docs show an environment? - The **Sandbox / Live** switch in the header changes the host and the key prefixes in code samples. - Your browser remembers the choice. - Pages load with sandbox values. Copied samples are safe to run by default. - Sentences and the table above never change. Only values in code do. - The Markdown version of a page always has sandbox values. ## What is the same in both? - Every path, header, field, error code and message type. - The path prefix `/partner/v1`. - The default [limits](https://docs.tryflume.ai/concepts/limits.md). Flume can set a different limit for one environment. ## What should you know about the sandbox? - The sandbox has no test fixtures today: - no test users, - no fixed verification code, - no way to force a quote result. - Verification sends a real email. Use an inbox you can read. - A sandbox key and a sandbox user access token do not work on live. ## How do you get live access? Live is in early access. Flume is not yet opening it to new partners, and will enable it by request, one partner at a time. Build and test in the sandbox, and do not plan a launch on live until Flume confirms your platform is enabled. 1. Finish the [go-live checklist](https://docs.tryflume.ai/go-live.md) in the sandbox. 2. [Ask Flume](https://docs.tryflume.ai/support.md#ask-for-live-access) for live access. Send your page origins, image hosts and redirect hosts. 3. Flume enables your platform on live and issues a live key. 4. Verify each user again on live. Sandbox tokens do not carry over. A live key alone is not enough. Until Flume enables your platform, live refuses the key with `PARTNER_KEY_INVALID`. > **Region:** Live accepts requests only from supported countries. A request from elsewhere gets a `403` with no JSON body, so it does not match the [error envelope](https://docs.tryflume.ai/api/errors.md). Tell Flume where your servers and your users are before you go live. ## What is the iframe origin? The iframe's origin is the origin of the `iframe_url` that `POST /start-iframe-session` returns. - Read it at run time: `new URL(iframe_url).origin`. - Do not hardcode it. It differs between sandbox and live, and it can change. - Your Content Security Policy needs it in `frame-src`. See [Security and CSP](https://docs.tryflume.ai/embed/security.md). - The image links Flume returns can be on another origin again. Read that from a real link too. --- # Users and verification Source: https://docs.tryflume.ai/concepts/users > A user is one person on your platform. They verify their email with a 6-digit code, and Flume returns an access token you send on calls made for them. A **user** is one person on your platform. They **verify** their email once with a 6-digit code. Flume then links them to a Flume account and gives you a **user access token**. ## How do you identify a user? You send `external_ref`: your own permanent id for the person. - Keep it stable. Flume uses it to find the user's earlier searches and quotes. - Never reuse it for another person. - 1 to 128 characters. ## How does verification work from your own screens? 1. Call `POST /users/trigger-verification` with `external_ref` and `email`. Flume emails the code. 2. Show a code box. `masked_email` tells the user which inbox to check. 3. Call `POST /users/confirm-verification` with `email` and `code`. 4. Store `access_token` from the response. It is returned once. Step by step with code: [Verify a user](https://docs.tryflume.ai/guides/verify-a-user.md). ## How does verification work in the iframe? **Diagram: The first open: the user verifies, you get a token.** Happens once per user. You build no screen for it: the iframe asks for the code. ```mermaid sequenceDiagram participant user as User participant iframe as Flume iframe participant page as Your page participant backend as Your backend participant flume as Flume servers user->>iframe: clicks Connect iframe->>flume: send a code flume->>user: email, 6 digits user->>iframe: types the code iframe->>flume: check the code iframe-->>page: flume:user_connected page->>backend: connect_code backend->>flume: POST /users/exchange-connect-code flume->>backend: access_token Note over backend: store the token ``` 1. The iframe opened without a user access token, so it shows the user their name and email. The user clicks Connect. 2. The iframe asks Flume to email a code. 3. Flume emails a 6-digit code to the user. It is valid for 10 minutes. 4. The user types the code in the iframe. 5. The iframe checks the code with Flume. Any token you held for this user stops working now. 6. The iframe posts flume:user_connected with a connect code. 7. Your page sends the connect code to your backend, at once. 8. Your backend calls POST /users/exchange-connect-code. The code works once, within 120 seconds. 9. Flume returns the user access token, once. 10. Your backend checks external_ref, then stores the token over any older one. It sends it on the next POST /start-iframe-session. 1. You call `POST /start-iframe-session` without a user access token. 2. The iframe asks the user to verify, and checks the code itself. 3. Your page receives `flume:user_connected` with a `connect_code`. 4. Your backend calls `POST /users/exchange-connect-code` within 120 seconds. 5. Your backend checks `external_ref` in the response, then stores `access_token`. 6. Next time, send the token on `POST /start-iframe-session`. The iframe opens with the user already verified. **Diagram: A later open, for a user whose token you hold.** No email step and no flume:user_connected. Lighter steps happen only when the stored token has stopped working. ```mermaid sequenceDiagram participant user as User participant iframe as Flume iframe participant page as Your page participant backend as Your backend participant flume as Flume servers user->>page: clicks your button page->>backend: your route backend->>flume: POST /start-iframe-session flume->>backend: 401 USER_TOKEN_INVALID (not every time) Note over backend: delete the token (not every time) backend->>flume: POST /start-iframe-session (not every time) flume->>backend: iframe_url, session_uuid backend->>page: iframe_url, session_uuid page->>iframe: iframe.src iframe->>flume: launch code, once iframe-->>page: flume:iframe_ready ``` 1. The user clicks your button for one item. 2. Your page asks your backend to start a session. 3. Your backend calls POST /start-iframe-session and sends the user access token it stored for this user. 4. Only when the token no longer works, because the user verified again or you revoked it: Flume answers 401 USER_TOKEN_INVALID. 5. Then your backend deletes the stored token. 6. Then your backend calls POST /start-iframe-session again without a token. The user verifies in the iframe, as on a first open. 7. Flume returns iframe_url and session_uuid. 8. Your backend returns those two values to your page. 9. Your page starts the ready timer, adds the message listener and sets the iframe src. 10. The iframe spends the launch code with Flume. 11. The iframe posts flume:iframe_ready and opens with the user already verified. ## What happens when a user verifies in the iframe? A user has one working access token at a time. Each verification issues a new one and stops the one before. | Moment | Effect on tokens | |---|---| | The user confirms the code in the iframe | Any token you hold for this user stops working. The iframe's own session keeps working | | Your backend exchanges the connect code | Flume issues the user's new token and returns it to you | | You never exchange the connect code | You hold no working token for this user | What this means for your backend: - **Always exchange the connect code**, at once, and overwrite the stored token. - **Always send the token you hold** on `POST /start-iframe-session`. The iframe then opens verified, and no new token is issued. - If you use both the API and the iframe, expect API calls to return `401` [`USER_TOKEN_INVALID`](https://docs.tryflume.ai/api/errors.md#user_token_invalid) between the user's verification in the iframe and your exchange. Retry them with the new token. - Another iframe that was open for the same user ends at its next call. It posts `flume:close_iframe` with reason `expired`. ## What are the rules for a code? | Rule | Value | |---|---| | Length | 6 digits | | Valid for | 10 minutes | | Wrong tries before it is burned | 3 | | Wait before a resend | 30 seconds | | Codes per mailbox | 5 in 24 hours | | Codes per user IP address, when you send `X-Flume-Client-IP` | 30 in 1 hour | | Codes per partner | 500 per UTC day | - A resend replaces the earlier code. - `X-Flume-Client-IP` is optional. When you send the user's IP address, Flume also limits codes per IP address. Without it, only the limits per mailbox and per partner apply. ## What happens to the user's Flume account? `account` in the response tells you. | Value | Meaning | |---|---| | `CREATED` | Flume opened a new Flume account with the details you sent | | `LINKED` | The email already had a Flume account. Flume linked it and wrote nothing to it | Searches, quotes and projects you create for the user appear in that account. ## How long does a user access token last? It has no expiry date. It stops working when one of these happens. | Event | Result | |---|---| | You call `DELETE /users/{external_ref}/token` | The token stops at once. Iframe sessions opened with it end at their next call | | The user confirms a code again, on your screens | The response holds a new token. The old one stops | | The user verifies in the iframe | The old token stops. The new one comes from the connect code exchange | | You call trigger-verification with a new email for a known `external_ref` | The user verifies the new address. The old token stops | A stopped token returns `401` [`USER_TOKEN_INVALID`](https://docs.tryflume.ai/api/errors.md#user_token_invalid). Verify the user again to get a new one. ## How do you update a user's details? Call `POST /users/trigger-verification` again with the same `external_ref`. - A field you leave out is kept. - A field you send as `null` is cleared. - The call also sends a fresh code. ## What can go wrong? | Situation | Status | Code | |---|---|---| | Wrong code | 422 | `VERIFICATION_CODE_INVALID`. The message says how many tries are left | | Third wrong code | 422 | `VERIFICATION_TRIES_EXHAUSTED`. Send a new code | | Code expired, used or never sent | 422 | `VERIFICATION_CODE_EXPIRED`. Send a new code | | Resend too soon, or a mailbox or IP limit | 429 | `VERIFICATION_RATE_LIMITED`. Wait `Retry-After` seconds | | The email could not be sent | 502 | `VERIFICATION_EMAIL_FAILED`. Retry | | The email belongs to a closed Flume account | 422 | `USER_ACCOUNT_CLOSED`. On every other call this code is a `403` | | No user of yours with that email awaits a code | 404 | `NOT_FOUND` | Every code is in the [error catalog](https://docs.tryflume.ai/api/errors.md). --- # Save payload Source: https://docs.tryflume.ai/concepts/save-payload > Saving an item gives you one versioned JSON payload for the product a user chose. This page covers the save flow and every field you can map. Saving an item gives you one JSON object, the **save payload**, for one product the user chose. You write it into your own records and tell Flume whether that worked. ## What is the flow? **Diagram: Save an item.** The user saves in the iframe, your side writes the record, and your page answers. Your backend calls Flume at no point. ```mermaid sequenceDiagram participant user as User participant iframe as Flume iframe participant page as Your page participant backend as Your backend participant flume as Flume servers user->>iframe: confirms a save iframe->>flume: build the save payload iframe-->>page: flume:save_item page->>backend: save payload Note over backend: write once per save_uuid backend->>page: your record id page-->>iframe: flume:item_saved iframe->>flume: record the result iframe->>user: saved screen ``` 1. The user confirms a save in the iframe. 2. The iframe asks Flume for the save payload. If that fails, the iframe shows its own error and your page hears nothing. 3. The iframe posts flume:save_item with the save payload and its save_uuid. 4. Your page sends the payload to your backend. 5. Your backend writes one record per save_uuid, and copies the photo bytes if you keep a photo. 6. Your backend answers with your record, or with a failure. 7. Your page replies flume:item_saved with the same save_uuid, within 60 seconds. On a failed write it replies flume:item_save_failed instead. 8. The iframe tells Flume how the save ended. You make no call for this. 9. The iframe shows the saved screen. | Step | In the iframe | With the API | |---|---|---| | 1. Build the payload | The iframe calls Flume | You call `POST /save-item` | | 2. Receive it | Your page gets `flume:save_item` | It is the response body | | 3. Write your record | Your page or backend | Your backend | | 4. Report the result | Reply `flume:item_saved` or `flume:item_save_failed` | Call `POST /save-item/{save_uuid}/result` | The payload is the same object on both paths. Step by step with the API: [Save an item](https://docs.tryflume.ai/concepts/save-payload.md). ## Why is save_uuid important? `save_uuid` identifies one save. A retry sends the same `save_uuid` and gets the payload Flume stored the first time. - Treat `save_uuid` as your idempotency key. - If you already hold a record for it, return success without writing again. - A `save_uuid` that belongs to another user returns `409` `SAVE_CONFLICT`. - The stored payload keeps its first image links. They may have expired by the time of a retry. Read fresh links with `GET /products/{sku_uuid}`. ## How do you carry pricing? - Send `quote_uuid` to include an instant quote's prices. - Send `option_source` to pick the shipping option. Without it, Flume picks the recommended one. - Leave `quote_uuid` out and `pricing` is `null`. - The quote must be `READY`, the user's own, not expired, and at or above the option's minimum order. Otherwise the call returns `422` [`QUOTE_NOT_READY`](https://docs.tryflume.ai/api/errors.md#quote_not_ready). ## What must you do with the payload? - **Copy image bytes.** Photo links expire after about 24 hours. In the sandbox they can stop working earlier. - **Store `product_url`.** It is the durable link back to Flume. It does not expire. - **Render text as text.** Escape every string on output. See [Text rules](#text-rules). - **Reply within 60 seconds** in the iframe. Silence counts as a failure. - **Check prices on your server** if they matter. See [Can you trust the save payload?](https://docs.tryflume.ai/embed/security.md#can-you-trust-the-save-payload) A save also files the product in the user's Flume project, when the search has one. ## Example ```json { "schema_version": "1.0", "save_uuid": "2b9d6f1c-7a34-4e58-b0c2-9d1e3f5a7b6c", "saved_at": "2026-10-16T09:20:05Z", "source": { "item_ref": "48213", "item_label": "T-1", "project_ref": "proj_7781" }, "user": { "external_ref": "u_8812", "flume_user_uuid": "a4d2c7e1-6b39-4f80-8e15-3c9b7a2d6f04" }, "project": { "external_ref": "proj_7781", "flume_project_uuid": "e7b1f3a9-2c64-4d05-a8f7-6b0e9d4c1a28" }, "product_url": "https://app.tryflume.ai/?utm_source=acme&utm_medium=partner&save_uuid=2b9d6f1c-7a34-4e58-b0c2-9d1e3f5a7b6c#/product/7f3c2a91-4b0e-4c8d-9a21-1f6b2d5e7c30", "product": { "sku_uuid": "7f3c2a91-4b0e-4c8d-9a21-1f6b2d5e7c30", "name": "Porcelain 24×48″ Matte", "category": "tile", "material": "Porcelain", "finish": "Matte", "size_label": "24×48″", "glance": [ { "label": "Size", "value": "24×48″" }, { "label": "Thickness", "value": "9 mm" }, { "label": "Finish", "value": "Matte" }, { "label": "Slip resistance", "value": "DCOF over 0.42" } ], "spec_rows": [ { "group": "Slip resistance", "rows": [ { "label": "DCOF", "value": "over 0.42", "method": "ANSI A326.3", "note": null } ] } ], "gallery": [ { "urls": { "640": "https://sandbox-api.tryflume.ai/partner/v1/images/example-image-main" } } ], "lanes": [ { "supply_source_type": "OVERSEAS_DIRECT", "on_request": false, "minimum_order_sqft": 5000, "ships_from": { "country": "ES", "label": "Ships from Spain" }, "lead_time": { "min_days": 90, "max_days": 120, "label": "Delivered in 90 to 120 days" } }, { "supply_source_type": "US_WAREHOUSE", "on_request": false, "minimum_order_sqft": 1000, "ships_from": { "country": "US", "label": "Ships from the US" }, "lead_time": { "min_days": 14, "max_days": 30, "label": "Delivered in 14 to 30 days" } } ], "suitable_application_areas": [ { "code": "INTERIOR_DRY_FLOOR", "label": "Interior Dry Floor" } ] }, "pricing": { "quote_uuid": "9c1e4b7a-2f3d-4e5b-8a6c-7d8e9f0a1b2c", "quote_number": "Q-7KD2-M4PA", "status": "READY", "created_at": "2026-10-16T09:14:22Z", "valid_until": "2026-10-23T09:14:22Z", "destination_zip": "22102", "currency": "USD", "picked": { "sku_uuid": "7f3c2a91-4b0e-4c8d-9a21-1f6b2d5e7c30", "supply_source_type": "OVERSEAS_DIRECT", "ships_from_country": "ES", "is_recommended": true, "quantity": "6000.00", "quantity_unit": "SF", "material_total": "38400.00", "tax_amount": "3168.00", "total_excl_tax": "38400.00", "total_incl_tax": "41568.00", "unit_landed_price_excl_tax": "6.40", "saving_vs_other_lane": "10200.00" }, "lead_time": { "min_days": 90, "max_days": 120, "label": "Delivered in 90 to 120 days" }, "items": [ { "sku_uuid": "7f3c2a91-4b0e-4c8d-9a21-1f6b2d5e7c30", "supply_source_type": "OVERSEAS_DIRECT", "ships_from_country": "ES", "is_recommended": true, "quantity": "6000.00", "quantity_unit": "SF", "material_total": "38400.00", "tax_amount": "3168.00", "total_excl_tax": "38400.00", "total_incl_tax": "41568.00", "unit_landed_price_excl_tax": "6.40", "saving_vs_other_lane": "10200.00" }, { "sku_uuid": "7f3c2a91-4b0e-4c8d-9a21-1f6b2d5e7c30", "supply_source_type": "US_WAREHOUSE", "ships_from_country": "US", "is_recommended": false, "quantity": "6000.00", "quantity_unit": "SF", "material_total": "48600.00", "tax_amount": "4009.50", "total_excl_tax": "48600.00", "total_incl_tax": "52609.50", "unit_landed_price_excl_tax": "8.10", "saving_vs_other_lane": null } ] }, "match": { "search_session_uuid": "5f0c1d2e-8a7b-4c3d-9e1f-2a3b4c5d6e7f", "filters": { "material": [ { "code": "PORCELAIN", "label": "Porcelain" } ], "finish": [ { "code": "MATTE", "label": "Matte" } ] }, "section": "strong", "rank": 1, "matched_on": [ "finish", "material" ] } } ``` ## Top level | Field | Type | Notes | |---|---|---| | `schema_version` | string | `"1.0"` | | `save_uuid` | string (UUID) | Identifies the save. Your idempotency key | | `saved_at` | string | UTC time | | `source` | object or null | Your own ids. `null` when you call the API yourself | | `user` | object | Who saved | | `project` | object or null | The project, when Flume holds one | | `product_url` | string | A durable link to the product in the Flume app | | `product` | object | What was saved | | `pricing` | object or null | The quote's prices. `null` when the save has no quote | | `match` | object | How the product was found | ## source | Field | Type | Notes | |---|---|---| | `item_ref` | string | The `item.external_id` you sent | | `item_label` | string or null | The `item.label` you sent | | `project_ref` | string | The `project.external_ref` you sent | ## user | Field | Type | Notes | |---|---|---| | `external_ref` | string | Your id for the user | | `flume_user_uuid` | string (UUID) | The user's id in Flume | ## project | Field | Type | Notes | |---|---|---| | `external_ref` | string | Your id for the project | | `flume_project_uuid` | string (UUID) | The project's id in Flume | ## product_url - Opens the product in the Flume app. - The user is not signed in by the link. - It does not expire. Store it. - Do not parse it. Its host depends on the environment. ## product | Field | Type | Notes | |---|---|---| | `sku_uuid` | string (UUID) | The product's id | | `name` | string | Written by Flume from the product's specification | | `category` | string | `tile` or `vinyl` | | `material` | string or null | For example `Porcelain` | | `finish` | string or null | For example `Matte` | | `size_label` | string or null | For example `24×48″` | | `glance` | array | Key facts: `label`, `value` | | `spec_rows` | array | The full specification, in groups | | `gallery` | array | Every product photo the product page shows, in order | | `lanes` | array | The shipping options | | `suitable_application_areas` | array | Where the product can be used: `code`, `label` | > **Two spellings of the category:** The save payload and `POST /detect-category` say `tile` or `vinyl`. `POST /search` and `GET /products/{sku_uuid}` say `TILE` or `FLOORING`. `vinyl` and `FLOORING` are the same category. `spec_rows` entries: | Field | Type | Notes | |---|---|---| | `group` | string | The group's heading | | `rows[].label` | string | A label can repeat within a group | | `rows[].value` | string | The property's value | | `rows[].method` | string or null | The test standard, when one is stated | | `rows[].note` | string or null | An extra note, when there is one | Each `rows` entry has `label`, `value`, `method` and `note`. `gallery` entries: | Field | Type | Notes | |---|---|---| | `urls` | object | Image links by size. They expire. Copy the bytes | `lanes` entries, one per shipping option: | Field | Type | Notes | |---|---|---| | `supply_source_type` | string | `US_WAREHOUSE` or `OVERSEAS_DIRECT` | | `on_request` | boolean | `true` when Flume's sales team prices this option | | `minimum_order_sqft` | integer | The smallest order for this option | | `ships_from` | object or null | `country` (two letters) and `label` | | `lead_time` | object or null | The delivery window the product page shows: `min_days`, `max_days`, `label`. `null` when the option is priced on request | ## pricing Present when the save names a quote. | Field | Type | Notes | |---|---|---| | `quote_uuid` | string (UUID) | The quote's id | | `quote_number` | string | As the user sees it in Flume, for example `Q-7KD2-M4PA` | | `status` | string | Always `READY` | | `created_at`, `valid_until` | string | UTC. Prices hold until `valid_until` | | `destination_zip` | string | The delivery ZIP code | | `currency` | string | `USD` | | `picked` | object | The option the user chose. Same fields as an `items` entry | | `lead_time` | object | For the picked option: `min_days`, `max_days`, `label` | | `items` | array | Every option of the quote for this product, cheapest first | `picked` and each `items` entry: | Field | Type | Notes | |---|---|---| | `sku_uuid` | string (UUID) | The product priced | | `supply_source_type` | string | `US_WAREHOUSE` or `OVERSEAS_DIRECT` | | `ships_from_country` | string or null | Two letters | | `is_recommended` | boolean | The option Flume led with | | `quantity` | decimal string | The quantity the user asked for | | `quantity_unit` | string | The unit of `quantity` | | `unit_landed_price_excl_tax` | decimal string | Delivered price per unit, before tax | | `total_excl_tax` | decimal string | Delivered price, before tax | | `material_total` | decimal string | Always equal to `total_excl_tax` | | `tax_amount` | decimal string or null | Sales tax | | `total_incl_tax` | decimal string | `total_excl_tax` plus `tax_amount` | | `saving_vs_other_lane` | decimal string or null | On the recommended option: how much less it costs than the other, before tax | Money is a decimal string. Do not parse it as a float. ## How do the quote and the payload name the same thing? A quote from the API and the `pricing` block describe the same shipping option with different field names. | Meaning | `quote.options[]` (API) | `pricing.items[]` (save payload) | |---|---|---| | How it ships | `source` | `supply_source_type` | | Where it ships from | `ships_from_country` | `ships_from_country` | | Quantity | `quantity_sqft` | `quantity` | | Delivered price per unit, before tax | `price_per_sqft` | `unit_landed_price_excl_tax` | | Delivered price, before tax | `subtotal` | `total_excl_tax` | | Sales tax | `sales_tax` | `tax_amount` | | Delivered price, with tax | `total` | `total_incl_tax` | | Delivery time | `lead_time_min_days`, `lead_time_max_days` | `pricing.lead_time`, for the picked option | ## match | Field | Type | Notes | |---|---|---| | `search_session_uuid` | string (UUID) | The search that found the product | | `filters` | object | The filters that search ran with. List values carry `code` and `label` | | `section` | string or null | Where the product appeared. `strong` for a strong match | | `rank` | integer or null | Its position in the results | | `matched_on` | array of strings | For a strong match: the filters it met. Otherwise empty | ## Text rules Flume cleans every string before it sends the payload. - No control characters. - No `<` or `>`. A comparison before a number becomes a word: `> 0.42` reads `over 0.42`. - Labels are cut at 200 characters. Values are cut at 500. - Links are not cut. Quotes and `&` are not removed. Escape every string on output. Never build HTML, or an HTML attribute, by joining these strings. ## Versioning `schema_version` is `"1.0"`. Read it before you map the payload, and log a value you do not know. > **Not in the OpenAPI document:** The OpenAPI document types the top-level fields only. The blocks are described on this page. --- # Deep links Source: https://docs.tryflume.ai/concepts/deep-links > A deep link opens the Flume app with your user already signed in, on a search, a product or a quote. It works once, within 7 days. A **deep link** is a one-time URL that opens the Flume app with the user signed in. It lands on one of their searches, a product from that search, or an instant quote. ## When should you create one? Create the link from your backend at the moment the user clicks. Then open it at once. - It works once. - It expires 7 days after you create it. - Flume never stores the URL. Do not store it either. ## What can a link open? | You send | The link opens | |---|---| | `search_session_uuid` | That search's results | | `search_session_uuid` and `sku_uuid` | That product. It must be a result of that search | | `search_session_uuid`, `sku_uuid` and `quote_uuid` | That instant quote. It must be `READY` | `search_session_uuid` is always required. ## What do the optional fields do? | Field | Use | |---|---| | `target` | What the link opens: `product`, `search` or `quote`. See [What does the link open?](#what-does-the-link-open) | | `utm_content` | Names the button that asked, for your own reporting. Max 64 characters | | `redirect_uri` | Where Flume sends the user back to. Must be `https`, on a redirect host agreed with Flume | ## What does the link open? Without `target`, the link opens the most specific id you send. | You send | The link opens | |---|---| | `quote_uuid` | The instant quote | | `sku_uuid`, no `quote_uuid` | The product | | Neither | The search | With `target`, the link opens what you name, whatever else you send. | `target` | Needs | The link opens | |---|---|---| | `quote` | `quote_uuid` | The instant quote | | `product` | `sku_uuid` | The product, also when you send a `quote_uuid` | | `search` | Nothing more | The search | - A `target` without the id it needs returns `422` `VALIDATION_FAILED`. `field` names the missing id. - Flume checks every id you send, whatever `target` says. A `sku_uuid` must be a result of the search, and a `quote_uuid` must be the user's own `READY` quote for that product. A quote that is not `READY` returns `422` `QUOTE_NOT_READY`; a quote for another product returns `422` `VALIDATION_FAILED` with `field: quote_uuid`. ## How does idempotency apply? `Idempotency-Key` is optional on `POST /deep-links`. - A replay of the same key returns `url: null`. A link is a sign-in grant, so Flume does not repeat it. - To get a new link, call again with a new key. ## What about the iframe's own button? Inside the iframe, the user can open the Flume app directly. The iframe creates the link itself. You do nothing. Your iframe tag needs `allow-popups` and `allow-popups-to-escape-sandbox` for this. See [Set up your page](https://docs.tryflume.ai/embed/host-page.md). ## What is the durable link? `product_url` in the [save payload](https://docs.tryflume.ai/concepts/save-payload.md) is a plain link to the product. | | Deep link | `product_url` | |---|---|---| | Signs the user in | Yes | No | | Uses | One | Unlimited | | Expires | After 7 days | Never | | Store it | No | Yes | ## What does the user see when a link is spent? The Flume app shows "This link has expired or was already used". Create a new link on the next click. ## Limits - 2,000 deep links per partner per UTC day. Over it: `429` [`RATE_LIMITED`](https://docs.tryflume.ai/api/errors.md#rate_limited). - The host of `url` depends on the environment. Do not parse or rebuild the URL. --- # Idempotency Source: https://docs.tryflume.ai/concepts/idempotency > Send an Idempotency-Key header so a retried write returns the original answer instead of running twice. Instant quotes and key rotation require it. An **idempotency key** makes a retry safe. Send the same `Idempotency-Key` header again and Flume returns the first answer instead of doing the work twice. ## Which calls use it? | Call | `Idempotency-Key` | Without it | |---|---|---| | `POST /request-instant-quote` | Required | `400` [`IDEMPOTENCY_KEY_REQUIRED`](https://docs.tryflume.ai/api/errors.md#idempotency_key_required) | | `POST /keys` | Required | `422` [`VALIDATION_FAILED`](https://docs.tryflume.ai/api/errors.md#validation_failed) | | `POST /sales-requests` | Optional | The call runs every time | | `POST /deep-links` | Optional | The call runs every time | | `POST /save-item` | Not used. `save_uuid` in the body does the same job | | | `POST /search` | Not used. Every call is a new search | | ## What is a valid key? - Any string of 1 to 255 characters. A UUID works well. - Use a new key for each user action. Reuse it only to retry that same action. - A key longer than 255 characters is a `400` `VALIDATION_FAILED`. ```sh curl -X POST "https://sandbox-api.tryflume.ai/partner/v1/deep-links" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "X-Flume-User-Token: $FLUME_USER_TOKEN" \ -H "Idempotency-Key: 0d3c1a52-6f0e-4d0b-9a57-1c2e8b7f4a10" \ -H "Content-Type: application/json" \ -d '{"search_session_uuid": "5f0c1d2e-8a7b-4c3d-9e1f-2a3b4c5d6e7f"}' ``` ## What happens on a retry? | Retry | Result | |---|---| | Same key, same body, first call finished | The stored answer, with the header `Idempotent-Replay: true` | | Same key, same body, first call still running | `409` [`IDEMPOTENCY_IN_PROGRESS`](https://docs.tryflume.ai/api/errors.md#idempotency_in_progress), usually with `Retry-After: 2` | | Same key, different body | `422` [`IDEMPOTENCY_KEY_REUSED`](https://docs.tryflume.ai/api/errors.md#idempotency_key_reused) | ## What is stored? - Flume keeps a key for 24 hours. - A key is scoped to your platform and to one endpoint. | First answer | Stored | A retry with the same key | |---|---|---| | A success | Yes | Replays it | | A `4xx` refusal | Yes | Replays it. Fix the request and use a new key | | A `5xx` error | No | Runs the work again | | A `429` with `Retry-After` | No | Runs the work again | | A quote that is still pricing | No | Runs again, and counts against the daily quote limit again | > **Poll, do not retry:** When a quote is still pricing, poll `GET /quotes/{quote_uuid}`. Polling is free. A retry of `POST /request-instant-quote` uses another unit of your daily limit. ## What is never replayed? A replay never repeats a secret. | Call | Field on a replay | |---|---| | `POST /keys` | `secret` is `null` | | `POST /deep-links` | `url` is `null` | ## How does save_uuid work? `POST /save-item` uses `save_uuid` from the body instead of the header. - You choose `save_uuid`. Use a new UUID per save. - The same `save_uuid` returns the stored payload, unchanged. - Another user's `save_uuid` is a `409` `SAVE_CONFLICT`. ## Why does search have no idempotency? Every `POST /search` call is a new search. Page 2 included. - Each call spends one unit of the daily search limit. - Each call returns a new `search_session_uuid`. - To read a search again without spending a unit, call `GET /searches/{search_session_uuid}`. --- # Limits Source: https://docs.tryflume.ai/concepts/limits > Flume limits calls per partner per UTC day and returns 429 RATE_LIMITED with Retry-After. This page lists every daily limit, per-user limit, size limit and lifetime. Flume counts some calls per partner per UTC day. Over a limit, the call returns `429` [`RATE_LIMITED`](https://docs.tryflume.ai/api/errors.md#rate_limited) with a `Retry-After` header. Counters reset at 00:00 UTC. ## What are the daily limits? | Call | Limit per partner per UTC day | |---|---| | `POST /search` | 1,000 | | `POST /request-instant-quote` | 200 | | `POST /users/trigger-verification` | 500 | | `POST /detect-category` | 2,000 | | `POST /deep-links` | 2,000 | - A limit is per partner, not per user. All your users share it. - Reads spend nothing: `GET /searches/{search_session_uuid}`, `GET /quotes/{quote_uuid}`, `GET /products/{sku_uuid}`, `GET /filters`. - A search that fails because search is unavailable (`503`) is not counted. - These are defaults. Flume can set a different limit for an environment. - Flume can also set a limit per user. See [Is there a limit per user?](#is-there-a-limit-per-user). ## What does a 429 look like? ```http HTTP/1.1 429 Too Many Requests Retry-After: 28412 Content-Type: application/json { "error": { "code": "RATE_LIMITED", "message": "Today's limit of 1,000 searches is used up. It resets at 00:00 UTC.", "field": null, "request_id": "e19b8889-a63b-4000-9dbe-ffd5b9157bd5" } } ``` - `Retry-After` is the number of seconds until 00:00 UTC. - Flume also protects its total capacity. The message then reads "Flume can't take more searches today." The status and code are the same. ## How do you track usage? - There are no `X-RateLimit-*` headers. Only `Retry-After` on the `429`. - No endpoint reports usage or what is left. - Count your own calls if you need to warn users before the limit. - On live, a network-level limit per IP address can also return a `429` with no JSON body and no `Retry-After`. Back off and retry. ## Is there a limit per user? Not by default. Flume can set a daily limit per user on your partner account, for searches, for instant quotes, or for both. Ask [Flume](https://docs.tryflume.ai/support.md) whether your account has one. | Counted | What counts as one | |---|---| | Search | The first page of a `POST /search`, and each new search the user runs in the iframe. A later page of the same search does not count | | Instant quote | Each `POST /request-instant-quote` that Flume accepts | - The count is per user's Flume account, per UTC day. It resets at 00:00 UTC. - Calls from your backend and the user's work in the iframe share one count. - Over the limit, the call returns `429` [`DAILY_LIMIT_REACHED`](https://docs.tryflume.ai/api/errors.md#daily_limit_reached) with a `Retry-After` header. - A call refused by the user's limit does not count against your limit per partner. - A search that fails because search is unavailable (`503`) is not counted. - In the iframe, the iframe tells the user. Your page receives no message. ```json { "error": { "code": "DAILY_LIMIT_REACHED", "message": "Today's limit of 20 searches is used up. It resets at 00:00 UTC.", "field": null, "request_id": "e19b8889-a63b-4000-9dbe-ffd5b9157bd5", "details": { "kind": "SEARCH", "limit": 20, "reset_at": "2026-11-12T00:00:00Z", "retry_after_s": 30120 } } } ``` The numbers in this sample are examples. Read the real limit from `details.limit`. | Field in `details` | Meaning | |---|---| | `kind` | `SEARCH` or `QUOTE` | | `limit` | The user's limit per UTC day | | `reset_at` | When the count resets, in UTC | | `retry_after_s` | Seconds until then. The same value as `Retry-After` | Other users are not affected. Tell this user when they can try again. ## Which other limits are there? | Limit | Value | Code | |---|---|---| | Wait before a code resend | 30 seconds | `VERIFICATION_RATE_LIMITED` | | Codes per mailbox | 5 in 24 hours | `VERIFICATION_RATE_LIMITED` | | Codes per user IP address, when you send `X-Flume-Client-IP` | 30 in 1 hour | `VERIFICATION_RATE_LIMITED` | | Open sales requests per user | 60 | `422` `VALIDATION_FAILED` | | Keys issued, sandbox | 10 in 24 hours | `KEY_ROTATION_RATE_LIMITED` | | Keys issued, live | 1 in 24 hours | `KEY_ROTATION_RATE_LIMITED` | ## What are the size limits? | Thing | Limit | |---|---| | Search photo | 6,000,000 bytes. JPEG, PNG or WebP | | Results per search | 50 | | Results per page | 24 | | Item photo Flume fetches for the iframe | 25 MB, `https`, no redirects, 10 second timeout | | `item.image_urls`, `item.document_urls` | 20 entries each | | `item.labels`, `item.rooms` | 100 entries each | | `Idempotency-Key` | 255 characters | | Active keys, sandbox | 5 | | Active keys, live | 2 | ## How long do things last? **Diagram: How long each thing lasts.** Every timer, code and link in the integration, shortest first. The scale is logarithmic. 1. Ready timer: 15 seconds. On your page. When it fires, remove the iframe and offer a retry. 2. Reply to a save: 60 seconds. After that the iframe treats the save as failed. 3. Launch code: 120 seconds, one use. Inside iframe_url. Set the iframe src at once. 4. Connect code: 120 seconds, one use. Exchange it at once. 5. Verification code: 10 minutes. The emailed code the user types in the iframe. 6. Iframe session: 12 hours. From its first open. Then flume:close_iframe with reason expired. 7. Image links: about 24 hours. In the save payload. Copy the bytes when you save. 8. Open in Flume link: 7 days, one use. Create it on the click. Never store it. 9. Instant quote prices: 30 days. Until pricing.valid_until in the save payload. 10. Unused session: 30 days. A session nobody reopens is dropped. The next open starts fresh. 11. User access token: no expiry. Replaced when the user verifies again, or revoked by you. | Thing | Lifetime | |---|---| | Verification code | 10 minutes, 3 tries | | Launch code, in `iframe_url` | 120 seconds, one use | | Connect code | 120 seconds, one use | | Iframe session | 12 hours from its first open. A reopen does not restart them | | Idle iframe session state | 30 days | | Instant quote prices | Until `valid_until`: 30 days today. Always read the field | | Deep link | 7 days, one use | | Image links | About 24 hours. In the sandbox they can stop working earlier | | Idempotency key | 24 hours | | Replaced key during rotation | `overlap_days`: default 7, 0 to 30 | | User access token | No expiry | | Search (`search_session_uuid`) | No expiry | ## How should you handle a limit? 1. Read `Retry-After`. 2. Stop sending that call until then. Other calls still work. 3. Tell the user plainly. `error.message` is written for them. 4. [Ask Flume](https://docs.tryflume.ai/support.md) if you need a higher limit. --- # Glossary Source: https://docs.tryflume.ai/concepts/glossary > Every term the Flume partner docs use, defined once. Partner key, user access token, item, iframe session, launch code, connect code, save payload and more. Every term these docs use, defined once. Each term has one name on every page. | Term | Meaning | |---|---| | Platform | Your product: the software your users work in. A partner key identifies it | | Partner key | The secret that identifies your platform. Sent as `X-Flume-Partner-Key`. See [Authentication and keys](https://docs.tryflume.ai/concepts/authentication.md) | | User | One person on your platform. You name them with `external_ref` | | Verify | What a user does once to prove their email: they type a 6-digit code. See [Users and verification](https://docs.tryflume.ai/concepts/users.md) | | User access token | The secret that lets you call the API for one verified user. Sent as `X-Flume-User-Token` | | Flume account | The user's own account at Flume. Flume opens or links one when the user verifies | | Flume app | Flume's own website, where a user sees their searches, quotes and projects | | Project | A job the user is working on. You name it with `project.external_ref`. Flume files searches and quotes under it | | Item | The thing the user wants a product for, such as one line of a schedule. You name it with `item.external_id` | | Product | One tile or vinyl floor in Flume's catalog. Flume names it with `sku_uuid` | | Search | One photo search. Flume names it with `search_session_uuid` | | Strong match | A search result that meets every filter and looks closest to the photo | | Instant quote | The delivered price of one product for a quantity and a US ZIP code | | Shipping option | One way a product ships: from a US warehouse, or from the factory abroad. A quote has one price per option | | Save payload | The JSON object Flume hands you when a user saves a product. See [Save payload](https://docs.tryflume.ai/concepts/save-payload.md) | | Sales request | A request for Flume's sales team, for what Flume cannot search or price instantly | | Deep link | A one-time link that opens the Flume app with the user signed in. See [Deep links](https://docs.tryflume.ai/concepts/deep-links.md) | | Iframe | The page Flume hosts and you frame. It runs the whole find, price and save flow | | Your page | The page on your platform that frames the iframe | | Iframe session | One user working on one item in the iframe. Flume names it with `session_uuid` | | Launch code | The single-use code inside `iframe_url`. It lets the iframe start once | | Connect code | The single-use code your page receives after a user verifies in the iframe. Your backend exchanges it for the user access token | | Ready timer | The 15 second timer your page runs while it waits for the iframe to load | | Sandbox | The environment for building. See [Environments](https://docs.tryflume.ai/concepts/environments.md) | | Live | The environment for real users. Access is by request | --- # API overview Source: https://docs.tryflume.ai/api > The Flume Partner API is a JSON API over HTTPS under /partner/v1. This page covers base URLs, headers, formats, pagination, versioning and what is not built. The Flume Partner API is a JSON API over HTTPS. Your backend calls it with a partner key. This page covers what every endpoint shares. ## How it fits together An API integration makes these calls, in this order. The [API quickstart](https://docs.tryflume.ai/quickstart/api.md) runs each one. 1. **Verify a user: send the code.** [`POST /users/trigger-verification`](https://docs.tryflume.ai/api/trigger-verification.md). Your first call for a new user. Flume emails them a 6-digit code, and you show a code box. 2. **Verify a user: confirm the code.** [`POST /users/confirm-verification`](https://docs.tryflume.ai/api/confirm-verification.md). The right code opens the user's Flume account and returns their access token. Keep it against the user. It does not expire. 3. **Search by photo.** [`POST /search`](https://docs.tryflume.ai/api/search.md). A photo and the user's filters give two lists, strong matches and other similar options, plus a `search_session_uuid`. 4. **Product detail.** [`GET /products/{sku_uuid}`](https://docs.tryflume.ai/api/get-product.md). Photos, price, specification and shipping. 5. **Instant quote.** [`POST /request-instant-quote`](https://docs.tryflume.ai/api/request-instant-quote.md). The delivered price for a quantity and a ZIP code. A quote that is still pricing is read with [`GET /quotes/{quote_uuid}`](https://docs.tryflume.ai/api/get-quote.md). 6. **Open Flume, signed in.** [`POST /deep-links`](https://docs.tryflume.ai/api/create-deep-link.md). A one-time link into the user's Flume account, where every search and quote you made for them is waiting. Supporting calls: [`GET /filters`](https://docs.tryflume.ai/api/list-filters.md), [`DELETE /users/{external_ref}/token`](https://docs.tryflume.ai/api/revoke-user-token.md), [`GET /keys`](https://docs.tryflume.ai/api/list-keys.md), [`POST /keys`](https://docs.tryflume.ai/api/rotate-key.md), [`DELETE /keys/{key_id}`](https://docs.tryflume.ai/api/revoke-key.md). **Two headers.** Every call carries your partner key as `X-Flume-Partner-Key`. Steps 3 to 6 also carry the user access token as `X-Flume-User-Token`. Neither works alone. Start with a sandbox key, then a user access token once you have one. Prices are US dollars, sent as text with 2 decimals, for example `"6.40"`. ## Base URL ```text https://sandbox-api.tryflume.ai/partner/v1 ``` | Environment | Base URL | |---|---| | Sandbox | `https://sandbox-api.tryflume.ai/partner/v1` | | Live | `https://ssapi.tryflume.ai/partner/v1` | Live is in early access and not yet open to new partners. See [Environments](https://docs.tryflume.ai/concepts/environments.md). ## Endpoints | Area | Endpoints | |---|---| | Users | [`POST /users/trigger-verification`](https://docs.tryflume.ai/api/trigger-verification.md), [`POST /users/confirm-verification`](https://docs.tryflume.ai/api/confirm-verification.md), [`DELETE /users/{external_ref}/token`](https://docs.tryflume.ai/api/revoke-user-token.md) | | Search | [`GET /filters`](https://docs.tryflume.ai/api/list-filters.md), [`POST /search`](https://docs.tryflume.ai/api/search.md) | | Products | [`GET /products/{sku_uuid}`](https://docs.tryflume.ai/api/get-product.md) | | Quotes | [`POST /request-instant-quote`](https://docs.tryflume.ai/api/request-instant-quote.md), [`GET /quotes/{quote_uuid}`](https://docs.tryflume.ai/api/get-quote.md) | | Deep links | [`POST /deep-links`](https://docs.tryflume.ai/api/create-deep-link.md) | | Keys | [`GET /keys`](https://docs.tryflume.ai/api/list-keys.md), [`POST /keys`](https://docs.tryflume.ai/api/rotate-key.md), [`DELETE /keys/{key_id}`](https://docs.tryflume.ai/api/revoke-key.md) | ## Request headers | Header | When | Notes | |---|---|---| | `X-Flume-Partner-Key` | Every call | Your partner key | | `X-Flume-User-Token` | Calls made for a user | The user's access token | | `Idempotency-Key` | Some `POST` calls | 1 to 255 characters. See [Idempotency](https://docs.tryflume.ai/concepts/idempotency.md) | | `X-Flume-Client-IP` | `POST /users/trigger-verification`, optional | The user's own IPv4 or IPv6 address. Adds a limit per IP address | | `Content-Type` | Calls with a body | `application/json`, or `multipart/form-data` for `POST /search` | See [Authentication and keys](https://docs.tryflume.ai/concepts/authentication.md). ## Response headers | Header | When | Notes | |---|---|---| | `X-Request-Id` | Every response | Quote it to Flume support | | `Retry-After` | Most `409` `IDEMPOTENCY_IN_PROGRESS` and `429` responses | Seconds to wait | | `Idempotent-Replay` | A replayed answer | `true` | ## Formats | Thing | Format | Example | |---|---|---| | Bodies | JSON, UTF-8 | | | Ids | UUID strings | `7f3c2a91-4b0e-4c8d-9a21-1f6b2d5e7c30` | | Money | Decimal string, 2 places, USD | `"6.40"` | | Timestamps | UTC, ISO 8601, with `Z` | `2026-10-16T09:14:22Z` | | Dates | ISO 8601 | `2027-03-01` | | Quantities | Square feet | `6000` | | Countries | Two letters | `ES` | | ZIP codes | 5 digits | `22102` | ## Code samples Every reference page shows the call in curl, Python and Node.js. - Samples read `FLUME_PARTNER_KEY` and, for calls made for a user, `FLUME_USER_TOKEN` from the environment. - Ids in a sample are examples. Use ids from your own responses. - The Python samples need `requests`. - The Node.js samples use top-level `await`. Save them as `.mjs`, with Node.js 20 or later. ## Strict requests Request bodies are strict. A field the API does not know is a `422` [`VALIDATION_FAILED`](https://docs.tryflume.ai/api/errors.md#validation_failed) that names it. One exception: `item`, and its `labels` and `rooms`, accept any extra fields. Flume stores them as sent. ## Errors Every error is JSON in one envelope. Branch on `error.code`. ```json { "error": { "code": "VALIDATION_FAILED", "message": "destination_zip: String should match pattern '^\\d{5}$'", "field": "destination_zip", "request_id": "e19b8889-a63b-4000-9dbe-ffd5b9157bd5" } } ``` Every code, with its status and fix: [API error codes](https://docs.tryflume.ai/api/errors.md). ## Pagination Only `POST /search` pages. - Send `page` and `page_size` in the payload. - Read `has_more` and `total` in the response. - At most 50 results per search and 24 per page. - There are no cursors. - Each page is a new search. See [Idempotency](https://docs.tryflume.ai/concepts/idempotency.md#why-does-search-have-no-idempotency). `GET /keys` returns every key, unpaged. ## Versioning - The version is in the path: `/partner/v1`. - The OpenAPI document's `info.version` is `2.0.0`. It counts revisions of the v1 contract. - Flume has no published deprecation policy yet. Changes are listed in the [changelog](https://docs.tryflume.ai/changelog.md). - Write your client to ignore response fields it does not know. ## OpenAPI - [`/openapi.json`](https://docs.tryflume.ai/openapi.json) is the OpenAPI 3.1 document this reference is generated from. - It lists the server-to-server calls. Calls only the Flume iframe makes are left out. ## Optional and null - In a **request**, a field marked Optional can be left out. - In a **response**, every field is always present. A field with no value is `null`. The reference shows this as "or null" after the type. ## What is not available Webhooks, SDKs, rate limit headers and list endpoints do not exist today. The full list is on the [overview](https://docs.tryflume.ai/index.md#what-exists-today). ## Try a call without writing code - Or import [`/openapi.json`](https://docs.tryflume.ai/openapi.json) into an HTTP client such as Postman or Bruno. There is no ready-made collection. --- # API error codes Source: https://docs.tryflume.ai/api/errors > Every Flume Partner API error code with its HTTP status, what it means and how to fix it. All errors share one JSON envelope with a stable code. Every error from the Flume Partner API is JSON in one envelope, with a stable `code`. This page lists every code, its HTTP status, what it means and how to fix it. ## The error envelope ```json { "error": { "code": "SKU_NOT_IN_SEARCH", "message": "This product was not a result of that search. Use the search_session_uuid of the search (or the page) that returned it.", "field": "sku_uuid", "request_id": "e19b8889-a63b-4000-9dbe-ffd5b9157bd5" } } ``` | Field | Type | Notes | |---|---|---| | `code` | string | Stable. Branch on it | | `message` | string | For a person. It can change | | `field` | string or null | The request field at fault, as a dotted path | | `request_id` | string | Quote it to Flume support. Also in the `X-Request-Id` header | | `details` | object | Sometimes present, with more about a refusal | ## All codes | Code | Status | |---|---| | [`PARTNER_KEY_INVALID`](#partner_key_invalid) | 401 | | [`EMBED_TOKEN_INVALID`](#embed_token_invalid) | 401 | | [`USER_TOKEN_INVALID`](#user_token_invalid) | 401 | | [`USER_NOT_CONNECTED`](#user_not_connected) | 403 | | [`USER_ACCOUNT_CLOSED`](#user_account_closed) | 403 | | [`CONNECT_CODE_INVALID`](#connect_code_invalid) | 401 | | [`VERIFICATION_CODE_INVALID`](#verification_code_invalid) | 422 | | [`VERIFICATION_TRIES_EXHAUSTED`](#verification_tries_exhausted) | 422 | | [`VERIFICATION_CODE_EXPIRED`](#verification_code_expired) | 422 | | [`VERIFICATION_RATE_LIMITED`](#verification_rate_limited) | 429 | | [`VERIFICATION_EMAIL_FAILED`](#verification_email_failed) | 502 | | [`VALIDATION_FAILED`](#validation_failed) | 422 | | [`NOT_FOUND`](#not_found) | 404 | | [`SESSION_NOT_FOUND`](#session_not_found) | 404 | | [`SKU_NOT_IN_SEARCH`](#sku_not_in_search) | 422 | | [`IMAGE_REQUIRED`](#image_required) | 400 | | [`IMAGE_TOO_LARGE`](#image_too_large) | 413 | | [`IMAGE_UNSUPPORTED`](#image_unsupported) | 422 | | [`SEARCH_UNAVAILABLE`](#search_unavailable) | 503 | | [`BELOW_MINIMUM_ORDER`](#below_minimum_order) | 400 | | [`NOT_INSTANTLY_QUOTABLE`](#not_instantly_quotable) | 400 | | [`QUOTE_NOT_READY`](#quote_not_ready) | 422 | | [`IDEMPOTENCY_KEY_REQUIRED`](#idempotency_key_required) | 400 | | [`IDEMPOTENCY_KEY_REUSED`](#idempotency_key_reused) | 422 | | [`IDEMPOTENCY_IN_PROGRESS`](#idempotency_in_progress) | 409 | | [`KEY_LIMIT_REACHED`](#key_limit_reached) | 409 | | [`KEY_ROTATION_RATE_LIMITED`](#key_rotation_rate_limited) | 429 | | [`LAST_ACTIVE_KEY`](#last_active_key) | 409 | | [`RATE_LIMITED`](#rate_limited) | 429 | | [`DAILY_LIMIT_REACHED`](#daily_limit_reached) | 429 | | [`INTERNAL`](#internal) | 500 | ## Responses outside the envelope | Response | When | |---|---| | `403` with no JSON body | On live, a request from an unsupported country. See [Environments](https://docs.tryflume.ai/concepts/environments.md) | | `429` with no JSON body and no `Retry-After` | On live, a network-level limit per IP address. Back off and retry | | No response | A network timeout. Retry a write with the same `Idempotency-Key` | ## Credentials ### `PARTNER_KEY_INVALID` - **Status:** 401. - **Returned by:** Every call. - **Meaning:** The partner key is missing, unknown, revoked, expired, or from the other environment. On live, also a platform Flume has not enabled yet. - **Fix:** Check the header name `X-Flume-Partner-Key` and the environment. Rotate the key if it was revoked. ### `EMBED_TOKEN_INVALID` - **Status:** 401. - **Returned by:** Every call. - **Meaning:** You sent an `Authorization` header and no `X-Flume-Partner-Key`. Flume read the call as one from its own iframe. - **Fix:** Send the partner key in `X-Flume-Partner-Key`. Never send it in `Authorization`. ### `USER_TOKEN_INVALID` - **Status:** 401. - **Returned by:** Calls that read `X-Flume-User-Token`. - **Meaning:** The user access token is unknown, revoked, another partner's, or replaced by a later verification. A verification in the iframe replaces it too. - **Fix:** Delete the stored token. Exchange the connect code if you have one. Otherwise verify the user again. ### `USER_NOT_CONNECTED` - **Status:** 403. - **Returned by:** Calls made for a user. - **Meaning:** No `X-Flume-User-Token` header was sent. - **Fix:** Send the user's access token. If you hold none, verify the user first. ### `USER_ACCOUNT_CLOSED` - **Status:** 403 on calls made for a user. 422 with `field: email` from the verification calls. - **Returned by:** Calls made for a user, and the two verification calls. - **Meaning:** The user's email belongs to a Flume account that Flume has closed. - **Fix:** Do not retry. The user contacts Flume. ### `CONNECT_CODE_INVALID` - **Status:** 401. - **Returned by:** `POST /users/exchange-connect-code`. - **Meaning:** The connect code is used, older than 120 seconds, unknown, or another partner's. - **Fix:** Exchange each code once, at once. The user verifies again on the next open. ## Verification ### `VERIFICATION_CODE_INVALID` - **Status:** 422. - **Returned by:** `POST /users/confirm-verification`. - **Meaning:** The code is wrong. The message says how many tries are left. - **Fix:** Let the user type it again. ### `VERIFICATION_TRIES_EXHAUSTED` - **Status:** 422. - **Returned by:** `POST /users/confirm-verification`. - **Meaning:** The code was wrong 3 times. It is burned. - **Fix:** Send a new code. ### `VERIFICATION_CODE_EXPIRED` - **Status:** 422. - **Returned by:** `POST /users/confirm-verification`. - **Meaning:** No code is valid: it is older than 10 minutes, already used, or was never sent. - **Fix:** Send a new code. ### `VERIFICATION_RATE_LIMITED` - **Status:** 429, with `Retry-After`. - **Returned by:** `POST /users/trigger-verification`. - **Meaning:** A resend came too soon, or the mailbox reached its limit. When you send `X-Flume-Client-IP`, the limit per IP address applies too. - **Fix:** Wait `Retry-After` seconds. ### `VERIFICATION_EMAIL_FAILED` - **Status:** 502. - **Returned by:** `POST /users/trigger-verification`. - **Meaning:** The email could not be sent. - **Fix:** Retry with backoff. ## Requests ### `VALIDATION_FAILED` - **Status:** 422. 400 for an `Idempotency-Key` over 255 characters. 405 for a wrong HTTP method. - **Returned by:** Every call. - **Meaning:** A field is missing, unknown or has an unusable value. `field` names it as a dotted path. Some refusals carry the detail in `details` and leave `field` empty. - **Fix:** Fix the field. Request bodies are strict: remove fields the API does not list. ### `NOT_FOUND` - **Status:** 404. - **Returned by:** Calls that name a product, quote, key, save or user. Also any unknown path. - **Meaning:** The thing does not exist, or is not this user's. A search id that is not a UUID also returns this code. - **Fix:** Check the id and that it belongs to this user. ### `SESSION_NOT_FOUND` - **Status:** 404. - **Returned by:** Calls that take `search_session_uuid`. - **Meaning:** The search does not exist, or is another user's. - **Fix:** Use a `search_session_uuid` from one of this user's searches. ### `SKU_NOT_IN_SEARCH` - **Status:** 422. - **Returned by:** `POST /request-instant-quote`, `POST /save-item`, `POST /deep-links`, `POST /sales-requests`. - **Meaning:** The product was not a result of the search you named. - **Fix:** Use the `search_session_uuid` of the search, or the page, that returned the product. ## Search ### `IMAGE_REQUIRED` - **Status:** 400. - **Returned by:** `POST /search`. - **Meaning:** The request has no `image` part, or the part is empty. - **Fix:** Send the photo as the `image` part of `multipart/form-data`. On a retry, read the file again. ### `IMAGE_TOO_LARGE` - **Status:** 413. - **Returned by:** `POST /search`. - **Meaning:** The photo is over 6,000,000 bytes. - **Fix:** Resize or compress the photo. ### `IMAGE_UNSUPPORTED` - **Status:** 422. - **Returned by:** `POST /search`. - **Meaning:** The file is not a JPEG, PNG or WebP, or it is over 50 megapixels. Flume reads the bytes, not the file name. - **Fix:** Convert or resize the photo. ### `SEARCH_UNAVAILABLE` - **Status:** 503. - **Returned by:** `POST /search`. - **Meaning:** Search is briefly unavailable. The call is not counted against your limit. - **Fix:** Retry with backoff. ## Quotes and saves ### `BELOW_MINIMUM_ORDER` - **Status:** 400. - **Returned by:** `POST /request-instant-quote`. - **Meaning:** The quantity is under the product's minimum order. The message states the minimum. - **Fix:** Raise `quantity_sqft`. Read `minimum_order_sqft` on the product's shipping options. ### `NOT_INSTANTLY_QUOTABLE` - **Status:** 400. - **Returned by:** `POST /request-instant-quote`. - **Meaning:** Flume's sales team prices this product. There is no instant quote. - **Fix:** Check `price_on_request` first. File a sales request. ### `QUOTE_NOT_READY` - **Status:** 422. - **Returned by:** `POST /save-item`, `POST /deep-links`. - **Meaning:** The quote is not `READY`. On `POST /save-item` it also covers a quote that expired, is not this user's, lacks the option you named, or is under that option's minimum order. - **Fix:** Request a new quote, then try again. ## Idempotency ### `IDEMPOTENCY_KEY_REQUIRED` - **Status:** 400. - **Returned by:** `POST /request-instant-quote`, `POST /keys`. - **Meaning:** The `Idempotency-Key` header is missing. - **Fix:** Send one key per user action. ### `IDEMPOTENCY_KEY_REUSED` - **Status:** 422. - **Returned by:** Calls that take `Idempotency-Key`. - **Meaning:** The key was already used with a different body. - **Fix:** Use a new key for a new request. ### `IDEMPOTENCY_IN_PROGRESS` - **Status:** 409, usually with `Retry-After: 2`. - **Returned by:** Calls that take `Idempotency-Key`. - **Meaning:** The first call with this key is still running. - **Fix:** Wait 2 seconds. Retry with the same key. ## Keys ### `KEY_LIMIT_REACHED` - **Status:** 409. - **Returned by:** `POST /keys`. - **Meaning:** You already hold the maximum number of active keys. - **Fix:** Revoke a key you no longer use. ### `KEY_ROTATION_RATE_LIMITED` - **Status:** 429, with `Retry-After: 86400`. - **Returned by:** `POST /keys`. - **Meaning:** You reached the number of keys you can issue in 24 hours. Your current key keeps working. - **Fix:** Wait, then rotate. For a leaked key on live, contact Flume. ### `LAST_ACTIVE_KEY` - **Status:** 409. - **Returned by:** `DELETE /keys/{key_id}`. - **Meaning:** This is your only working key. - **Fix:** Issue a replacement first. ## Limits and server ### `RATE_LIMITED` - **Status:** 429, with `Retry-After`. - **Returned by:** Calls with a daily limit. - **Meaning:** Today's limit for this call is used up. `Retry-After` is the number of seconds until 00:00 UTC. - **Fix:** Stop that call until then. See [Limits](https://docs.tryflume.ai/concepts/limits.md). ### `DAILY_LIMIT_REACHED` - **Status:** 429, with `Retry-After`. - **Returned by:** `POST /search`, `POST /request-instant-quote`. - **Meaning:** This user's own daily limit of searches or instant quotes is used up. Flume sets this limit per partner account, and it is off by default. `details` holds `kind`, `limit`, `reset_at` and `retry_after_s`. - **Fix:** Tell the user when they can try again, from `details.reset_at`. Other users are not affected. See [Limits](https://docs.tryflume.ai/concepts/limits.md#is-there-a-limit-per-user). ### `INTERNAL` - **Status:** 500. - **Returned by:** Every call. - **Meaning:** Something failed on Flume's side. - **Fix:** Retry with backoff. Keep the same `Idempotency-Key`. Send `request_id` to Flume if it persists. ## What is not an error - `POST /request-instant-quote` returns `200` with `status: FAILED` when it could not price. Read `failure_reason`. See [Get an instant quote](https://docs.tryflume.ai/guides/get-an-instant-quote.md). - `POST /detect-category` returns `200` for every verdict, including `error`. ## See also - [Handle errors](https://docs.tryflume.ai/guides/handle-errors.md): what to retry - [Limits](https://docs.tryflume.ai/concepts/limits.md) --- # Send a verification code Source: https://docs.tryflume.ai/api/trigger-verification > POST /users/trigger-verification: Emails a 6-digit verification code to a user. Emails a 6-digit verification code to a user. ```http POST https://sandbox-api.tryflume.ai/partner/v1/users/trigger-verification ``` - Send `external_ref`, your permanent id for the user, and `email`. - The code is valid for 10 minutes. - No account or token exists until the code is confirmed. - Call again to resend after `resend_after_s` seconds, or to update the user's details. - Send the user's IP address as `X-Flume-Client-IP`. Flume then also limits codes per IP address. - Codes count against a daily limit per partner. ## Request ### Headers | Header | Required | Description | |---|---|---| | `X-Flume-Partner-Key` | Yes | Your partner key. | | `X-Flume-Client-IP` | No | The user's own IP address: one IPv4 or IPv6 address. When sent, Flume also limits codes per IP address. Without it, only the limits per mailbox apply. | ### Body (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `external_ref` | string | Yes | Your permanent id for the user. 1 to 128 characters. | | `email` | string | Yes | Where the code goes. It is also the user's Flume sign-in address. Max 320 characters. Pattern `^[^@\s]+@[^@\s]+\.[^@\s]+$`. | | `first_name` | string or null | No | The user's first name. Max 128 characters. | | `last_name` | string or null | No | The user's last name. Max 128 characters. | | `phone` | string or null | No | A US phone number, in any common format. Max 32 characters. | | `org` | object or null | No | The firm the user works at. | | `org.external_ref` | string or null | No | Your id for the firm. Max 128 characters. | | `org.name` | string or null | No | The firm's name. Max 255 characters. | | `org.website_domain` | string or null | No | The firm's website domain. Max 255 characters. | | `method` | enum | No | How the user proves their email. `email_code` is the only method today. Default `"email_code"`. | ### Sample **curl** ```sh curl -X POST "https://sandbox-api.tryflume.ai/partner/v1/users/trigger-verification" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "Content-Type: application/json" \ -d '{ "external_ref": "u_8812", "email": "sarah.chen@example.com", "first_name": "Sarah", "last_name": "Chen", "org": { "name": "NY Design Co" } }' ``` ## Returns (202) Every field is always present. A field with no value is `null`. | Field | Type | Description | |---|---|---| | `external_ref` | string | Your id for the user. | | `masked_email` | string | The address the code went to, masked. Safe to show the user. | | `expires_in_s` | integer | How long the code is valid, in seconds. | | `resend_after_s` | integer | How long to wait before you ask for another code for this user, in seconds. | **Response (202)** ```json { "external_ref": "u_8812", "masked_email": "s···@example.com", "expires_in_s": 600, "resend_after_s": 30 } ``` ## Errors | Status | Code | Meaning | |---|---|---| | 401 | `EMBED_TOKEN_INVALID` | You sent an `Authorization` header and no `X-Flume-Partner-Key`. Flume read the call as one from its own iframe. | | 401 | `PARTNER_KEY_INVALID` | The partner key is missing, unknown, revoked, expired, or from the other environment. On live, also a platform Flume has not enabled yet. | | 401 | `USER_TOKEN_INVALID` | The user access token is unknown, revoked, another partner's, or replaced by a later verification. A verification in the iframe replaces it too. | | 422 | `USER_ACCOUNT_CLOSED` | The user's email belongs to a Flume account that Flume has closed. | | 422 | `VALIDATION_FAILED` | A field is missing, unknown or has an unusable value. `field` names it as a dotted path. Some refusals carry the detail in `details` and leave `field` empty. | | 429 | `RATE_LIMITED` | Today's limit for this call is used up. `Retry-After` is the number of seconds until 00:00 UTC. | | 429 | `VERIFICATION_RATE_LIMITED` | A resend came too soon, or the mailbox reached its limit. When you send `X-Flume-Client-IP`, the limit per IP address applies too. | | 500 | `INTERNAL` | Something failed on Flume's side. | | 502 | `VERIFICATION_EMAIL_FAILED` | The email could not be sent. | Every code, with its fix: [API error codes](https://docs.tryflume.ai/api/errors.md). Guide: [Verify a user](https://docs.tryflume.ai/guides/verify-a-user.md). --- # Confirm a verification code Source: https://docs.tryflume.ai/api/confirm-verification > POST /users/confirm-verification: Confirms the code a user typed and returns their user access token. Confirms the code a user typed and returns their user access token. ```http POST https://sandbox-api.tryflume.ai/partner/v1/users/confirm-verification ``` - Flume opens a Flume account for the user, or links the one their email already has. - `access_token` is returned once and has no expiry. Store it as a secret, against the user. - Three wrong tries burn the code. After that, or after 10 minutes, send a new one. - Confirming again issues a new token and stops the old one. ## Request ### Headers | Header | Required | Description | |---|---|---| | `X-Flume-Partner-Key` | Yes | Your partner key. | ### Body (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `method` | enum | No | The method used to send the code. `email_code` is the only method today. Default `"email_code"`. | | `email` | string | Yes | The address the code was sent to. Max 320 characters. Pattern `^[^@\s]+@[^@\s]+\.[^@\s]+$`. | | `code` | string | Yes | The 6 digits from the email. 6 digits. | | `external_ref` | string or null | No | Your id for the user. Needed only when two of your users share one email. Max 128 characters. | ### Sample **curl** ```sh curl -X POST "https://sandbox-api.tryflume.ai/partner/v1/users/confirm-verification" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "Content-Type: application/json" \ -d '{ "email": "sarah.chen@example.com", "code": "482913" }' ``` ## Returns (200) Every field is always present. A field with no value is `null`. | Field | Type | Description | |---|---|---| | `external_ref` | string | Your id for the user. Check that it is the user you expect before you store the token. | | `email` | string | The verified address. | | `access_token` | string | The user access token. Returned once. Flume keeps only a hash of it. | | `token_type` | string | Always `user_token`. Always `"user_token"`. Default `"user_token"`. | | `verified_at` | string | When the user verified their email, in UTC. | | `account` | enum | `CREATED`: Flume opened a new Flume account with the details you sent. `LINKED`: the email already had a Flume account, and Flume linked it unchanged. | **Response (200)** ```json { "external_ref": "u_8812", "email": "sarah.chen@example.com", "access_token": "ut_dev_acme_example_token", "token_type": "user_token", "verified_at": "2026-10-16T09:02:11Z", "account": "CREATED" } ``` ## Errors | Status | Code | Meaning | |---|---|---| | 401 | `EMBED_TOKEN_INVALID` | You sent an `Authorization` header and no `X-Flume-Partner-Key`. Flume read the call as one from its own iframe. | | 401 | `PARTNER_KEY_INVALID` | The partner key is missing, unknown, revoked, expired, or from the other environment. On live, also a platform Flume has not enabled yet. | | 401 | `USER_TOKEN_INVALID` | The user access token is unknown, revoked, another partner's, or replaced by a later verification. A verification in the iframe replaces it too. | | 404 | `NOT_FOUND` | The thing does not exist, or is not this user's. A search id that is not a UUID also returns this code. | | 422 | `USER_ACCOUNT_CLOSED` | The user's email belongs to a Flume account that Flume has closed. | | 422 | `VALIDATION_FAILED` | A field is missing, unknown or has an unusable value. `field` names it as a dotted path. Some refusals carry the detail in `details` and leave `field` empty. | | 422 | `VERIFICATION_CODE_EXPIRED` | No code is valid: it is older than 10 minutes, already used, or was never sent. | | 422 | `VERIFICATION_CODE_INVALID` | The code is wrong. The message says how many tries are left. | | 422 | `VERIFICATION_TRIES_EXHAUSTED` | The code was wrong 3 times. It is burned. | | 500 | `INTERNAL` | Something failed on Flume's side. | Every code, with its fix: [API error codes](https://docs.tryflume.ai/api/errors.md). Guide: [Verify a user](https://docs.tryflume.ai/guides/verify-a-user.md). --- # Revoke a user's access token Source: https://docs.tryflume.ai/api/revoke-user-token > DELETE /users/{external_ref}/token: Revokes a user's access token at once. Revokes a user's access token at once. ```http DELETE https://sandbox-api.tryflume.ai/partner/v1/users/{external_ref}/token ``` - Every iframe session opened with it ends at its next call. - Verify the user again to issue a new token. ## Request ### Headers | Header | Required | Description | |---|---|---| | `X-Flume-Partner-Key` | Yes | Your partner key. | ### Path parameters | Parameter | Type | Description | |---|---|---| | `external_ref` | string | Your id for the user, as you sent it when the user verified. | ### Sample **curl** ```sh curl -X DELETE "https://sandbox-api.tryflume.ai/partner/v1/users/u_8812/token" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" ``` ## Returns (204) **Response (204)**: no body. ## Errors | Status | Code | Meaning | |---|---|---| | 401 | `EMBED_TOKEN_INVALID` | You sent an `Authorization` header and no `X-Flume-Partner-Key`. Flume read the call as one from its own iframe. | | 401 | `PARTNER_KEY_INVALID` | The partner key is missing, unknown, revoked, expired, or from the other environment. On live, also a platform Flume has not enabled yet. | | 404 | `NOT_FOUND` | The thing does not exist, or is not this user's. A search id that is not a UUID also returns this code. | | 422 | `VALIDATION_FAILED` | A field is missing, unknown or has an unusable value. `field` names it as a dotted path. Some refusals carry the detail in `details` and leave `field` empty. | | 500 | `INTERNAL` | Something failed on Flume's side. | Every code, with its fix: [API error codes](https://docs.tryflume.ai/api/errors.md). Guide: [Users and verification](https://docs.tryflume.ai/concepts/users.md). --- # List filter options Source: https://docs.tryflume.ai/api/list-filters > GET /filters: Returns every value each search filter accepts, with a label to show. Returns every value each search filter accepts, with a label to show. ```http GET https://sandbox-api.tryflume.ai/partner/v1/filters ``` - Build your filter menus from this call. New values then appear without a release. ## Request ### Headers | Header | Required | Description | |---|---|---| | `X-Flume-Partner-Key` | Yes | Your partner key. | ### Sample **curl** ```sh curl -X GET "https://sandbox-api.tryflume.ai/partner/v1/filters" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" ``` ## Returns (200) Every field is always present. A field with no value is `null`. | Field | Type | Description | |---|---|---| | `tile` | object | Values for a `TILE` search. | | `tile.material` | array of objects | Values for `tile_filters.material`. | | `tile.material[].value` | string or integer | The value to send. | | `tile.material[].label` | string | The text to show. | | `tile.material[].group` | string or null | The group an application area belongs to. | | `tile.material[].also_matches` | array of strings or null | The similar tile finishes this finish also finds. | | `tile.finish` | array of objects | Values for `tile_filters.finish` and `tile_filters.finish_exclude`. | | `tile.finish[].value` | string or integer | The value to send. | | `tile.finish[].label` | string | The text to show. | | `tile.finish[].group` | string or null | The group an application area belongs to. | | `tile.finish[].also_matches` | array of strings or null | The similar tile finishes this finish also finds. | | `tile.application_area` | array of objects | Values for `tile_filters.application_area`. | | `tile.application_area[].value` | string or integer | The value to send. | | `tile.application_area[].label` | string | The text to show. | | `tile.application_area[].group` | string or null | The group an application area belongs to. | | `tile.application_area[].also_matches` | array of strings or null | The similar tile finishes this finish also finds. | | `flooring` | object | Values for a `FLOORING` search. | | `flooring.wear_layer_mil` | array of objects | Values for `flooring_filters.wear_layer_mil`. | | `flooring.wear_layer_mil[].value` | string or integer | The value to send. | | `flooring.wear_layer_mil[].label` | string | The text to show. | | `flooring.wear_layer_mil[].group` | string or null | The group an application area belongs to. | | `flooring.wear_layer_mil[].also_matches` | array of strings or null | The similar tile finishes this finish also finds. | | `flooring.installation_method` | array of objects | Values for `flooring_filters.installation_method`. | | `flooring.installation_method[].value` | string or integer | The value to send. | | `flooring.installation_method[].label` | string | The text to show. | | `flooring.installation_method[].group` | string or null | The group an application area belongs to. | | `flooring.installation_method[].also_matches` | array of strings or null | The similar tile finishes this finish also finds. | | `flooring.finish` | array of objects | Values for `flooring_filters.finish`. | | `flooring.finish[].value` | string or integer | The value to send. | | `flooring.finish[].label` | string | The text to show. | | `flooring.finish[].group` | string or null | The group an application area belongs to. | | `flooring.finish[].also_matches` | array of strings or null | The similar tile finishes this finish also finds. | | `flooring.rigid_core` | array of objects | Values for `flooring_filters.rigid_core`. | | `flooring.rigid_core[].value` | string or integer | The value to send. | | `flooring.rigid_core[].label` | string | The text to show. | | `flooring.rigid_core[].group` | string or null | The group an application area belongs to. | | `flooring.rigid_core[].also_matches` | array of strings or null | The similar tile finishes this finish also finds. | **Response (200)** ```json { "tile": { "material": [ { "value": "PORCELAIN", "label": "Porcelain" }, { "value": "CERAMIC", "label": "Ceramic" } ], "finish": [ { "value": "MATTE", "label": "Matte", "also_matches": [ "NATURAL", "HONED", "ANTI_SLIP" ] }, { "value": "GLOSSY_POLISHED", "label": "Glossy / Polished", "also_matches": [ "LAPPATO_SEMI_POLISHED" ] } ], "application_area": [ { "value": "INTERIOR_DRY_FLOOR", "label": "Interior Dry Floor", "group": "FLOORS" }, { "value": "SHOWER_WALL", "label": "Shower Wall", "group": "WALLS" } ] }, "flooring": { "wear_layer_mil": [ { "value": 20, "label": "20 mil" }, { "value": 28, "label": "28 mil and up" } ], "installation_method": [ { "value": "CLICK_LOCK", "label": "Click Lock" } ], "finish": [ { "value": "MATTE", "label": "Matte" } ], "rigid_core": [ { "value": "SPC", "label": "SPC" } ] } } ``` ## Errors | Status | Code | Meaning | |---|---|---| | 401 | `EMBED_TOKEN_INVALID` | You sent an `Authorization` header and no `X-Flume-Partner-Key`. Flume read the call as one from its own iframe. | | 401 | `PARTNER_KEY_INVALID` | The partner key is missing, unknown, revoked, expired, or from the other environment. On live, also a platform Flume has not enabled yet. | | 401 | `USER_TOKEN_INVALID` | The user access token is unknown, revoked, another partner's, or replaced by a later verification. A verification in the iframe replaces it too. | | 422 | `VALIDATION_FAILED` | A field is missing, unknown or has an unusable value. `field` names it as a dotted path. Some refusals carry the detail in `details` and leave `field` empty. | | 500 | `INTERNAL` | Something failed on Flume's side. | Every code, with its fix: [API error codes](https://docs.tryflume.ai/api/errors.md). Guide: [Find products by photo](https://docs.tryflume.ai/guides/find-products-by-photo.md). --- # Search by photo Source: https://docs.tryflume.ai/api/search > POST /search: Returns products that look like a photo and match the filters you send. Returns products that look like a photo and match the filters you send. ```http POST https://sandbox-api.tryflume.ai/partner/v1/search ``` - Send `multipart/form-data` with two parts: `image` and `payload`. - `image`: a JPEG, PNG or WebP, up to 6 MB. - `payload`: JSON. `category` is required. - The response has two lists: `strong_matches`, then `other_similar_options`. - Keep `search_session_uuid`. Quotes, saves and deep links for these products need it. - For the next page, send the same photo and payload with `page: 2`. Each call counts as a new search and returns a new `search_session_uuid`. - Send `spec_unit_price` to get `saving_vs_spec` on each result. - Daily limit: 1,000 searches per partner. ## Request ### Headers | Header | Required | Description | |---|---|---| | `X-Flume-Partner-Key` | Yes | Your partner key. | | `X-Flume-User-Token` | Yes | The access token of the user you call for. | ### Body (`multipart/form-data`) | Field | Type | Required | Description | |---|---|---|---| | `image` | file | Yes | | | `payload` | object | Yes | The `payload` part of a search, sent beside the photo. | | `payload.project` | object or null | No | The project to file the search under. | | `payload.project.external_ref` | string or null | No | Your id for the project. Max 128 characters. | | `payload.project.name` | string or null | No | The project's name. Max 255 characters. | | `payload.project.project_type` | enum or null | No | The kind of project. One of: `RESIDENTIAL`, `MULTIFAMILY`, `COMMERCIAL`, `HOSPITALITY`, `HEALTHCARE`, `EDUCATION`, `RETAIL`, `OTHER`. | | `payload.project.city` | string or null | No | The project's city. Max 128 characters. | | `payload.project.state` | string or null | No | The US state, as 2 capital letters. 2 capital letters. | | `payload.project.destination_zip` | string or null | No | The ZIP code the material is delivered to. 5 digits. | | `payload.project.estimated_area_sqft` | integer or null | No | The project's total area, in square feet. 1 to 10,000,000. | | `payload.project.unit_count` | integer or null | No | The number of homes, units or rooms. 1 to 1,000,000. | | `payload.project.target_install_date` | string (date) or null | No | When installation is planned. | | `payload.category` | enum | Yes | What to search for: `TILE` or `FLOORING` (vinyl flooring). Send the filters for that category only. | | `payload.tile_filters` | object or null | No | Filters for a `TILE` search. | | `payload.tile_filters.material` | array of enums | No | Match any of these materials. Any of: `PORCELAIN`, `CERAMIC`. | | `payload.tile_filters.finish` | array of enums | No | Match any of these finishes. Similar finishes are included: `MATTE` also finds `NATURAL`, `HONED` and `ANTI_SLIP`. `GET /filters` lists what each finish also finds. Any of: `MATTE`, `GLOSSY_POLISHED`, `SATIN`, `HONED`, `LAPPATO_SEMI_POLISHED`, `TEXTURED`, `NATURAL`, `BUSH_HAMMERED`, `ANTI_SLIP`, `CRACKLE`. | | `payload.tile_filters.finish_exclude` | array of enums | No | Leave these finishes out. Any of: `MATTE`, `GLOSSY_POLISHED`, `SATIN`, `HONED`, `LAPPATO_SEMI_POLISHED`, `TEXTURED`, `NATURAL`, `BUSH_HAMMERED`, `ANTI_SLIP`, `CRACKLE`. | | `payload.tile_filters.application_area` | array of enums | No | Where the tile is used. A product must suit every area listed. Any of: `INTERIOR_WALL_BACKSPLASH`, `SHOWER_WALL`, `STEAM_SHOWER_WET_ROOM_WALL`, `EXTERIOR_WALL_FACADE`, `INTERIOR_DRY_FLOOR`, `INTERIOR_WET_FLOOR`, `SHOWER_FLOOR`, `EXTERIOR_FLOOR_PATIO`, `RAMP_STAIR`, `POOL_DECK_WET_SURROUND`, `POOL_SUBMERGED`, `COUNTERTOP`. | | `payload.tile_filters.size` | object or null | No | The tile size to match. | | `payload.tile_filters.size.width` | number or null | No | The width, in `unit`. More than 0. Max 10,000. | | `payload.tile_filters.size.length` | number or null | No | The length, in `unit`. More than 0. Max 10,000. | | `payload.tile_filters.size.unit` | enum | No | The unit of `width` and `length`. One of: `in`, `cm`, `mm`. Default `"in"`. | | `payload.tile_filters.thickness_mm` | number or null | No | The minimum thickness, in millimetres. Products up to 2 mm thinner still match. More than 0. Max 500. | | `payload.tile_filters.glazed` | boolean or null | No | `true`: glazed only. `false`: unglazed only. | | `payload.tile_filters.mosaic` | boolean | No | `false` leaves mosaics out. `true` returns mosaics only. Default `false`. | | `payload.flooring_filters` | object or null | No | Filters for a `FLOORING` search. | | `payload.flooring_filters.size` | object or null | No | The plank size to match. | | `payload.flooring_filters.size.width` | number or null | No | The width, in `unit`. More than 0. Max 10,000. | | `payload.flooring_filters.size.length` | number or null | No | The length, in `unit`. More than 0. Max 10,000. | | `payload.flooring_filters.size.unit` | enum | No | The unit of `width` and `length`. One of: `in`, `cm`, `mm`. Default `"in"`. | | `payload.flooring_filters.thickness_mm` | number or null | No | The minimum thickness, in millimetres. Products up to 20% thinner still match. More than 0. Max 50. | | `payload.flooring_filters.thickness_mm_max` | number or null | No | The maximum thickness, in millimetres. More than 0. Max 50. | | `payload.flooring_filters.wear_layer_mil` | array of integer enums | No | Match any of these wear layers, in mil. `28` means 28 mil and thicker. Any of: `8`, `12`, `20`, `22`, `28`. | | `payload.flooring_filters.installation_method` | array of enums | No | Match any of these installation methods. Any of: `CLICK_LOCK`, `GLUE_DOWN`, `LOOSE_LAY`. | | `payload.flooring_filters.finish` | array of enums | No | Match any of these finishes. Any of: `MATTE`, `LOW_GLOSS`. | | `payload.flooring_filters.eir` | boolean or null | No | `true`: embossed in register (EIR) only. `false`: not EIR. | | `payload.flooring_filters.rigid_core` | array of enums | No | Match any of these core types. Any of: `SPC`, `WPC`. | | `payload.flooring_filters.attached_pad` | boolean or null | No | `true`: with an attached pad. `false`: without one. | | `payload.page` | integer | No | The page to return. A search finds at most 50 products. 1 to 50. Default `1`. | | `payload.page_size` | integer | No | Products per page. Use the same value for every page of one search. 1 to 24. Default `24`. | | `payload.crop_box` | object or null | No | The part of the photo to search on. | | `payload.crop_box.x` | number | Yes | The left edge, from 0 to 1. 0 to 1. | | `payload.crop_box.y` | number | Yes | The top edge, from 0 to 1. 0 to 1. | | `payload.crop_box.w` | number | Yes | The width, as a fraction of the photo's width. More than 0. Max 1. | | `payload.crop_box.h` | number | Yes | The height, as a fraction of the photo's height. More than 0. Max 1. | | `payload.spec_unit_price` | object or null | No | The unit price of the product the user has now. When sent, each result carries `saving_vs_spec`. Only USD per `SF` gives a saving. | | `payload.spec_unit_price.amount` | decimal string | Yes | The price, as a decimal. 0 to 1,000,000. | | `payload.spec_unit_price.currency` | string | Yes | The 3-letter currency code. 3 characters. | | `payload.spec_unit_price.unit` | string or null | No | The unit the price is for. It matches the unit of the quantity. Max 16 characters. | ### Sample **curl** ```sh curl -X POST "https://sandbox-api.tryflume.ai/partner/v1/search" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "X-Flume-User-Token: $FLUME_USER_TOKEN" \ -F "image=@photo.jpg" \ -F 'payload={"category":"TILE","tile_filters":{"material":["PORCELAIN"],"finish":["MATTE"],"size":{"width":24,"length":48,"unit":"in"}},"project":{"external_ref":"proj_7781","name":"Harbor Lofts - L2","destination_zip":"22102"}}' ``` ## Returns (200) Every field is always present. A field with no value is `null`. | Field | Type | Description | |---|---|---| | `search_session_uuid` | string (UUID) | The search's id. Send it when you request a quote, save an item or create a deep link for a product on this page. Every call returns a new one, page 2 included. | | `total` | integer | Products across all pages, at most 50. | | `strong_match_count` | integer | How many of `total` are strong matches. | | `other_similar_count` | integer | How many of `total` are other similar options. | | `page` | integer | The page returned. | | `page_size` | integer | Products per page. | | `has_more` | boolean | `true` when a later page has more products. | | `strong_matches` | array of objects | Products on this page that meet every filter and look closest to the photo, best first. | | `strong_matches[].sku_uuid` | string (UUID) | The product's id. | | `strong_matches[].name` | string | The product's name, written by Flume from its specification. | | `strong_matches[].image_url` | string or null | The main product photo. | | `strong_matches[].more_image_urls` | array of strings | Other product photos. | | `strong_matches[].size` | string or null | The size, as text. Tile only. | | `strong_matches[].thickness` | string or null | The thickness, as text. Tile only. | | `strong_matches[].material` | string or null | The material. Tile only. | | `strong_matches[].as_low_as_per_sqft` | string or null | The lowest delivered price per square foot, before tax. An instant quote for a real quantity and ZIP code can be higher. `null` when `price_on_request` is `true`. | | `strong_matches[].price_on_request` | boolean | `true`: Flume's sales team prices this product, and there is no instant quote. | | `strong_matches[].lead_times` | array of objects | The ways the product ships, with delivery times. | | `strong_matches[].lead_times[].source` | enum | `US_WAREHOUSE` or `OVERSEAS_DIRECT`. | | `strong_matches[].lead_times[].lead_time_min_days` | integer | The shortest delivery time, in days. | | `strong_matches[].lead_times[].lead_time_max_days` | integer | The longest delivery time, in days. | | `strong_matches[].suitable_application_areas` | array of objects | Every area where the product can be used: floors first, then walls, then the rest. Empty for vinyl flooring, or when Flume knows none. | | `strong_matches[].suitable_application_areas[].code` | string | The area's code, as `GET /filters` lists it. | | `strong_matches[].suitable_application_areas[].label` | string | The area's name, to show. | | `strong_matches[].saving_vs_spec` | object or null | The saving against `spec_unit_price`, when you sent one. `null` when the saving is under 5%, or when the product costs the same or more. | | `strong_matches[].saving_vs_spec.percent` | integer | The saving, in percent. | | `strong_matches[].saving_vs_spec.label` | string | The saving as text, to show. | | `other_similar_options` | array of objects | Products on this page that look close to the photo, with some filters relaxed. They come after every strong match. | | `other_similar_options[].sku_uuid` | string (UUID) | The product's id. | | `other_similar_options[].name` | string | The product's name, written by Flume from its specification. | | `other_similar_options[].image_url` | string or null | The main product photo. | | `other_similar_options[].more_image_urls` | array of strings | Other product photos. | | `other_similar_options[].size` | string or null | The size, as text. Tile only. | | `other_similar_options[].thickness` | string or null | The thickness, as text. Tile only. | | `other_similar_options[].material` | string or null | The material. Tile only. | | `other_similar_options[].as_low_as_per_sqft` | string or null | The lowest delivered price per square foot, before tax. An instant quote for a real quantity and ZIP code can be higher. `null` when `price_on_request` is `true`. | | `other_similar_options[].price_on_request` | boolean | `true`: Flume's sales team prices this product, and there is no instant quote. | | `other_similar_options[].lead_times` | array of objects | The ways the product ships, with delivery times. | | `other_similar_options[].lead_times[].source` | enum | `US_WAREHOUSE` or `OVERSEAS_DIRECT`. | | `other_similar_options[].lead_times[].lead_time_min_days` | integer | The shortest delivery time, in days. | | `other_similar_options[].lead_times[].lead_time_max_days` | integer | The longest delivery time, in days. | | `other_similar_options[].suitable_application_areas` | array of objects | Every area where the product can be used: floors first, then walls, then the rest. Empty for vinyl flooring, or when Flume knows none. | | `other_similar_options[].suitable_application_areas[].code` | string | The area's code, as `GET /filters` lists it. | | `other_similar_options[].suitable_application_areas[].label` | string | The area's name, to show. | | `other_similar_options[].saving_vs_spec` | object or null | The saving against `spec_unit_price`, when you sent one. `null` when the saving is under 5%, or when the product costs the same or more. | | `other_similar_options[].saving_vs_spec.percent` | integer | The saving, in percent. | | `other_similar_options[].saving_vs_spec.label` | string | The saving as text, to show. | | `project` | object or null | What happened to the `project` you sent. `null` when you sent none. | | `project.external_ref` | string or null | Your id for the project. | | `project.status` | enum | `FILED`: this search saved the project in Flume. `EXISTING`: an earlier search saved it. `NOT_FILED`: not saved, and `reason` says why. | | `project.reason` | enum or null | With `NOT_FILED`: `MISSING_FIELDS` (see `missing`) or `PROJECT_LIMIT_REACHED`. Otherwise `null`. | | `project.missing` | array of strings | With `MISSING_FIELDS`: the fields to send next time. | | `project.message` | string or null | A sentence you can show the user. | **Response (200)** ```json { "search_session_uuid": "5f0c1d2e-8a7b-4c3d-9e1f-2a3b4c5d6e7f", "total": 31, "strong_match_count": 9, "other_similar_count": 22, "page": 1, "page_size": 24, "has_more": true, "strong_matches": [ { "sku_uuid": "7f3c2a91-4b0e-4c8d-9a21-1f6b2d5e7c30", "name": "Porcelain 24×48″ Matte", "image_url": "https://sandbox-api.tryflume.ai/partner/v1/images/example-image-main", "more_image_urls": [ "https://sandbox-api.tryflume.ai/partner/v1/images/example-image-2", "https://sandbox-api.tryflume.ai/partner/v1/images/example-image-3" ], "size": "24×48 in", "thickness": "9 mm", "material": "Porcelain", "as_low_as_per_sqft": "6.40", "price_on_request": false, "lead_times": [ { "source": "US_WAREHOUSE", "lead_time_min_days": 14, "lead_time_max_days": 30 }, { "source": "OVERSEAS_DIRECT", "lead_time_min_days": 90, "lead_time_max_days": 120 } ], "suitable_application_areas": [ { "code": "INTERIOR_DRY_FLOOR", "label": "Interior Dry Floor" }, { "code": "INTERIOR_WALL_BACKSPLASH", "label": "Interior Wall / Backsplash" } ], "saving_vs_spec": null } ], "other_similar_options": [ { "sku_uuid": "0b6f9d34-2e71-4c5a-8f10-3d9a7e2b6c41", "name": "Porcelain 24×48″ Natural", "image_url": "https://sandbox-api.tryflume.ai/partner/v1/images/example-image-main", "more_image_urls": [ "https://sandbox-api.tryflume.ai/partner/v1/images/example-image-2", "https://sandbox-api.tryflume.ai/partner/v1/images/example-image-3" ], "size": "24×48 in", "thickness": "9 mm", "material": "Porcelain", "as_low_as_per_sqft": "5.90", "price_on_request": false, "lead_times": [ { "source": "US_WAREHOUSE", "lead_time_min_days": 14, "lead_time_max_days": 30 }, { "source": "OVERSEAS_DIRECT", "lead_time_min_days": 90, "lead_time_max_days": 120 } ], "suitable_application_areas": [ { "code": "INTERIOR_DRY_FLOOR", "label": "Interior Dry Floor" }, { "code": "INTERIOR_WALL_BACKSPLASH", "label": "Interior Wall / Backsplash" } ], "saving_vs_spec": null } ], "project": { "external_ref": "proj_7781", "status": "FILED", "reason": null, "missing": [], "message": null } } ``` ## Errors | Status | Code | Meaning | |---|---|---| | 400 | `IMAGE_REQUIRED` | The request has no `image` part, or the part is empty. | | 401 | `EMBED_TOKEN_INVALID` | You sent an `Authorization` header and no `X-Flume-Partner-Key`. Flume read the call as one from its own iframe. | | 401 | `PARTNER_KEY_INVALID` | The partner key is missing, unknown, revoked, expired, or from the other environment. On live, also a platform Flume has not enabled yet. | | 401 | `USER_TOKEN_INVALID` | The user access token is unknown, revoked, another partner's, or replaced by a later verification. A verification in the iframe replaces it too. | | 403 | `USER_ACCOUNT_CLOSED` | The user's email belongs to a Flume account that Flume has closed. | | 403 | `USER_NOT_CONNECTED` | No `X-Flume-User-Token` header was sent. | | 413 | `IMAGE_TOO_LARGE` | The photo is over 6,000,000 bytes. | | 422 | `IMAGE_UNSUPPORTED` | The file is not a JPEG, PNG or WebP, or it is over 50 megapixels. Flume reads the bytes, not the file name. | | 422 | `VALIDATION_FAILED` | A field is missing, unknown or has an unusable value. `field` names it as a dotted path. Some refusals carry the detail in `details` and leave `field` empty. | | 429 | `DAILY_LIMIT_REACHED` | This user's own daily limit of searches or instant quotes is used up. Flume sets this limit per partner account, and it is off by default. `details` holds `kind`, `limit`, `reset_at` and `retry_after_s`. | | 429 | `RATE_LIMITED` | Today's limit for this call is used up. `Retry-After` is the number of seconds until 00:00 UTC. | | 500 | `INTERNAL` | Something failed on Flume's side. | | 503 | `SEARCH_UNAVAILABLE` | Search is briefly unavailable. The call is not counted against your limit. | Every code, with its fix: [API error codes](https://docs.tryflume.ai/api/errors.md). Guide: [Find products by photo](https://docs.tryflume.ai/guides/find-products-by-photo.md). --- # Retrieve a product Source: https://docs.tryflume.ai/api/get-product > GET /products/{sku_uuid}: Returns one product's photos, price, specification and shipping options. Returns one product's photos, price, specification and shipping options. ```http GET https://sandbox-api.tryflume.ai/partner/v1/products/{sku_uuid} ``` ## Request ### Headers | Header | Required | Description | |---|---|---| | `X-Flume-Partner-Key` | Yes | Your partner key. | | `X-Flume-User-Token` | Yes | The access token of the user you call for. | ### Path parameters | Parameter | Type | Description | |---|---|---| | `sku_uuid` | string | The product's id, from a search result. | ### Sample **curl** ```sh curl -X GET "https://sandbox-api.tryflume.ai/partner/v1/products/7f3c2a91-4b0e-4c8d-9a21-1f6b2d5e7c30" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "X-Flume-User-Token: $FLUME_USER_TOKEN" ``` ## Returns (200) Every field is always present. A field with no value is `null`. | Field | Type | Description | |---|---|---| | `sku_uuid` | string (UUID) | The product's id. | | `category` | enum | What the product is: `TILE` or `FLOORING`. | | `name` | string | The product's name, written by Flume from its specification. | | `as_low_as_per_sqft` | string or null | The lowest delivered price per square foot, before tax. An instant quote for a real quantity and ZIP code can be higher. `null` when `price_on_request` is `true`. | | `price_on_request` | boolean | `true`: Flume's sales team prices this product, and there is no instant quote. | | `image_urls` | array of strings | Product photos, the main one first. | | `at_a_glance` | array of objects | The key facts, in display order. Tile only. Empty for flooring. | | `at_a_glance[].label` | string | The fact's name. | | `at_a_glance[].value` | string | The fact's value. | | `specification` | array of objects | The full specification, in groups. A property the product does not state is left out. | | `specification[].group` | string | The group's heading. | | `specification[].rows` | array of objects | The lines in the group. | | `specification[].rows[].label` | string | The property's name. | | `specification[].rows[].value` | string | The property's value. | | `specification[].rows[].test_method` | string or null | The test standard, when one is stated. | | `shipping_options` | array of objects | The ways the product ships. | | `shipping_options[].source` | enum | `US_WAREHOUSE` (stocked in the US) or `OVERSEAS_DIRECT` (shipped from the factory abroad). | | `shipping_options[].ships_from_country` | string or null | The 2-letter code of the country the option ships from, or `null`. | | `shipping_options[].minimum_order_sqft` | integer or null | The smallest order this option ships, in square feet. | | `shipping_options[].lead_time_min_days` | integer | The shortest delivery time, in days. | | `shipping_options[].lead_time_max_days` | integer | The longest delivery time, in days. | | `suitable_application_areas` | array of objects | Every area where the product can be used: floors first, then walls, then the rest. Empty for vinyl flooring, or when Flume knows none. | | `suitable_application_areas[].code` | string | The area's code, as `GET /filters` lists it. | | `suitable_application_areas[].label` | string | The area's name, to show. | **Response (200)** ```json { "sku_uuid": "7f3c2a91-4b0e-4c8d-9a21-1f6b2d5e7c30", "category": "TILE", "name": "Porcelain 24×48″ Matte", "as_low_as_per_sqft": "6.40", "price_on_request": false, "image_urls": [ "https://sandbox-api.tryflume.ai/partner/v1/images/example-image-main", "https://sandbox-api.tryflume.ai/partner/v1/images/example-image-2" ], "at_a_glance": [ { "label": "Size", "value": "24×48″" }, { "label": "Thickness", "value": "9 mm" }, { "label": "Finish", "value": "Matte" } ], "specification": [ { "group": "Slip resistance", "rows": [ { "label": "DCOF", "value": "> 0.42", "test_method": "ANSI A326.3" } ] } ], "shipping_options": [ { "source": "US_WAREHOUSE", "lead_time_min_days": 14, "lead_time_max_days": 30, "ships_from_country": "US", "minimum_order_sqft": 1000 }, { "source": "OVERSEAS_DIRECT", "lead_time_min_days": 90, "lead_time_max_days": 120, "ships_from_country": "ES", "minimum_order_sqft": 5000 } ], "suitable_application_areas": [ { "code": "INTERIOR_DRY_FLOOR", "label": "Interior Dry Floor" } ] } ``` ## Errors | Status | Code | Meaning | |---|---|---| | 401 | `EMBED_TOKEN_INVALID` | You sent an `Authorization` header and no `X-Flume-Partner-Key`. Flume read the call as one from its own iframe. | | 401 | `PARTNER_KEY_INVALID` | The partner key is missing, unknown, revoked, expired, or from the other environment. On live, also a platform Flume has not enabled yet. | | 401 | `USER_TOKEN_INVALID` | The user access token is unknown, revoked, another partner's, or replaced by a later verification. A verification in the iframe replaces it too. | | 403 | `USER_ACCOUNT_CLOSED` | The user's email belongs to a Flume account that Flume has closed. | | 403 | `USER_NOT_CONNECTED` | No `X-Flume-User-Token` header was sent. | | 404 | `NOT_FOUND` | The thing does not exist, or is not this user's. A search id that is not a UUID also returns this code. | | 422 | `VALIDATION_FAILED` | A field is missing, unknown or has an unusable value. `field` names it as a dotted path. Some refusals carry the detail in `details` and leave `field` empty. | | 500 | `INTERNAL` | Something failed on Flume's side. | Every code, with its fix: [API error codes](https://docs.tryflume.ai/api/errors.md). Guide: [Find products by photo](https://docs.tryflume.ai/guides/find-products-by-photo.md). --- # Request an instant quote Source: https://docs.tryflume.ai/api/request-instant-quote > POST /request-instant-quote: Returns the delivered price of one product for a quantity and a US ZIP code. Returns the delivered price of one product for a quantity and a US ZIP code. ```http POST https://sandbox-api.tryflume.ai/partner/v1/request-instant-quote ``` - The response lists one price per shipping option, cheapest first. Freight and sales tax are included. - `search_session_uuid` must be the search that returned the product. - The call can take up to 60 seconds. Set your HTTP read timeout to 75 seconds. - `READY`: `quote` holds the prices, valid until `valid_until`. - `FAILED`: read `failure_reason`. `TEMPORARILY_UNAVAILABLE` can mean the quote is still pricing. Poll `GET /quotes/{quote_uuid}`. - A shipping option is priced only when the quantity reaches its `minimum_order_sqft`. - Daily limit: 200 instant quotes per partner. ## Request ### Headers | Header | Required | Description | |---|---|---| | `X-Flume-Partner-Key` | Yes | Your partner key. | | `X-Flume-User-Token` | Yes | The access token of the user you call for. | | `Idempotency-Key` | No | One value per user action, 1 to 255 characters. A UUID works well. A retry with the same value returns the first response. | ### Body (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `sku_uuid` | string (UUID) | Yes | The product to price. | | `quantity_sqft` | integer | Yes | The quantity, as a whole number of square feet. 1 to 10,000,000. | | `destination_zip` | string | Yes | The US delivery ZIP code, as 5 digits. 5 digits. | | `search_session_uuid` | string (UUID) | Yes | The search that returned this product. Use the id from the page that listed it. Searches do not expire, so a later quote can send the same id. | ### Sample **curl** ```sh curl -X POST "https://sandbox-api.tryflume.ai/partner/v1/request-instant-quote" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "X-Flume-User-Token: $FLUME_USER_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "sku_uuid": "7f3c2a91-4b0e-4c8d-9a21-1f6b2d5e7c30", "quantity_sqft": 6000, "destination_zip": "22102", "search_session_uuid": "5f0c1d2e-8a7b-4c3d-9e1f-2a3b4c5d6e7f" }' ``` ## Returns (200) Every field is always present. A field with no value is `null`. | Field | Type | Description | |---|---|---| | `status` | enum | `READY` or `FAILED`. | | `failure_reason` | enum or null | With `FAILED`: why there are no prices. `TEMPORARILY_UNAVAILABLE` can mean the quote is still pricing. One of: `NOT_QUOTABLE`, `TEMPORARILY_UNAVAILABLE`, `REGION_RESTRICTED`, `INCORRECT_DESTINATION`. | | `quote_uuid` | string (UUID) or null | The id to poll with. Send it to `GET /quotes/{quote_uuid}` while the quote is still pricing. Every response has it. | | `quote` | object or null | The prices, with `READY`. Otherwise `null`. | | `quote.quote_uuid` | string (UUID) | The quote's id in the user's Flume account. | | `quote.quote_number` | string | The quote's number, as the user sees it in Flume. | | `quote.sku_uuid` | string (UUID) | The product priced. | | `quote.destination_zip` | string | The delivery ZIP code. | | `quote.currency` | string | Always `USD`. Default `"USD"`. | | `quote.created_at` | string | When the quote was priced, in UTC. | | `quote.valid_until` | string | The prices hold until this time, in UTC. | | `quote.options` | array of objects | One entry per shipping option, cheapest `total` first. | | `quote.options[].source` | enum | How this option ships: `US_WAREHOUSE` or `OVERSEAS_DIRECT`. | | `quote.options[].ships_from_country` | string or null | The 2-letter code of the country the option ships from, or `null`. | | `quote.options[].lead_time_min_days` | integer | The shortest delivery time, in days. | | `quote.options[].lead_time_max_days` | integer | The longest delivery time, in days. | | `quote.options[].quantity_sqft` | string | The quantity priced, in square feet. | | `quote.options[].price_per_sqft` | string | The delivered price per square foot, before tax. | | `quote.options[].subtotal` | string | The delivered price, before tax. | | `quote.options[].sales_tax` | string or null | The sales tax, or `null`. | | `quote.options[].total` | string | The delivered price, including sales tax. | **Response (200)** ```json { "status": "READY", "failure_reason": null, "quote_uuid": "9c1e4b7a-2f3d-4e5b-8a6c-7d8e9f0a1b2c", "quote": { "quote_uuid": "9c1e4b7a-2f3d-4e5b-8a6c-7d8e9f0a1b2c", "quote_number": "Q-7KD2-M4PA", "sku_uuid": "7f3c2a91-4b0e-4c8d-9a21-1f6b2d5e7c30", "destination_zip": "22102", "currency": "USD", "created_at": "2026-10-16T09:14:22Z", "valid_until": "2026-10-23T09:14:22Z", "options": [ { "source": "OVERSEAS_DIRECT", "lead_time_min_days": 90, "lead_time_max_days": 120, "ships_from_country": "ES", "quantity_sqft": "6000.00", "price_per_sqft": "6.40", "subtotal": "38400.00", "sales_tax": "3168.00", "total": "41568.00" }, { "source": "US_WAREHOUSE", "lead_time_min_days": 14, "lead_time_max_days": 30, "ships_from_country": "US", "quantity_sqft": "6000.00", "price_per_sqft": "8.10", "subtotal": "48600.00", "sales_tax": "4009.50", "total": "52609.50" } ] } } ``` ## Errors | Status | Code | Meaning | |---|---|---| | 400 | `BELOW_MINIMUM_ORDER` | The quantity is under the product's minimum order. The message states the minimum. | | 400 | `IDEMPOTENCY_KEY_REQUIRED` | The `Idempotency-Key` header is missing. | | 400 | `NOT_INSTANTLY_QUOTABLE` | Flume's sales team prices this product. There is no instant quote. | | 401 | `EMBED_TOKEN_INVALID` | You sent an `Authorization` header and no `X-Flume-Partner-Key`. Flume read the call as one from its own iframe. | | 401 | `PARTNER_KEY_INVALID` | The partner key is missing, unknown, revoked, expired, or from the other environment. On live, also a platform Flume has not enabled yet. | | 401 | `USER_TOKEN_INVALID` | The user access token is unknown, revoked, another partner's, or replaced by a later verification. A verification in the iframe replaces it too. | | 403 | `USER_ACCOUNT_CLOSED` | The user's email belongs to a Flume account that Flume has closed. | | 403 | `USER_NOT_CONNECTED` | No `X-Flume-User-Token` header was sent. | | 404 | `NOT_FOUND` | The thing does not exist, or is not this user's. A search id that is not a UUID also returns this code. | | 404 | `SESSION_NOT_FOUND` | The search does not exist, or is another user's. | | 409 | `IDEMPOTENCY_IN_PROGRESS` | The first call with this key is still running. | | 422 | `IDEMPOTENCY_KEY_REUSED` | The key was already used with a different body. | | 422 | `SKU_NOT_IN_SEARCH` | The product was not a result of the search you named. | | 422 | `VALIDATION_FAILED` | A field is missing, unknown or has an unusable value. `field` names it as a dotted path. Some refusals carry the detail in `details` and leave `field` empty. | | 429 | `DAILY_LIMIT_REACHED` | This user's own daily limit of searches or instant quotes is used up. Flume sets this limit per partner account, and it is off by default. `details` holds `kind`, `limit`, `reset_at` and `retry_after_s`. | | 429 | `RATE_LIMITED` | Today's limit for this call is used up. `Retry-After` is the number of seconds until 00:00 UTC. | | 500 | `INTERNAL` | Something failed on Flume's side. | Every code, with its fix: [API error codes](https://docs.tryflume.ai/api/errors.md). Guide: [Get an instant quote](https://docs.tryflume.ai/guides/get-an-instant-quote.md). --- # Retrieve an instant quote Source: https://docs.tryflume.ai/api/get-quote > GET /quotes/{quote_uuid}: Returns an instant quote by id, in the same shape as POST /request-instant-quote. Returns an instant quote by id, in the same shape as `POST /request-instant-quote`. ```http GET https://sandbox-api.tryflume.ai/partner/v1/quotes/{quote_uuid} ``` - Use it to poll a quote that is still pricing. - Poll every 12 seconds for 2 minutes, then every 30 seconds until 5 minutes, then stop. - No daily limit is used. ## Request ### Headers | Header | Required | Description | |---|---|---| | `X-Flume-Partner-Key` | Yes | Your partner key. | | `X-Flume-User-Token` | Yes | The access token of the user you call for. | ### Path parameters | Parameter | Type | Description | |---|---|---| | `quote_uuid` | string | The quote's id, from `POST /request-instant-quote`. | ### Sample **curl** ```sh curl -X GET "https://sandbox-api.tryflume.ai/partner/v1/quotes/9c1e4b7a-2f3d-4e5b-8a6c-7d8e9f0a1b2c" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "X-Flume-User-Token: $FLUME_USER_TOKEN" ``` ## Returns (200) Every field is always present. A field with no value is `null`. | Field | Type | Description | |---|---|---| | `status` | enum | `READY` or `FAILED`. | | `failure_reason` | enum or null | With `FAILED`: why there are no prices. `TEMPORARILY_UNAVAILABLE` can mean the quote is still pricing. One of: `NOT_QUOTABLE`, `TEMPORARILY_UNAVAILABLE`, `REGION_RESTRICTED`, `INCORRECT_DESTINATION`. | | `quote_uuid` | string (UUID) or null | The id to poll with. Send it to `GET /quotes/{quote_uuid}` while the quote is still pricing. Every response has it. | | `quote` | object or null | The prices, with `READY`. Otherwise `null`. | | `quote.quote_uuid` | string (UUID) | The quote's id in the user's Flume account. | | `quote.quote_number` | string | The quote's number, as the user sees it in Flume. | | `quote.sku_uuid` | string (UUID) | The product priced. | | `quote.destination_zip` | string | The delivery ZIP code. | | `quote.currency` | string | Always `USD`. Default `"USD"`. | | `quote.created_at` | string | When the quote was priced, in UTC. | | `quote.valid_until` | string | The prices hold until this time, in UTC. | | `quote.options` | array of objects | One entry per shipping option, cheapest `total` first. | | `quote.options[].source` | enum | How this option ships: `US_WAREHOUSE` or `OVERSEAS_DIRECT`. | | `quote.options[].ships_from_country` | string or null | The 2-letter code of the country the option ships from, or `null`. | | `quote.options[].lead_time_min_days` | integer | The shortest delivery time, in days. | | `quote.options[].lead_time_max_days` | integer | The longest delivery time, in days. | | `quote.options[].quantity_sqft` | string | The quantity priced, in square feet. | | `quote.options[].price_per_sqft` | string | The delivered price per square foot, before tax. | | `quote.options[].subtotal` | string | The delivered price, before tax. | | `quote.options[].sales_tax` | string or null | The sales tax, or `null`. | | `quote.options[].total` | string | The delivered price, including sales tax. | **Response (200)** ```json { "status": "READY", "failure_reason": null, "quote_uuid": "9c1e4b7a-2f3d-4e5b-8a6c-7d8e9f0a1b2c", "quote": { "quote_uuid": "9c1e4b7a-2f3d-4e5b-8a6c-7d8e9f0a1b2c", "quote_number": "Q-7KD2-M4PA", "sku_uuid": "7f3c2a91-4b0e-4c8d-9a21-1f6b2d5e7c30", "destination_zip": "22102", "currency": "USD", "created_at": "2026-10-16T09:14:22Z", "valid_until": "2026-10-23T09:14:22Z", "options": [ { "source": "OVERSEAS_DIRECT", "lead_time_min_days": 90, "lead_time_max_days": 120, "ships_from_country": "ES", "quantity_sqft": "6000.00", "price_per_sqft": "6.40", "subtotal": "38400.00", "sales_tax": "3168.00", "total": "41568.00" }, { "source": "US_WAREHOUSE", "lead_time_min_days": 14, "lead_time_max_days": 30, "ships_from_country": "US", "quantity_sqft": "6000.00", "price_per_sqft": "8.10", "subtotal": "48600.00", "sales_tax": "4009.50", "total": "52609.50" } ] } } ``` ## Errors | Status | Code | Meaning | |---|---|---| | 401 | `EMBED_TOKEN_INVALID` | You sent an `Authorization` header and no `X-Flume-Partner-Key`. Flume read the call as one from its own iframe. | | 401 | `PARTNER_KEY_INVALID` | The partner key is missing, unknown, revoked, expired, or from the other environment. On live, also a platform Flume has not enabled yet. | | 401 | `USER_TOKEN_INVALID` | The user access token is unknown, revoked, another partner's, or replaced by a later verification. A verification in the iframe replaces it too. | | 403 | `USER_ACCOUNT_CLOSED` | The user's email belongs to a Flume account that Flume has closed. | | 403 | `USER_NOT_CONNECTED` | No `X-Flume-User-Token` header was sent. | | 404 | `NOT_FOUND` | The thing does not exist, or is not this user's. A search id that is not a UUID also returns this code. | | 422 | `VALIDATION_FAILED` | A field is missing, unknown or has an unusable value. `field` names it as a dotted path. Some refusals carry the detail in `details` and leave `field` empty. | | 500 | `INTERNAL` | Something failed on Flume's side. | Every code, with its fix: [API error codes](https://docs.tryflume.ai/api/errors.md). Guide: [Get an instant quote](https://docs.tryflume.ai/guides/get-an-instant-quote.md). --- # Create a deep link Source: https://docs.tryflume.ai/api/create-deep-link > POST /deep-links: Creates a one-time link that opens the Flume app with the user signed in. Creates a one-time link that opens the Flume app with the user signed in. ```http POST https://sandbox-api.tryflume.ai/partner/v1/deep-links ``` - Create it when the user clicks, and open it at once. It works once, within 7 days. - `search_session_uuid` is required. Add `sku_uuid` to open a product from that search. - A replay of the same `Idempotency-Key` returns `url: null`. ## Request ### Headers | Header | Required | Description | |---|---|---| | `X-Flume-Partner-Key` | Yes | Your partner key. | | `X-Flume-User-Token` | Yes | The access token of the user you call for. | | `Idempotency-Key` | No | One value per user action, 1 to 255 characters. A retry with the same value returns the first response. | ### Body (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `search_session_uuid` | string (UUID) | Yes | One of the user's searches. Without `sku_uuid`, the link opens its results. | | `sku_uuid` | string (UUID) or null | No | A product to open. It must be a result of that search. | | `quote_uuid` | string (UUID) or null | No | An instant quote to open, with `sku_uuid`. It must be `READY` and belong to the user. | | `target` | enum or null | No | What the link opens: `product`, `search` or `quote`. Optional. Without it, the link opens the most specific id you send: `quote_uuid`, then `sku_uuid`, then the search. When set, it wins: `product` with a `quote_uuid` opens the product. `quote` needs `quote_uuid`, and `product` needs `sku_uuid`. Every id you send is checked, whatever `target` says. | | `utm_content` | string or null | No | A name for the button that asked, for your own reporting. Max 64 characters. | | `redirect_uri` | string or null | No | Where Flume sends the user back to. It must be an `https` URL on a redirect host agreed with Flume. Max 2,048 characters. | ### Sample **curl** ```sh curl -X POST "https://sandbox-api.tryflume.ai/partner/v1/deep-links" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "X-Flume-User-Token: $FLUME_USER_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "search_session_uuid": "5f0c1d2e-8a7b-4c3d-9e1f-2a3b4c5d6e7f", "sku_uuid": "7f3c2a91-4b0e-4c8d-9a21-1f6b2d5e7c30" }' ``` ## Returns (201) Every field is always present. A field with no value is `null`. | Field | Type | Description | |---|---|---| | `url` | string or null | The link. It works once, within 7 days. `null` when you replay an `Idempotency-Key`. | | `expires_at` | string | When the link stops working, in UTC. | **Response (201)** ```json { "url": "https://app.tryflume.ai/?utm_source=acme&utm_medium=partner#/l/dlk_q3Vh8yT…", "expires_at": "2026-10-23T09:14:22Z" } ``` ## Errors | Status | Code | Meaning | |---|---|---| | 401 | `EMBED_TOKEN_INVALID` | You sent an `Authorization` header and no `X-Flume-Partner-Key`. Flume read the call as one from its own iframe. | | 401 | `PARTNER_KEY_INVALID` | The partner key is missing, unknown, revoked, expired, or from the other environment. On live, also a platform Flume has not enabled yet. | | 401 | `USER_TOKEN_INVALID` | The user access token is unknown, revoked, another partner's, or replaced by a later verification. A verification in the iframe replaces it too. | | 403 | `USER_ACCOUNT_CLOSED` | The user's email belongs to a Flume account that Flume has closed. | | 403 | `USER_NOT_CONNECTED` | No `X-Flume-User-Token` header was sent. | | 404 | `NOT_FOUND` | The thing does not exist, or is not this user's. A search id that is not a UUID also returns this code. | | 404 | `SESSION_NOT_FOUND` | The search does not exist, or is another user's. | | 409 | `IDEMPOTENCY_IN_PROGRESS` | The first call with this key is still running. | | 422 | `IDEMPOTENCY_KEY_REUSED` | The key was already used with a different body. | | 422 | `QUOTE_NOT_READY` | The quote is not `READY`. On `POST /save-item` it also covers a quote that expired, is not this user's, lacks the option you named, or is under that option's minimum order. | | 422 | `SKU_NOT_IN_SEARCH` | The product was not a result of the search you named. | | 422 | `VALIDATION_FAILED` | A field is missing, unknown or has an unusable value. `field` names it as a dotted path. Some refusals carry the detail in `details` and leave `field` empty. | | 429 | `RATE_LIMITED` | Today's limit for this call is used up. `Retry-After` is the number of seconds until 00:00 UTC. | | 500 | `INTERNAL` | Something failed on Flume's side. | Every code, with its fix: [API error codes](https://docs.tryflume.ai/api/errors.md). Guide: [Open Flume signed in](https://docs.tryflume.ai/guides/open-flume-signed-in.md). --- # List keys Source: https://docs.tryflume.ai/api/list-keys > GET /keys: Returns your partner keys and their status. It never returns a secret. Returns your partner keys and their status. It never returns a secret. ```http GET https://sandbox-api.tryflume.ai/partner/v1/keys ``` ## Request ### Headers | Header | Required | Description | |---|---|---| | `X-Flume-Partner-Key` | Yes | Your partner key. | ### Sample **curl** ```sh curl -X GET "https://sandbox-api.tryflume.ai/partner/v1/keys" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" ``` ## Returns (200) Every field is always present. A field with no value is `null`. | Field | Type | Description | |---|---|---| | `keys` | array of objects | Every key, newest first. | | `keys[].key_id` | string | The key's id. Use it to revoke the key. | | `keys[].prefix` | string | The start of the key. Safe to log. | | `keys[].label` | string or null | The name you gave the key. | | `keys[].status` | enum | `ACTIVE`, `EXPIRING` (replaced, and working until `expires_at`), `REVOKED` or `EXPIRED`. | | `keys[].created_at` | string | When the key was issued, in UTC. | | `keys[].last_used_at` | string or null | When the key was last used, in UTC. | | `keys[].expires_at` | string or null | When a replaced key stops working, in UTC. | **Response (200)** ```json { "keys": [ { "key_id": "pk_3f9a1c2e5b7d4a6f8e0c1b2d3a4f5e6d", "prefix": "sk_dev_acme_Qm4t", "label": "rotation 2026-Q4", "status": "ACTIVE", "created_at": "2026-10-16T08:00:00Z", "last_used_at": "2026-10-16T09:14:22Z", "expires_at": null }, { "key_id": "pk_9e1b4d7a2c5f4068b3a1d9c7e5f2a0b4", "prefix": "sk_dev_acme_8f3k", "label": null, "status": "EXPIRING", "created_at": "2026-07-02T10:30:00Z", "last_used_at": "2026-10-16T07:58:41Z", "expires_at": "2026-10-23T08:00:00Z" } ] } ``` ## Errors | Status | Code | Meaning | |---|---|---| | 401 | `EMBED_TOKEN_INVALID` | You sent an `Authorization` header and no `X-Flume-Partner-Key`. Flume read the call as one from its own iframe. | | 401 | `PARTNER_KEY_INVALID` | The partner key is missing, unknown, revoked, expired, or from the other environment. On live, also a platform Flume has not enabled yet. | | 422 | `VALIDATION_FAILED` | A field is missing, unknown or has an unusable value. `field` names it as a dotted path. Some refusals carry the detail in `details` and leave `field` empty. | | 500 | `INTERNAL` | Something failed on Flume's side. | Every code, with its fix: [API error codes](https://docs.tryflume.ai/api/errors.md). Guide: [Rotate keys](https://docs.tryflume.ai/guides/rotate-keys.md). --- # Rotate a key Source: https://docs.tryflume.ai/api/rotate-key > POST /keys: Issues a new partner key and sets the key you called with to expire. Issues a new partner key and sets the key you called with to expire. ```http POST https://sandbox-api.tryflume.ai/partner/v1/keys ``` - `secret` is returned once. - The key you called with keeps working for `overlap_days`. - `Idempotency-Key` is required. ## Request ### Headers | Header | Required | Description | |---|---|---| | `X-Flume-Partner-Key` | Yes | Your partner key. | | `Idempotency-Key` | Yes | A new value for each rotation. | ### Body (`application/json`) ### Sample **curl** ```sh curl -X POST "https://sandbox-api.tryflume.ai/partner/v1/keys" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "label": "rotation 2026-Q4", "overlap_days": 7 }' ``` ## Returns (201) Every field is always present. A field with no value is `null`. | Field | Type | Description | |---|---|---| | `key_id` | string | The key's id. Use it to revoke the key. | | `prefix` | string | The start of the key. Safe to log. | | `label` | string or null | The name you gave the key. | | `status` | enum | `ACTIVE`, `EXPIRING` (replaced, and working until `expires_at`), `REVOKED` or `EXPIRED`. | | `created_at` | string | When the key was issued, in UTC. | | `last_used_at` | string or null | When the key was last used, in UTC. | | `expires_at` | string or null | When a replaced key stops working, in UTC. | | `secret` | string or null | The new key. Returned once. `null` when you replay an `Idempotency-Key`. | **Response (201)** ```json { "key_id": "pk_3f9a1c2e5b7d4a6f8e0c1b2d3a4f5e6d", "prefix": "sk_dev_acme_Qm4t", "label": "rotation 2026-Q4", "status": "ACTIVE", "created_at": "2026-10-16T08:00:00Z", "last_used_at": null, "expires_at": null, "secret": "sk_dev_acme_Qm4tW…" } ``` ## Errors | Status | Code | Meaning | |---|---|---| | 401 | `EMBED_TOKEN_INVALID` | You sent an `Authorization` header and no `X-Flume-Partner-Key`. Flume read the call as one from its own iframe. | | 401 | `PARTNER_KEY_INVALID` | The partner key is missing, unknown, revoked, expired, or from the other environment. On live, also a platform Flume has not enabled yet. | | 409 | `IDEMPOTENCY_IN_PROGRESS` | The first call with this key is still running. | | 409 | `KEY_LIMIT_REACHED` | You already hold the maximum number of active keys. | | 422 | `IDEMPOTENCY_KEY_REUSED` | The key was already used with a different body. | | 422 | `VALIDATION_FAILED` | A field is missing, unknown or has an unusable value. `field` names it as a dotted path. Some refusals carry the detail in `details` and leave `field` empty. | | 429 | `KEY_ROTATION_RATE_LIMITED` | You reached the number of keys you can issue in 24 hours. Your current key keeps working. | | 500 | `INTERNAL` | Something failed on Flume's side. | Every code, with its fix: [API error codes](https://docs.tryflume.ai/api/errors.md). Guide: [Rotate keys](https://docs.tryflume.ai/guides/rotate-keys.md). --- # Revoke a key Source: https://docs.tryflume.ai/api/revoke-key > DELETE /keys/{key_id}: Revokes a partner key at once. Revokes a partner key at once. ```http DELETE https://sandbox-api.tryflume.ai/partner/v1/keys/{key_id} ``` - You cannot revoke your only working key. Rotate first. ## Request ### Headers | Header | Required | Description | |---|---|---| | `X-Flume-Partner-Key` | Yes | Your partner key. | ### Path parameters | Parameter | Type | Description | |---|---|---| | `key_id` | string | The key to revoke, from `GET /keys`. | ### Sample **curl** ```sh curl -X DELETE "https://sandbox-api.tryflume.ai/partner/v1/keys/pk_9e1b4d7a2c5f4068b3a1d9c7e5f2a0b4" \ -H "X-Flume-Partner-Key: $FLUME_PARTNER_KEY" ``` ## Returns (204) **Response (204)**: no body. ## Errors | Status | Code | Meaning | |---|---|---| | 401 | `EMBED_TOKEN_INVALID` | You sent an `Authorization` header and no `X-Flume-Partner-Key`. Flume read the call as one from its own iframe. | | 401 | `PARTNER_KEY_INVALID` | The partner key is missing, unknown, revoked, expired, or from the other environment. On live, also a platform Flume has not enabled yet. | | 404 | `NOT_FOUND` | The thing does not exist, or is not this user's. A search id that is not a UUID also returns this code. | | 409 | `LAST_ACTIVE_KEY` | This is your only working key. | | 422 | `VALIDATION_FAILED` | A field is missing, unknown or has an unusable value. `field` names it as a dotted path. Some refusals carry the detail in `details` and leave `field` empty. | | 500 | `INTERNAL` | Something failed on Flume's side. | Every code, with its fix: [API error codes](https://docs.tryflume.ai/api/errors.md). Guide: [Rotate keys](https://docs.tryflume.ai/guides/rotate-keys.md). --- # Go-live checklist Source: https://docs.tryflume.ai/go-live > A checklist to finish in the sandbox before you ask Flume for live access. It covers credentials, the iframe, errors, limits and what to send Flume. Finish this checklist in the sandbox, then ask Flume for live access. Live is in early access: Flume is not yet opening it to new partners, and enables it by request, one partner at a time. Do not plan a launch on live until Flume confirms your platform is enabled. Copy the list into your own tracker. ## Credentials - [ ] The partner key is in a secret manager. It is not in source control or a client bundle. - [ ] Each user access token is stored encrypted, on your server, against one user. - [ ] No key or token is ever sent to a browser or written to a log. - [ ] You log the key prefix and `X-Request-Id` for failed calls. - [ ] You have [rotated a key](https://docs.tryflume.ai/guides/rotate-keys.md) once in the sandbox. ## Users - [ ] `external_ref` is a permanent id that you never reuse. - [ ] You handle a wrong code, an expired code and a resend. - [ ] On `401` `USER_TOKEN_INVALID`, you delete the token and verify the user again. - [ ] You overwrite the stored token every time a user verifies. ## Iframe Skip this section if you use the API only. - [ ] The iframe tag has exactly the [five sandbox tokens](https://docs.tryflume.ai/embed/host-page.md#the-iframe-tag). - [ ] You read the iframe origin from `iframe_url`. It is not hardcoded. - [ ] Your listener makes the [four checks](https://docs.tryflume.ai/embed/messages.md#checks-your-page-must-make). - [ ] You reply to the iframe with its origin as the target, never `"*"`. - [ ] The ready timer removes the iframe and shows an error with a retry. - [ ] You call `stop()` when your page removes the iframe itself. - [ ] You always send the stored token on `POST /start-iframe-session`. - [ ] You exchange the connect code at once, check `external_ref`, and store the token. - [ ] A repeated `save_uuid` writes nothing twice. - [ ] A failed write replies `flume:item_save_failed` with the `save_uuid`. - [ ] You reply to `flume:save_item` within 60 seconds. - [ ] You copy image bytes on save, and store `product_url`. - [ ] You do not store a price that came through the browser without [checking it](https://docs.tryflume.ai/embed/security.md#can-you-trust-the-save-payload). - [ ] On `flume:close_iframe` with `expired`, you start a new session. - [ ] Your CSP allows the iframe origin in `frame-src`. - [ ] You log `session_uuid` with your item id and user id. ## API Skip this section if you use the iframe only. - [ ] You send a new `Idempotency-Key` per user action, and reuse it only to retry. - [ ] Your HTTP read timeout on `POST /request-instant-quote` is 75 seconds. - [ ] You poll `GET /quotes/{quote_uuid}` on `TEMPORARILY_UNAVAILABLE`, and stop after 5 minutes. - [ ] You pass the `search_session_uuid` of the page that returned the product. - [ ] You treat money as a decimal string. - [ ] You build filter menus from `GET /filters`. - [ ] You do not store image links or deep link URLs. ## Errors and limits - [ ] You branch on `error.code`, never on `error.message`. - [ ] You retry only `429`, `500`, `502`, `503` and `IDEMPOTENCY_IN_PROGRESS`. - [ ] You honor `Retry-After`. - [ ] You handle a response that is not JSON. - [ ] You know your expected daily volume against the [limits](https://docs.tryflume.ai/concepts/limits.md). ## Send this to Flume When the list is done, [ask Flume](https://docs.tryflume.ai/support.md#ask-for-live-access) for live access. Include: | Item | Example | |---|---| | Your page origins for live | `https://app.acme.example` | | Image hosts for `item.image_urls` | `cdn.acme.example` | | Redirect hosts for deep links | `app.acme.example` | | Countries your servers and users are in | United States | | Expected searches and quotes per day | 300 searches, 60 quotes | | A contact for incidents | An email address | ## After Flume enables live 1. Store the live key. It starts with `sk_prod_`. 2. Switch the base URL to `https://ssapi.tryflume.ai/partner/v1`. 3. Verify each user again. Sandbox tokens do not work on live. 4. Read the iframe origin from a live `iframe_url`, and add it to your CSP. 5. Run one full flow with a real user before you announce it. --- # Changelog Source: https://docs.tryflume.ai/changelog > Dated changes to the Flume iframe, its server calls and these docs, newest first. Breaking changes are marked. Also available as an RSS feed. Dated changes to the iframe, its server calls and these docs. Newest first. ## How to read this page | Tag | Meaning | |---|---| | **Iframe** | A change to the iframe or its messages | | **Server calls** | A change to a call your backend makes | | **Docs** | A change to these pages only | | **Breaking** | You must change your integration | Subscribe to the [RSS feed](https://docs.tryflume.ai/changelog.xml). There is no email list for changes. ## 2026-11-11 **Docs** - First version of these docs: two ways to integrate, the Flume iframe (recommended) and the Partner API, with a reference page per endpoint. - Diagrams for every flow, collected on [Diagrams](https://docs.tryflume.ai/diagrams.md). - Every page has a Markdown version. [`/llms.txt`](https://docs.tryflume.ai/llms.txt), [`/llms-full.txt`](https://docs.tryflume.ai/llms-full.txt) and [`/openapi.json`](https://docs.tryflume.ai/openapi.json) are published. **Server calls** - `POST /deep-links`: `target` decides what the link opens. Send `product` for a saved item. **Iframe** - Flume can set a daily limit per user on a partner account. The iframe tells the user. See [Limits](https://docs.tryflume.ai/concepts/limits.md#is-there-a-limit-per-user). --- # Support Source: https://docs.tryflume.ai/support > How to ask Flume for live access, change your allowed origins, raise a limit or report a problem, and what to include so the answer is fast. Flume handles keys, live access and configuration by request, by email. This page says what to ask for and what to include. ## How do you reach Flume? Email `support@tryflume.ai`. There is no self-serve sign-up, no partner dashboard and no public status page today. ## What can you ask for? | Request | Include | |---|---| | A sandbox key | See [Get a sandbox key](https://docs.tryflume.ai/get-a-key.md) | | Live access | See [Ask for live access](#ask-for-live-access) | | A change to allowed origins, image hosts or redirect hosts | The exact values, and the environment | | A higher daily limit | Which call, and your expected daily volume | | A lost or leaked key | The key prefix. Never the key | ## Ask for live access 1. Finish the [go-live checklist](https://docs.tryflume.ai/go-live.md) in the sandbox. 2. Email the items in [Send this to Flume](https://docs.tryflume.ai/go-live.md#send-this-to-flume). 3. Flume enables your platform on live and sends a live key. Live is in early access. Flume is not yet opening it to new partners, and will enable it by request, one partner at a time. Build and test in the sandbox, and do not plan a launch on live until Flume confirms your platform is enabled. ## What should a problem report include? - `X-Request-Id` of a failing call. It is on every response, and in `error.request_id`. - The time in UTC. - The environment: sandbox or live. - The method and path. - The `error.code` you got. - For the iframe: `session_uuid`, your item id and your user's `external_ref`. - Your key prefix. Never send a partner key, a user access token, a connect code or an `iframe_url`. ## What can you do yourself? | Task | How | |---|---| | Rotate or revoke a key | [Rotate your key](https://docs.tryflume.ai/guides/rotate-keys.md) | | Revoke a user's access token | [`DELETE /users/{external_ref}/token`](https://docs.tryflume.ai/embed/server-calls.md#revoke-a-users-access-token) | ## Report a problem with these docs Email `support@tryflume.ai` with the page's URL and what was wrong or missing.