# How this reference stays true (/docs/how-the-spec-stays-true) Most API references are wrong in small ways. Not through carelessness — through ordinary drift. Someone adds a query parameter, ships it, and the docs keep describing the API as it was last quarter. Nothing looks broken, which is exactly what makes it expensive: you only find out when your integration fails against behaviour the page never mentioned. This page explains what keeps that from happening here, because the honest answer changes how much you should trust any given part of the reference. ## One document, served in two places [#one-document-served-in-two-places] There is exactly one OpenAPI document. The reference pages render it, and the API serves it: ``` https://api.flambe.dev/openapi.json # from the API itself https://docs.flambe.dev/openapi.yaml # the same document, as YAML ``` Byte-identical, verified on every build. That matters more than it sounds: the API used to serve a *different, smaller* spec — generated from code comments, describing 18 of its 131 operations. If you had found it at the conventional URL and generated a client from it, you would have got a client missing most of the API and no indication anything was missing. They are the same document. The API's copy is the one to prefer if you want to pin to a deployed version — it ships with the service. ## What is mechanically guaranteed [#what-is-mechanically-guaranteed] Three checks run in CI on every change to the API or the docs. Each one fails the build rather than warning, because a warning in a pipeline is a warning nobody reads. **Every endpoint exists, and every endpoint is documented.** A parser walks the Express application, resolves the route prefixes and the auth middleware, and produces the real list of mounted routes. That list must match the document exactly, in both directions. Add an endpoint without documenting it and the build fails; document an endpoint that does not exist and it fails too. **Every field the API validates is described.** This is the subtler one. An endpoint that *grows a parameter* keeps the same path and method, so a route-level check sees nothing wrong — and the reference page still lists everything it knew about, so nothing looks off. The validation chains attached to each route are compared against the documented request schema, and a field the API accepts but the document omits fails the build. **Every operation says what success looks like.** An operation documented with only its error responses produces a page that tells you how the call can fail and nothing about what you get back. ## What it cannot tell you [#what-it-cannot-tell-you] Two real gaps, worth knowing rather than discovering: **Meaning is not verified.** If a response field changes type, or an endpoint starts rejecting input it used to accept, no check here notices. The shape of the request is derived from code; the shape of the *response* is written by a person. Treat response schemas as carefully-maintained documentation rather than as a machine-verified contract. **Prose can age.** The guides are written, not generated. They are the part of these docs most likely to describe how something worked six months ago — which is the tradeoff for their being useful at all. The [reference](/docs/api-reference) is the authority when the two disagree. ## Why not generate all of it from the code [#why-not-generate-all-of-it-from-the-code] It is a reasonable question, and it was the previous design. Generating a spec from annotations gives you proximity — the description sits next to the handler, so it is hard to forget. What it costs is everything that makes a reference pleasant to read: worked examples, response unions, shared parameter definitions, and the explanation of *why* an endpoint behaves the way it does. Those become large comment blocks wedged between lines of application logic, and in practice they do not get written. The old generated spec covered 14% of the API, and that is the normal outcome rather than bad luck. So the document is authored, and the guarantee that annotations were supposed to provide is enforced by the checks above instead. You get a reference someone wrote on purpose, and a build that will not let it quietly become false. ## Reporting something wrong [#reporting-something-wrong] If the reference contradicts the API, that is a bug worth reporting — it means a check has a hole in it. [Get in touch](https://flambe.dev/contact), and include the endpoint and what you observed. # Flambe API (/docs) Flambe turns recipes from anywhere — a URL, a photo of a cookbook page, a block of pasted text — into structured, editable data. The API is the same interface the Flambe apps use, so anything they can do, your integration can do too. Authenticate and run your first import in about five minutes. The flagship capability: URL, image and text into a structured recipe. Every public endpoint, generated from the OpenAPI spec. `llms.txt`, Markdown for every page, and an MCP server. What the spec guarantees, and the two things it cannot tell you. ## What you can build [#what-you-can-build] * **Recipe capture** — point Flambe at a URL or a photo and get back parsed ingredients, components and steps. See [Imports](/docs/guides/imports). * **A library** — create, update, tag and search recipes, and organize them into collections. See [Recipes](/docs/guides/recipes). * **Planning and shopping** — build meal plans and derive grocery lists from them. * **Shared cooking** — share a library with a household, or mint an unlisted public link for one recipe. See [Households](/docs/guides/households) and [Sharing](/docs/guides/sharing). * **Offline-capable clients** — sync incrementally with a watermark instead of refetching. See [Syncing](/docs/guides/syncing). ## The shape of the API [#the-shape-of-the-api] ``` https://api.flambe.dev ``` One base URL, JSON in and out, bearer-token authenticated. There is no separate sandbox host — use a dedicated test user while you build. | Convention | Detail | | ---------- | ------------------------------------------------------------------------ | | Auth | `Authorization: Bearer ` | | Casing | `snake_case`, except timer fields, which are `camelCase` | | Timestamps | ISO 8601, UTC — `2026-09-10T18:22:04.512Z` | | Ids | UUID v4 strings. Opaque; do not parse them | | Errors | `{ "error": "message" }`, or `{ "errors": [...] }` on validation failure | Creating an import returns `201` immediately with `status: "pending"`. Extraction happens on a worker and takes seconds to a couple of minutes. Poll the import or subscribe to [the event stream](/docs/guides/events) — do not expect a recipe in the create response. ## Service health [#service-health] Live component status, including per-queue import health, is published at [status.flambe.dev](https://status.flambe.dev). Subscribe there to be emailed when something is degraded. # Quickstart (/docs/quickstart) This walks through the whole loop: get a token, start an import, wait for it, and read the recipe it produced. ## 1. Get a token [#1-get-a-token] Every request needs a Clerk session JWT. In a browser app with a Clerk SDK already wired up: ```ts title="token.ts" import { useAuth } from '@clerk/nextjs'; const { getToken } = useAuth(); const token = await getToken(); ``` For a script or server-side integration, mint a token from your Clerk instance rather than hard-coding one — session tokens are short-lived by design. [Authentication](/docs/guides/authentication) covers both paths in detail. Check it works. This is the cheapest possible authenticated call: ```bash curl https://api.flambe.dev/api/users/me \ -H "Authorization: Bearer $FLAMBE_TOKEN" ``` `Access token required` means the `Authorization` header never arrived — a client bug. `Invalid or expired token` means it arrived but did not verify. Only the second one is worth refreshing a session over. ## 2. Start an import [#2-start-an-import] ```bash curl -X POST https://api.flambe.dev/api/imports \ -H "Authorization: Bearer $FLAMBE_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "type": "web", "url": "https://www.seriouseats.com/best-chocolate-chip-cookies" }' ``` ```json title="201 Created" { "id": "4d1f8c2e-9a3b-4f1e-8c7d-1b2a3c4d5e6f", "type": "web", "status": "pending", "url": "https://www.seriouseats.com/best-chocolate-chip-cookies", "created_at": "2026-09-10T18:22:04.512Z" } ``` Note the `status`. Nothing has been extracted yet. ## 3. Wait for it [#3-wait-for-it] Poll the import until `status` is terminal — `completed`, `failed` or `cancelled`: ```bash curl "https://api.flambe.dev/api/imports/4d1f8c2e-9a3b-4f1e-8c7d-1b2a3c4d5e6f" \ -H "Authorization: Bearer $FLAMBE_TOKEN" ``` ```json title="200 OK" { "id": "4d1f8c2e-9a3b-4f1e-8c7d-1b2a3c4d5e6f", "status": "completed", "recipe_id": "9b8a7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d" } ``` A one- to two-second interval is plenty. For anything long-lived, subscribe to [the event stream](/docs/guides/events) instead and skip polling entirely. ## 4. Read the recipe [#4-read-the-recipe] ```bash curl "https://api.flambe.dev/api/recipes/9b8a7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d" \ -H "Authorization: Bearer $FLAMBE_TOKEN" ``` ```json title="200 OK" { "id": "9b8a7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d", "title": "The Best Chocolate Chip Cookies", "servings": 24, "prep_time": 20, "cook_time": 12, "ingredients": [ { "name": "all-purpose flour", "quantity": 2.25, "unit": "cups" }, { "name": "unsalted butter", "quantity": 1, "unit": "cup", "preparation": "browned" }, { "name": "flaky salt", "isOptional": true } ], "instructions": [ "Brown the butter and let it cool to room temperature.", "Cream with both sugars, then beat in the eggs." ] } ``` ## The whole thing, in one script [#the-whole-thing-in-one-script] ```ts title="import-recipe.ts" const API = 'https://api.flambe.dev'; const token = process.env.FLAMBE_TOKEN!; const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }; async function importRecipe(url: string) { const started = await fetch(`${API}/api/imports`, { method: 'POST', headers, body: JSON.stringify({ type: 'web', url }), }); if (!started.ok) throw new Error(`create failed: ${started.status}`); const { id } = await started.json(); // Terminal states only. Anything else means the worker still has it. const terminal = new Set(['completed', 'failed', 'cancelled']); const deadline = Date.now() + 3 * 60_000; while (Date.now() < deadline) { const res = await fetch(`${API}/api/imports/${id}`, { headers }); const job = await res.json(); if (terminal.has(job.status)) { if (job.status !== 'completed') { throw new Error(`import ${job.status}: ${job.error ?? 'no reason given'}`); } const recipe = await fetch(`${API}/api/recipes/${job.recipe_id}`, { headers }); return recipe.json(); } await new Promise((r) => setTimeout(r, 1500)); } throw new Error('import did not finish within three minutes'); } const recipe = await importRecipe(process.argv[2]); console.log(recipe.title, `— ${recipe.ingredients.length} ingredients`); ``` ## Next [#next] Photos, pasted text, batches, retries and what failures mean. Tokens, scoping, and server-to-server access. What to retry, what to surface, and what never to retry. # Plain-text context (/docs/agents/context) Every page here is available as Markdown, and the whole corpus as a single file, so you can put the documentation into a model's context without parsing HTML. ## The files [#the-files] | URL | Contents | | -------------------------------------------------------------------- | ---------------------------------------------------------- | | [`/llms.txt`](https://docs.flambe.dev/llms.txt) | An index: every page, with its title, description and URL. | | [`/llms-full.txt`](https://docs.flambe.dev/llms-full.txt) | The entire corpus of guides in one file. | | [`/openapi.yaml`](https://docs.flambe.dev/openapi.yaml) | The complete API contract. | | [`/openapi.json`](https://docs.flambe.dev/openapi.json) | The same contract, as JSON. | | [`api.flambe.dev/openapi.json`](https://api.flambe.dev/openapi.json) | The same document again, served by the API itself. | The API's copy is byte-identical and verified on every build, so either source works. Prefer the API's if you want the contract that shipped with the running service. Start with `llms.txt` to decide what you need, then fetch the specific pages. Reach for `llms-full.txt` when you would rather hold everything at once — it is small enough that this is usually the right call. ## One page as Markdown [#one-page-as-markdown] Two ways, both returning the same thing: ```bash # Append .md to any docs URL curl https://docs.flambe.dev/docs/guides/imports.md # Or negotiate on the canonical URL curl -H 'Accept: text/markdown' https://docs.flambe.dev/docs/guides/imports ``` The negotiated response carries `Vary: Accept`, so a cache in front of it will not serve HTML to a client that asked for Markdown. Every page in the web UI also has a **Copy Markdown** button and a view menu for opening the page directly in an assistant. ## Sizing [#sizing] At the time of writing the corpus is roughly 30k tokens including the OpenAPI spec — comfortably inside any current model's context window. You do not need to chunk it or stand up a vector store. Our own [Ask AI](https://docs.flambe.dev/docs) does exactly this: the whole corpus in the system prompt, behind prompt caching. ## Keeping current [#keeping-current] There is no version in these URLs; they always describe production. If you cache the corpus, re-fetch on deploy rather than pinning — the API reference in particular is regenerated from the server's routes on every build and is verified to match them. # Built for agents (/docs/agents) A large share of the traffic to these docs is not human. This section is the short version of everything an agent needs. ## Three ways in [#three-ways-in] Connect a client to `docs.flambe.dev/api/mcp` and search the docs as tools. `llms.txt`, `llms-full.txt`, and `.md` for any page. The complete contract for all 131 operations — the same document the API itself serves at `api.flambe.dev/openapi.json`. ## The fastest possible orientation [#the-fastest-possible-orientation] ``` https://docs.flambe.dev/llms.txt # index of every page https://docs.flambe.dev/llms-full.txt # the entire corpus, one file https://docs.flambe.dev/openapi.yaml # the API contract https://docs.flambe.dev/openapi.json # same, as JSON ``` Append `.md` to any docs URL for its Markdown source. The site also honours content negotiation — send `Accept: text/markdown` and you get Markdown back from the canonical URL. ```bash curl -H 'Accept: text/markdown' https://docs.flambe.dev/docs/guides/imports # or curl https://docs.flambe.dev/docs/guides/imports.md ``` ## The eight things that actually trip agents up [#the-eight-things-that-actually-trip-agents-up] Most integration failures against this API come from the same handful of assumptions. If you read nothing else: 1. **Imports are asynchronous.** `POST /api/imports` returns `201` with `status: "pending"` and no recipe. Poll the import or subscribe to the event stream. See [Imports](/docs/guides/imports). 2. **`media` and `image_batch` are different.** `media` reads several images as *one* recipe; `image_batch` treats each image as its own. Picking wrong collapses unrelated photos into one garbled recipe. 3. **The 401 message matters.** `Access token required` means the header was never sent — refreshing cannot help and retrying loops forever. `Invalid or expired token` is the one worth a refresh. See [Authentication](/docs/guides/authentication). 4. **Delta sync has a 25-day cliff.** A watermark older than 25 days returns `full_sync_required: true` and an **empty** result. Ignore the flag and the client silently stops updating. See [Syncing](/docs/guides/syncing). 5. **`EventSource` cannot authenticate.** The browser API cannot set an `Authorization` header. Use a fetch-based SSE reader. See [Live events](/docs/guides/events). 6. **Household `bulk` grants revoke omissions.** `PUT .../library/{type}/bulk` replaces the entire grant set. A partial list un-shares everything missing from it. See [Households](/docs/guides/households). 7. **CDN widths are a fixed list.** Only `320, 480, 640, 800, 960, 1200, 1600` exist; any other width silently serves the full-resolution original. See [Files](/docs/guides/files). 8. **`/changes` takes a sequence number, not a timestamp.** Unlike `updated_since`, the `since` on `/api/recipes/changes` is numeric. An ISO string returns `400`. ## Writing code against this API [#writing-code-against-this-api] * Base URL is `https://api.flambe.dev`. There is no sandbox host — use a dedicated test user. * Every endpoint except `GET /health` and `GET /api/shared/{token}` needs `Authorization: Bearer `. * Fields are `snake_case`. Timer fields are `camelCase` — the one exception. * Retry `429` and `5xx` with jittered backoff. Never retry other `4xx`. * Writes are not idempotent unless documented. `POST /api/imports` has no idempotency key, so a timed-out create must be reconciled against `GET /api/imports/working-set` rather than retried blindly. Timers *do* accept `idempotencyKey`. See [Errors and retries](/docs/guides/errors). ## Check status before debugging [#check-status-before-debugging] If calls fail broadly, [status.flambe.dev](https://status.flambe.dev) publishes live component health — the web service, the apps, and each import queue separately. `https://status.flambe.dev/api/v1/status.json` is the machine-readable form. # MCP server (/docs/agents/mcp) These docs are served over the [Model Context Protocol](https://modelcontextprotocol.io) so an agent can search and read them as tools instead of scraping HTML. ``` https://docs.flambe.dev/api/mcp ``` ## Tools [#tools] | Tool | What it does | | ------------ | ------------------------------------------------------------------------------ | | `search` | Full-text search across every page. Returns matching sections with their URLs. | | `list_pages` | Lists every page with its title and description — a cheap way to orient. | | `get_page` | Fetches one page's full Markdown. | They are backed by the same search index and Markdown the website renders, so there is no second copy that can drift. ## Connecting [#connecting] ### Claude Code [#claude-code] ```bash claude mcp add --transport http flambe-docs https://docs.flambe.dev/api/mcp ``` ### Claude Desktop and other clients [#claude-desktop-and-other-clients] Add it to the client's MCP configuration: ```json title="config.json" { "mcpServers": { "flambe-docs": { "type": "http", "url": "https://docs.flambe.dev/api/mcp" } } } ``` ### Cursor, Windsurf, and Zed [#cursor-windsurf-and-zed] All three read the same shape — point an HTTP MCP server at the URL above. No API key is required; the docs are public. ## Verifying it works [#verifying-it-works] ```bash curl -sS https://docs.flambe.dev/api/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` You should get back the three tools above. ## When not to use it [#when-not-to-use-it] The MCP server is for *reading the documentation*. It does not call the Flambe API — there is no `create_recipe` tool here, and it holds no credentials. To have an agent actually use the API, give it the [OpenAPI spec](/openapi.yaml) and a token. The spec is generated from the same source as this reference and is verified against the running server on every build, so it is safe to generate a client from. # API reference (/docs/api-reference) All **131** public operations, grouped by resource in the sidebar. Each page carries the full request and response schema, examples, and an interactive playground. ## The spec [#the-spec] The reference is rendered from a single OpenAPI 3.1 document, which you can also consume directly: The contract, as YAML. The same contract, as JSON. The API serves the **same document** at [`api.flambe.dev/openapi.json`](https://api.flambe.dev/openapi.json), byte-identical and verified on every build — so a client generated from either URL describes the same API. It is safe to generate from. Three checks run in CI: every mounted route must be documented and vice versa, every field the API validates must appear in the request schema, and every operation must say what success looks like. Each fails the build rather than warning. [How this reference stays true](/docs/how-the-spec-stays-true) explains what that does and does not guarantee. Deliberately excluded from the public surface: service-to-service endpoints under `/api/internal`, operator-only endpoints under `/api/admin`, and the inbound `/api/webhooks` handlers. They exist, but no token issued to a third-party integration can call them. ## Before you start [#before-you-start] * **Base URL** — `https://api.flambe.dev`. No sandbox host; use a test user. * **Auth** — `Authorization: Bearer ` on everything except `GET /health` and `GET /api/shared/{token}`. See [Authentication](/docs/guides/authentication). * **Casing** — `snake_case`, except [timers](/docs/api-reference/timers/createTimer), which are `camelCase`. * **Errors** — `{ "error": "..." }`, or `{ "errors": [...] }` when validation failed. See [Errors and retries](/docs/guides/errors). ## Where to start reading [#where-to-start-reading] The flagship endpoint. Asynchronous — read the guide first. Library reads, pagination, and the delta-sync watermark. The first half of importing from photographs. Server-sent events, so you do not have to poll. # Authentication (/docs/guides/authentication) Every endpoint except `/health` requires a bearer token: ```http Authorization: Bearer ``` Tokens are [Clerk](https://clerk.com) session JWTs. Flambe does not issue its own API keys to third parties — the token's `sub` claim is the Flambe user id, and it is what scopes every request. A token can only ever see and modify what that user can see and modify. ## From a browser or mobile client [#from-a-browser-or-mobile-client] If you already use a Clerk SDK, ask it for the current session token: ```ts import { useAuth } from '@clerk/nextjs'; const { getToken } = useAuth(); async function authedFetch(path: string, init: RequestInit = {}) { const token = await getToken(); return fetch(`https://api.flambe.dev${path}`, { ...init, headers: { ...init.headers, Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, }); } ``` `getToken()` returns a short-lived token and refreshes it as needed. Call it per request rather than caching the string — that is the single most common cause of spurious `401`s. ## From a server or a script [#from-a-server-or-a-script] Use a Clerk backend SDK to mint a token for the user you are acting as. Never ship a Clerk secret key to a client. ```ts import { createClerkClient } from '@clerk/backend'; const clerk = createClerkClient({ secretKey: process.env.CLERK_SECRET_KEY! }); // Exchange a session for a short-lived JWT. const { jwt } = await clerk.sessions.getToken(sessionId, 'default'); ``` For a long-running integration with no interactive user, create a dedicated Flambe account for it and drive that account's sessions. ## Reading the 401 [#reading-the-401] The two failures mean different things and want different handling: | Body | Meaning | What to do | | -------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------- | | `{"error":"Access token required"}` | The `Authorization` header was absent entirely. | Fix the client. Do not refresh — there was no session to refresh. | | `{"error":"Invalid or expired token"}` | The header arrived but the JWT did not verify. | Refresh the session and retry once. | Treating `Access token required` as an expired session sends clients into a refresh loop that can never succeed, because the header was never being sent in the first place. Branch on the message. ## Verifying a token [#verifying-a-token] `GET /api/users/me` is the cheapest way to check a token and learn who it belongs to: ```bash curl https://api.flambe.dev/api/users/me \ -H "Authorization: Bearer $FLAMBE_TOKEN" ``` ```json { "id": "user_2abcDEF", "email": "you@example.com", "household_ids": ["1f0e2d3c-4b5a-6978-8796-a5b4c3d2e1f0"] } ``` ## Endpoints that need no token [#endpoints-that-need-no-token] Two, deliberately: * `GET /health` — the liveness probe. * `GET /api/shared/{token}` — resolves a public [share link](/docs/guides/sharing) so a recipient without an account can view it. Everything else, including `POST /api/shared/{token}/save`, is authenticated. ## Legacy username/password [#legacy-usernamepassword] `POST /api/auth/register` and `POST /api/auth/login` still exist for older clients and are marked deprecated. New integrations should not use them. # Errors and retries (/docs/guides/errors) ## The envelope [#the-envelope] Errors return a non-2xx status and one of two shapes. A general failure: ```json { "error": "Recipe not found" } ``` A request-validation failure, listing the offending fields: ```json { "errors": [ { "type": "field", "path": "title", "msg": "Invalid value", "location": "body" } ] } ``` Both can come back with `400`, so handle either: ```ts type FlambeError = { error: string } | { errors: Array<{ path: string; msg: string }> }; function describe(body: FlambeError): string { if ('errors' in body) { return body.errors.map((e) => `${e.path}: ${e.msg}`).join(', '); } return body.error; } ``` `error` is a human-readable message, not a stable machine identifier. Branch on the **status code**, and on the message only where this documentation calls out a specific string. Messages can be reworded. ## Status codes [#status-codes] | Status | Meaning | Retry? | | ------ | --------------------------------------------------------- | ---------------------------------- | | `400` | Malformed request, or validation failed. | No — fix the request. | | `401` | No token, or it did not verify. | Once, after refreshing. See below. | | `403` | Authenticated but not permitted. | No. | | `404` | No such resource, or not visible to this user. | No. | | `409` | The resource is not in a state that allows the operation. | No — re-read state first. | | `429` | Rate limited. | Yes, with backoff. | | `5xx` | Server-side failure. | Yes, with backoff. | | `502` | An upstream provider failed. | Yes, sparingly. | ### 401 [#401] Branch on the message, because the two cases want opposite handling: * `Access token required` — the header was never sent. A client bug. Refreshing cannot help, and retrying produces an infinite loop. * `Invalid or expired token` — refresh the session and retry once. ### 404 vs 403 [#404-vs-403] A resource you cannot see returns `404`, not `403`. This is deliberate: `403` would confirm the resource exists. Do not infer from a `404` that an id is invalid. ### 409 [#409] `409` means state, not syntax. The common cases: | Operation | Cause | | ------------------------- | ------------------------------------------------------- | | Retry an import | It is still in flight. Cancel it first. | | Cancel an import | It already reached a terminal state. | | Report an import | It has not completed, or this user already reported it. | | Accept a household invite | The caller is already a member. | Re-read the resource before deciding what to do — a `409` usually means your view of its state is stale. ## Retrying well [#retrying-well] Retry `429` and `5xx`. Never retry `4xx` other than a single post-refresh attempt on `401`. ```ts async function withRetry(fn: () => Promise, parse: (r: Response) => Promise) { const maxAttempts = 4; for (let attempt = 1; ; attempt++) { const res = await fn(); if (res.ok) return parse(res); const retryable = res.status === 429 || res.status >= 500; if (!retryable || attempt === maxAttempts) { throw new Error(`${res.status}: ${describe(await res.json())}`); } // Exponential backoff with jitter, so a fleet of clients does not // synchronize its retries into a second thundering herd. const base = 500 * 2 ** (attempt - 1); await new Promise((r) => setTimeout(r, base + Math.random() * base)); } } ``` Two rules worth stating plainly: * **Add jitter.** Without it, every client that failed together retries together. * **Cap attempts.** An unbounded retry against a degraded service is indistinguishable from an attack on it. ## Idempotency [#idempotency] Most write endpoints are not idempotent — retrying `POST /api/imports` after a timeout creates a **second** import. Where retry safety matters: * **Timers** accept `idempotencyKey`. A repeat with the same key returns the original timer instead of creating another. * **Imports** have no idempotency key. If a create times out, list `GET /api/imports/working-set` and look for the job before creating another. * **Push tokens** are keyed on the token itself, so re-registering updates rather than duplicating. ## Rate limits [#rate-limits] Limits are applied per IP. Exceeding them returns `429`. Back off as above; do not spread the same workload across addresses to evade the limit. ## When it is not you [#when-it-is-not-you] If calls are failing broadly, check [status.flambe.dev](https://status.flambe.dev) before debugging your client. It publishes live component health — including each import queue separately — and you can subscribe for email notice of incidents. # Live events (/docs/guides/events) `GET /api/events/stream` is a [Server-Sent Events](https://developer.mozilla.org/docs/Web/API/Server-sent_events) stream of changes to the caller's data. Its main use is import progress — it removes polling entirely. ``` event: import.updated data: {"id":"4d1f8c2e","status":"processing"} event: import.completed data: {"id":"4d1f8c2e","recipe_id":"9b8a7c6d"} ``` ## Connecting [#connecting] The stream is authenticated with the usual bearer header. The browser's built-in `EventSource` cannot set request headers, so it cannot send `Authorization`. Use a fetch-based SSE client instead. This trips up nearly everyone the first time. ```ts async function subscribe(onEvent: (name: string, data: unknown) => void) { const res = await fetch('https://api.flambe.dev/api/events/stream', { headers: { Authorization: `Bearer ${await getToken()}` }, }); if (!res.ok) throw new Error(`stream failed: ${res.status}`); const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader(); let buffer = ''; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += value; // SSE frames are separated by a blank line. const frames = buffer.split('\n\n'); buffer = frames.pop() ?? ''; for (const frame of frames) { let name = 'message'; const payload: string[] = []; for (const line of frame.split('\n')) { if (line.startsWith(':')) continue; // keep-alive comment if (line.startsWith('event:')) name = line.slice(6).trim(); if (line.startsWith('data:')) payload.push(line.slice(5).trim()); } if (payload.length) onEvent(name, JSON.parse(payload.join('\n'))); } } } ``` ## Keep-alives [#keep-alives] The server sends a comment line — one beginning with `:` — every few seconds so intermediaries do not drop an idle connection. Skip those lines; they are not events. ## Reconnecting [#reconnecting] The stream is best-effort and carries no replay. Events that occurred while you were disconnected are gone. So on every reconnect, [delta sync](/docs/guides/syncing) to catch up, then rely on the stream for what happens next. Reconnect with backoff rather than immediately — a tight reconnect loop against a server that is having trouble makes things worse. A `401` closes the stream with an empty body. Refresh the token and reconnect once; do not loop. ## Still poll as a backstop [#still-poll-as-a-backstop] For a job you must not lose track of, combine both: react to `import.completed`, and also poll `GET /api/imports/{id}` on a slow interval — say every 15 seconds — so a dropped connection cannot leave a job hanging in your UI forever. # Files (/docs/guides/files) Files are the binary half of the API: the photographs you import from, and the images attached to recipes. ## Uploading [#uploading] `POST /api/files` takes `multipart/form-data`. The field name is `files`, and it repeats — one request can carry several. ```bash curl -X POST https://api.flambe.dev/api/files \ -H "Authorization: Bearer $FLAMBE_TOKEN" \ -F 'files=@page-42.jpg' \ -F 'files=@page-43.jpg' ``` ```json title="201 Created" { "files": [ { "id": "6f1c2e5a-6f0a-4c4e-9d2f-2c5a5b9e1a11", "url": "https://cdn.flambe.dev/files/6f1c2e5a...", "content_type": "image/jpeg", "width": 3024, "height": 4032, "blur_hash": "LEHV6nWB2yk8pyo0adR*.7kCMdnj" } ] } ``` Ids come back in upload order. Images are processed for width variants and given a BlurHash on the way in. This is the first half of a `media` [import](/docs/guides/imports#from-photographs): upload here, then pass the ids as `file_ids`. ## Resolving metadata [#resolving-metadata] Three ways, for three situations: | Endpoint | Use when | | --------------------------------- | ------------------------------------------------------ | | `GET /api/files/{id}/metadata` | You need exactly one file. | | `POST /api/files/metadata/query` | You have a list of ids. One round trip. | | `POST /api/files/metadata/stream` | The list is long and you want to render progressively. | ```bash curl -X POST https://api.flambe.dev/api/files/metadata/query \ -H "Authorization: Bearer $FLAMBE_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "ids": ["6f1c2e5a-6f0a-4c4e-9d2f-2c5a5b9e1a11"] }' ``` Ids the caller cannot read are **omitted** from the response rather than raising an error. Never assume `items.length === ids.length` — match results back by id. `/metadata/stream` returns `application/x-ndjson`: one JSON object per line, in no guaranteed order. ```ts const res = await fetch('https://api.flambe.dev/api/files/metadata/stream', { method: 'POST', headers, body: JSON.stringify({ ids }), }); const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader(); let buffer = ''; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += value; const lines = buffer.split('\n'); buffer = lines.pop() ?? ''; // keep the partial line for the next chunk for (const line of lines) { if (line.trim()) render(JSON.parse(line)); } } ``` ## Serving images [#serving-images] Prefer the CDN `url` over `GET /api/files/{id}` for anything user-facing — it is cached at the edge, and the API route is not. Width variants come from appending `@{width}w.webp`. Only these widths exist: ``` 320, 480, 640, 800, 960, 1200, 1600 ``` Anything else silently falls back to the original, full-resolution file — which is exactly the accident that makes a grid of thumbnails download tens of megabytes. Pick from the list. Fall back to the canonical URL if a variant 404s; variants are generated on demand and a brand-new upload may briefly have none. # Households (/docs/guides/households) A household is a group of users who share part of their libraries. Sharing is opt-in per resource — joining a household does not expose everything you own. ## Creating and joining [#creating-and-joining] ```bash curl -X POST https://api.flambe.dev/api/households \ -H "Authorization: Bearer $FLAMBE_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "name": "Kitchen" }' ``` The creator becomes the `owner`. Members are `owner`, `admin` or `member`. Invite by email for someone who may not have an account yet, or by user id for an existing user: ```bash curl -X POST "https://api.flambe.dev/api/households/$HOUSEHOLD_ID/invites" \ -H "Authorization: Bearer $FLAMBE_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "recipient_email": "partner@example.com", "role": "member" }' ``` The invite carries a `token`. The recipient redeems it — **authenticated**, so the invite binds to whoever accepts: ```bash curl -X POST "https://api.flambe.dev/api/households/invites/$TOKEN/accept" \ -H "Authorization: Bearer $RECIPIENT_TOKEN" ``` `GET /api/households/me` lists the households the caller belongs to. ## Sharing a resource [#sharing-a-resource] Nothing is shared until you grant it. Recipes, collections, meal plans and grocery lists can each be shared, as `viewer` or `editor`: ```bash curl -X POST "https://api.flambe.dev/api/households/$HOUSEHOLD_ID/library/recipe" \ -H "Authorization: Bearer $FLAMBE_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "resource_id": "9b8a7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d", "role": "viewer" }' ``` `GET /api/households/{id}/library` returns everything shared into the household by anyone. Shared recipes also appear in the member's own `GET /api/recipes`, which is why a library can contain recipes the caller does not own. ### Driving a share picker [#driving-a-share-picker] Two endpoints exist specifically for a checkbox UI: * `GET /api/households/{id}/library/my-resources/{resourceType}` — the caller's own resources of that type, each annotated with whether it is currently shared. * `PUT /api/households/{id}/library/{resourceType}/bulk` — sets the **complete** set of grants in one call. `bulk` is a replace, not a merge. Anything the caller had shared that is absent from `grants` is revoked. That is what makes it safe to submit a checkbox list wholesale — and what makes it destructive if you send a partial list. ```bash curl -X PUT "https://api.flambe.dev/api/households/$HOUSEHOLD_ID/library/recipe/bulk" \ -H "Authorization: Bearer $FLAMBE_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "grants": [{ "resource_id": "9b8a7c6d-...", "role": "editor" }] }' ``` ```json title="200 OK" { "granted": 1, "revoked": 3 } ``` ## Defaults [#defaults] `GET` and `PUT /api/households/{id}/sharing/preferences` hold the caller's per-household defaults — for example, whether newly created recipes are shared automatically. Read these before presenting a share dialog so the UI reflects what will actually happen. ## Leaving and removing [#leaving-and-removing] | Action | Endpoint | Effect | | --------------- | ---------------------------------------------- | ---------------------------------------------------------------------------- | | Leave | `POST /api/households/{id}/leave` | Removes the caller and revokes what they had shared in. Owners cannot leave. | | Remove a member | `DELETE /api/households/{id}/members/{userId}` | Owner or admin only. | | Dissolve | `DELETE /api/households/{id}` | Owner only. Revokes every share; members keep their own libraries. | When a member leaves or is removed, the resources they shared stop being visible to everyone else. Syncing clients see those ids in `revoked_ids` — see [Syncing](/docs/guides/syncing). ## Copies, not references [#copies-not-references] `POST /api/recipes/{id}/save-copy` takes an independent copy of a shared recipe into the caller's own library. It keeps working after the original is un-shared, and it does not track later edits to the original. Use it when someone wants to keep a recipe permanently; rely on the share when they want to see updates. ## Leaving a single resource [#leaving-a-single-resource] A member who wants out of one shared collection or grocery list, without leaving the household, can use `POST /api/collections/{id}/leave` or `POST /api/grocery-lists/{id}/leave`. The owner's copy is untouched. Owners cannot leave their own resource — they delete it instead. # Imports (/docs/guides/imports) An import is an asynchronous extraction job. You hand Flambe a source, it hands back a job id, and a worker turns the source into a structured recipe. ## The lifecycle [#the-lifecycle] ``` pending ──► queued ──► processing ──┬──► completed ├──► failed └──► cancelled ``` `completed`, `failed` and `cancelled` are terminal; nothing leaves them except an explicit [retry](#retrying). Everything else means a worker still has the job. `POST /api/imports` returns `201` with `status: "pending"` **immediately**. It does not wait for extraction. When the job reaches `completed`, `recipe_id` points at the recipe it created. ## Picking a type [#picking-a-type] | `type` | Source | Required field | | ------------- | ------------------------------------------------------- | ------------------------ | | `web` | An https URL — recipe sites, Instagram, YouTube, TikTok | `url` | | `media` | Uploaded images, e.g. photographed cookbook pages | `file_ids` | | `text` | Pasted or uploaded plain text | `text` or `text_file_id` | | `image_batch` | Many images, fanned out into one child job per image | `file_ids` | ### From a URL [#from-a-url] ```bash curl -X POST https://api.flambe.dev/api/imports \ -H "Authorization: Bearer $FLAMBE_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "type": "web", "url": "https://example.com/cookies" }' ``` The URL must be `https` — `http` is rejected with `400 Only https URLs are allowed`. A missing `url` gives `400 url is required for web imports`. For YouTube, pass `youtube_extract_video: true` to analyze the video frames as well as the description and transcript. It is slower, and worth it when the recipe is only spoken or shown. ### From photographs [#from-photographs] Two steps: upload the images, then reference them. ```bash # 1. Upload. The multipart field name is `files`, and repeats. curl -X POST https://api.flambe.dev/api/files \ -H "Authorization: Bearer $FLAMBE_TOKEN" \ -F 'files=@page-42.jpg' \ -F 'files=@page-43.jpg' ``` ```json title="201 Created" { "files": [ { "id": "6f1c2e5a-6f0a-4c4e-9d2f-2c5a5b9e1a11", "url": "https://cdn.flambe.dev/..." }, { "id": "7a2d3f6b-7b1b-4d5f-8e3a-3d6b6c0f2b22", "url": "https://cdn.flambe.dev/..." } ] } ``` ```bash # 2. Import them as one recipe spanning both pages. curl -X POST https://api.flambe.dev/api/imports \ -H "Authorization: Bearer $FLAMBE_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "type": "media", "file_ids": ["6f1c2e5a-6f0a-4c4e-9d2f-2c5a5b9e1a11", "7a2d3f6b-7b1b-4d5f-8e3a-3d6b6c0f2b22"], "source_section": "Chapter 4 — Breads" }' ``` `media` reads every image as **one** recipe — use it for a recipe that spans a two-page spread. `image_batch` treats each image as its **own** recipe and fans out into child jobs. Choosing wrong is the usual reason a batch of unrelated photos collapses into one garbled recipe. ### From text [#from-text] ```bash curl -X POST https://api.flambe.dev/api/imports \ -H "Authorization: Bearer $FLAMBE_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "type": "text", "text": "Brown Butter Cookies\n2 1/4 cups flour, 1 cup brown butter...", "source_url": "https://example.com/original-post" }' ``` `text` must be at least 3 characters. For anything large, upload it as a file and pass `text_file_id` instead. `source_url`, if given, must also be `https`. ## Tracking a job [#tracking-a-job] Poll it: ```bash curl "https://api.flambe.dev/api/imports/$IMPORT_ID" \ -H "Authorization: Bearer $FLAMBE_TOKEN" ``` A 1–2 second interval is appropriate; imports take seconds to a couple of minutes. Better, subscribe to [the event stream](/docs/guides/events) and react to `import.completed` rather than polling at all. ### Listing [#listing] Two endpoints, for two different jobs: * **`GET /api/imports/working-set`** — the newest imports, unpaginated. This is the bounded window an import screen renders. * **`GET /api/imports/v2`** — the full archive, cursor paginated. `working-set` sorts by `created_at`. An old import that just failed or is being retried has a fresh `updated_at` but keeps its original `created_at`, so it is **not** pulled into the window. If you are showing "recent activity", you want `/api/imports/v2`. ## Batches [#batches] An `image_batch` import is a parent that fans out into one child per image. Children succeed or fail independently, so a batch is routinely *partially* complete. ```bash curl "https://api.flambe.dev/api/imports/$PARENT_ID/children" \ -H "Authorization: Bearer $FLAMBE_TOKEN" ``` Treat the parent's status as a rollup, and the children as the real outcomes. `working-set` pulls in the parent of any child in the window even when the parent itself falls outside it, so a batch never renders orphaned. ## Retrying [#retrying] ```bash curl -X POST "https://api.flambe.dev/api/imports/$IMPORT_ID/retry" \ -H "Authorization: Bearer $FLAMBE_TOKEN" ``` Retry re-enqueues a **finished** import against its original source. A job still in flight returns `409` — cancel it first if you want it to stop. The retry budget is anchored to the attempt, not to the job: retrying re-stamps `processing_started_at`, so the new attempt gets a full budget rather than inheriting however long the previous one burned. ## Cancelling [#cancelling] ```bash curl -X POST "https://api.flambe.dev/api/imports/$IMPORT_ID/cancel" \ -H "Authorization: Bearer $FLAMBE_TOKEN" ``` A worker mid-extraction notices at its next checkpoint, so the job may run a little longer before it actually stops. Cancelling something already terminal returns `409`. ## When extraction is wrong [#when-extraction-is-wrong] If a job completes but the recipe is wrong, report it. Reports feed triage of the extraction pipeline. ```bash curl -X POST "https://api.flambe.dev/api/imports/$IMPORT_ID/report" \ -H "Authorization: Bearer $FLAMBE_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "reason": "missing_ingredients", "details": "Dropped the frosting component." }' ``` One report per import per user; a second gives `409`. Reporting an import that has not completed also gives `409`. ## Attribution [#attribution] Pass `source_section` at creation for a chapter or page reference, or attach a full provenance record with `POST /api/imports/{id}/source` so the resulting recipe is credited to the right cookbook, site or creator. See [Sources in the reference](/docs/api-reference). ## Failure modes worth handling [#failure-modes-worth-handling] | Symptom | Likely cause | | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `failed` with a fetch or `403` error | The site blocked the fetch. Retrying rarely helps; re-import as `text` by pasting the recipe. | | `failed` mentioning an invalid image | Usually the image could not be *fetched*, not that it was malformed. Check the file uploaded cleanly, then retry. | | Stuck in `processing` past a few minutes | The attempt has likely exhausted its budget and will land on `failed`. Wait for terminal state rather than creating a duplicate. | | Batch children with mixed results | Expected. Handle per child. | Never create a second import for the same source while the first is non-terminal — you get two recipes. # Recipes (/docs/guides/recipes) A recipe is the central object. Imports produce them; you can also create them directly. ## The model [#the-model] Only `title` is required. Everything else is optional, which matters because extraction from a photograph does not always recover every field. ```json { "id": "9b8a7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d", "title": "Brown Butter Chocolate Chip Cookies", "subtitle": "Makes two dozen", "tags": ["dessert", "cookies"], "prep_time": 20, "cook_time": 12, "servings": 24, "difficulty": "easy", "rating": 5, "ingredients": [ { "name": "all-purpose flour", "quantity": 2.25, "unit": "cups" }, { "name": "unsalted butter", "quantity": 1, "unit": "cup", "preparation": "browned" }, { "name": "flaky salt", "isOptional": true } ], "instructions": ["Brown the butter and let it cool.", "Cream with both sugars."], "components": [], "notes": "", "media": [] } ``` `prep_time` and `cook_time` are **minutes**, as integers. ### Ingredients are parsed, not strings [#ingredients-are-parsed-not-strings] Each ingredient is an object, so `2 ½ cups sifted flour` arrives as its parts rather than a line of text you have to re-parse: ```json { "name": "flour", "quantity": 2.5, "unit": "cups", "preparation": "sifted" } ``` Fractions are normalized to decimals. `isOptional` reflects the source marking the ingredient optional. ### Components are sub-recipes [#components-are-sub-recipes] A recipe with a sauce, a dough and a streusel has three `components`, each with its own `ingredients` and `instructions`. This is what stops a multi-part recipe from flattening into one ambiguous list. When `components` is non-empty, top-level `ingredients` holds only the ingredients belonging to no component. To show every ingredient, concatenate both. ```ts const allIngredients = [ ...recipe.ingredients, ...recipe.components.flatMap((c) => c.ingredients), ]; ``` `tags`, `ingredients`, `components`, `instructions` and `media` are normalized server-side and always come back as arrays — never `null`, never a JSON-encoded string. `notes` is likewise always a string, possibly empty. You do not need defensive coercion. ## Creating [#creating] ```bash curl -X POST https://api.flambe.dev/api/recipes \ -H "Authorization: Bearer $FLAMBE_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "title": "Weeknight carbonara", "servings": 2 }' ``` ## Updating [#updating] `PUT /api/recipes/{id}` replaces only the fields you send — omitted fields are left alone. Despite being a `PUT`, it behaves as a partial update, so you can rate a recipe without resending it: ```bash curl -X PUT "https://api.flambe.dev/api/recipes/$RECIPE_ID" \ -H "Authorization: Bearer $FLAMBE_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "rating": 5, "notes": "Chilled the dough overnight — better." }' ``` Sending an array field **does** replace it wholesale. To add one tag, send the full new list. ## Deleting [#deleting] `DELETE /api/recipes/{id}` is permanent. It also records a revocation, so clients syncing incrementally learn to drop their copy — see [Syncing](/docs/guides/syncing). ## Listing [#listing] ```bash curl "https://api.flambe.dev/api/recipes?limit=24" \ -H "Authorization: Bearer $FLAMBE_TOKEN" ``` Returns the caller's library plus anything shared with them through a [household](/docs/guides/households). `limit` defaults to 24 and caps at 100; page with `cursor`. ## Nutrition [#nutrition] ```bash curl -X POST "https://api.flambe.dev/api/recipes/$RECIPE_ID/nutrition" \ -H "Authorization: Bearer $FLAMBE_TOKEN" ``` Estimates per-serving nutrition by matching ingredients against USDA FoodData Central and Open Food Facts. Two things to know: * The figures are **estimates**. Ingredients that cannot be matched are skipped rather than guessed at, so a recipe with unusual ingredients under-reports. * The result is cached against `ingredients_hash`. If that hash no longer matches the recipe's ingredients, the stored nutrition is stale — recompute. ## Copying someone else's recipe [#copying-someone-elses-recipe] `POST /api/recipes/{id}/save-copy` copies a recipe you can see — typically one shared through a household — into your own library as an independent record. Later edits to the original do not propagate. ## Images [#images] Recipe `media` entries carry a CDN URL on `cdn.flambe.dev`. Width variants are available by appending `@{width}w.webp`, for these widths only: ``` 320, 480, 640, 800, 960, 1200, 1600 ``` Any other width falls back to the original file, so do not invent sizes. ```html ``` Each entry also carries `blur_hash` for a placeholder while the image loads. ## Organizing [#organizing] * **Collections** group recipes by name — `POST /api/collections`, then `POST /api/collections/{id}/recipes`. Deleting a collection does not delete its recipes. * **Tags** live on the recipe itself. * **Sources** record where a recipe came from. See the Sources section of the [reference](/docs/api-reference). ## Semantic search [#semantic-search] Recipes are indexed as embeddings. To search over recipes you have synced locally, embed the query with `POST /api/ai/embed-query` — using the *same* model that built the index — and compare vectors yourself: ```bash curl -X POST https://api.flambe.dev/api/ai/embed-query \ -H "Authorization: Bearer $FLAMBE_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "query": "something warm with lentils" }' ``` Embedding the query with a different model produces distances that are not comparable to the index, which is the whole reason this endpoint exists. # Public share links (/docs/guides/sharing) A share link is an unlisted public URL for a single resource. Anyone holding the link can view it without a Flambe account — which makes the token a secret. ## Minting [#minting] ```bash curl -X POST https://api.flambe.dev/api/share-links \ -H "Authorization: Bearer $FLAMBE_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "resource_type": "recipe", "resource_id": "9b8a7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d" }' ``` ```json title="201 Created" { "id": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e", "token": "Yk9sTnFXcmY", "resource_type": "recipe", "resource_id": "9b8a7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d" } ``` The public URL is `https://flambe.dev/share/{token}`. `resource_type` is one of `recipe`, `collection`, `mealplan`, `grocerylist`. ## Resolving [#resolving] `GET /api/shared/{token}` is the one read endpoint that needs **no** authentication — it is what renders the page for a recipient with no account. ```bash curl "https://api.flambe.dev/api/shared/Yk9sTnFXcmY" ``` An unknown or revoked token returns `404`. ## Saving a shared resource [#saving-a-shared-resource] `POST /api/shared/{token}/save` copies the resource into the caller's own library. Unlike resolving, this **does** require authentication — it has to know whose library to write to. ## Revoking [#revoking] ```bash curl -X DELETE "https://api.flambe.dev/api/share-links/$SHARE_LINK_ID" \ -H "Authorization: Bearer $FLAMBE_TOKEN" ``` Note the path takes the share link's `id`, not its `token`. Revocation is immediate: the URL stops resolving. A page already open keeps whatever it has already loaded — revoking does not reach into a browser that has the content in memory. ## Treat the token as a credential [#treat-the-token-as-a-credential] * Anyone with the link can read the resource. There is no second factor. * Links are excluded from `sitemap.xml` and disallowed in `robots.txt`, so they are not indexed — but that is not a security boundary. A link pasted into a public channel is public. * Revoke rather than relying on obscurity once a link has served its purpose. ## Share links vs households [#share-links-vs-households] | | Share link | Household | | ------------ | ------------------------------ | ----------------------------------------- | | Audience | Anyone with the URL | Named members | | Auth to view | None | Required | | Granularity | One resource | Per-resource grants, `viewer` or `editor` | | Good for | Sending one recipe to a friend | An ongoing shared library | See [Households](/docs/guides/households) for the second. # Syncing (/docs/guides/syncing) Refetching a whole library on every launch is wasteful and slow. `GET /api/recipes` supports a watermark so you can ask only for what changed. ## Delta sync [#delta-sync] Pass `updated_since` and you get back only recipes modified at or after that instant, plus the ids of recipes that are no longer visible: ```bash curl "https://api.flambe.dev/api/recipes?updated_since=2026-09-09T00:00:00.000Z" \ -H "Authorization: Bearer $FLAMBE_TOKEN" ``` ```json title="200 OK" { "recipes": [ /* changed since the watermark */ ], "revoked_ids": ["3c2b1a09-8f7e-6d5c-4b3a-2918f7e6d5c4"], "server_timestamp": "2026-09-10T18:22:04.512Z", "full_sync_required": false, "next_cursor": null } ``` Apply it like this: 1. Upsert everything in `recipes`. 2. Delete everything in `revoked_ids` — these are recipes deleted, or un-shared from a household. 3. Store `server_timestamp` and send it as the next `updated_since`. Use the server's timestamp, not your own clock. Clock skew between your device and the server will otherwise cause you to miss changes in the gap. ## The 25-day cliff [#the-25-day-cliff] Change history is retained for **30 days**. A watermark older than **25 days** is refused: ```json { "recipes": [], "revoked_ids": [], "full_sync_required": true, "server_timestamp": "2026-09-10T18:22:04.512Z" } ``` The five-day margin exists so you are told to re-sync *before* the history you would need actually expires. When you see `full_sync_required: true`, discard local state and re-sync without `updated_since`. Do not ignore it and do not retry with the same watermark — the response is empty, so a client that ignores the flag silently stops receiving updates forever. ```ts async function sync(db: LocalStore) { const since = await db.getWatermark(); const qs = since ? `?updated_since=${encodeURIComponent(since)}` : ''; const res = await authedFetch(`/api/recipes${qs}`); const page = await res.json(); if (page.full_sync_required) { await db.clear(); await db.setWatermark(null); return sync(db); // once — the next call has no watermark, so it cannot recurse again } await db.upsertAll(page.recipes); await db.deleteAll(page.revoked_ids); await db.setWatermark(page.server_timestamp); } ``` ## Pagination and delta sync are independent [#pagination-and-delta-sync-are-independent] `limit` and `cursor` work with or without `updated_since`. A large delta comes back paginated, so keep following `next_cursor` until it is `null` — and only then commit the new watermark. Committing it mid-page loses everything on the pages you have not read. ## The change log [#the-change-log] `GET /api/recipes/changes` is the lower-level primitive: an append-only event log, for when you need to know *what* happened rather than just the current state. ```bash curl "https://api.flambe.dev/api/recipes/changes?since=0&limit=100" \ -H "Authorization: Bearer $FLAMBE_TOKEN" ``` Unlike `updated_since` on `/api/recipes`, the `since` on `/changes` is a numeric **sequence** watermark, not a timestamp. Start at `0` and carry forward the value the response gives you. Passing an ISO timestamp returns `400 Invalid since parameter`. ## Live updates [#live-updates] Delta sync covers catching up. For changes arriving while your client is open, subscribe to [the event stream](/docs/guides/events) — and still delta-sync on reconnect, since events that happened while you were disconnected are not replayed. # Health check (/docs/api-reference/system/healthCheck) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Start an import (/docs/api-reference/imports/createImport) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # List imports (paginated) (/docs/api-reference/imports/listImports) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # List the import working set (/docs/api-reference/imports/getImportWorkingSet) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Get an import (/docs/api-reference/imports/getImport) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Retry an import (/docs/api-reference/imports/retryImport) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Cancel an import (/docs/api-reference/imports/cancelImport) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # List child imports (/docs/api-reference/imports/listImportChildren) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Report a bad import (/docs/api-reference/imports/reportImport) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Attach provenance to an import (/docs/api-reference/imports/setImportSource) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Attach provenance to an import (/docs/api-reference/sources/setImportSource) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Set legacy source (/docs/api-reference/sources/setRecipeSource) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Clear the primary source (/docs/api-reference/sources/clearRecipeSourceV2) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Set the primary source (/docs/api-reference/sources/setRecipeSourceV2) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Attach a source (/docs/api-reference/sources/attachRecipeSource) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Update a source attachment (/docs/api-reference/sources/updateRecipeSourceAttachment) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Detach a source (/docs/api-reference/sources/detachRecipeSource) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # List sources (v1) (/docs/api-reference/sources/listSources) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # List a source's recipes (v1) (/docs/api-reference/sources/listSourceRecipes) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # List sources (/docs/api-reference/sources/listSourcesV2) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Get a source (/docs/api-reference/sources/getSourceV2) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Update a source (/docs/api-reference/sources/updateSourceV2) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # List a source's recipes (/docs/api-reference/sources/listSourceV2Recipes) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # List verified recipes (/docs/api-reference/sources/listVerifiedRecipes) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Get a verified recipe (/docs/api-reference/sources/getVerifiedRecipe) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # List recipes (/docs/api-reference/recipes/listRecipes) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Create a recipe (/docs/api-reference/recipes/createRecipe) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Stream recipe change events (/docs/api-reference/recipes/listRecipeChanges) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Get a recipe (/docs/api-reference/recipes/getRecipe) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Delete a recipe (/docs/api-reference/recipes/deleteRecipe) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Update a recipe (/docs/api-reference/recipes/updateRecipe) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Compute nutrition (/docs/api-reference/recipes/computeRecipeNutrition) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Save a copy (/docs/api-reference/recipes/saveRecipeCopy) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Set legacy source (/docs/api-reference/recipes/setRecipeSource) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Clear the primary source (/docs/api-reference/recipes/clearRecipeSourceV2) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Set the primary source (/docs/api-reference/recipes/setRecipeSourceV2) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Attach a source (/docs/api-reference/recipes/attachRecipeSource) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Update a source attachment (/docs/api-reference/recipes/updateRecipeSourceAttachment) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Detach a source (/docs/api-reference/recipes/detachRecipeSource) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # List collections (/docs/api-reference/collections/listCollections) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Create a collection (/docs/api-reference/collections/createCollection) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Get a collection (/docs/api-reference/collections/getCollection) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Delete a collection (/docs/api-reference/collections/deleteCollection) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Update a collection (/docs/api-reference/collections/updateCollection) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Add a recipe to a collection (/docs/api-reference/collections/addRecipeToCollection) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Add several recipes to a collection (/docs/api-reference/collections/addRecipesToCollectionBulk) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Remove a recipe from a collection (/docs/api-reference/collections/removeRecipeFromCollection) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Leave a shared collection (/docs/api-reference/collections/leaveCollection) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Leave a shared collection (/docs/api-reference/households/leaveCollection) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Leave a shared grocery list (/docs/api-reference/households/leaveGroceryList) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Create a household (/docs/api-reference/households/createHousehold) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # List my households (/docs/api-reference/households/listMyHouseholds) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Get a household (/docs/api-reference/households/getHousehold) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Rename a household (/docs/api-reference/households/updateHousehold) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Delete a household (/docs/api-reference/households/deleteHousehold) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Leave a household (/docs/api-reference/households/leaveHousehold) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Invite someone to a household (/docs/api-reference/households/createHouseholdInvite) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Revoke an invite (/docs/api-reference/households/revokeHouseholdInvite) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Accept an invite (/docs/api-reference/households/acceptHouseholdInvite) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Remove a member (/docs/api-reference/households/removeHouseholdMember) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # List the shared library (/docs/api-reference/households/getHouseholdLibrary) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Share a resource into a household (/docs/api-reference/households/grantHouseholdResource) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Replace the grants for a resource type (/docs/api-reference/households/bulkGrantHouseholdResources) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Unshare a resource (/docs/api-reference/households/revokeHouseholdResource) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # List my shareable resources (/docs/api-reference/households/listMyHouseholdResources) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Get sharing preferences (/docs/api-reference/households/getHouseholdSharingPreferences) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Set sharing preferences (/docs/api-reference/households/setHouseholdSharingPreferences) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # List meal plans (/docs/api-reference/meal-plans/listMealPlans) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Create a meal plan (/docs/api-reference/meal-plans/createMealPlan) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Get a meal plan (/docs/api-reference/meal-plans/getMealPlan) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Delete a meal plan (/docs/api-reference/meal-plans/deleteMealPlan) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Update a meal plan (/docs/api-reference/meal-plans/updateMealPlan) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Add a meal to a plan (/docs/api-reference/meal-plans/addMeal) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Remove a meal (/docs/api-reference/meal-plans/deleteMeal) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Update a meal (/docs/api-reference/meal-plans/updateMeal) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Export a meal plan (/docs/api-reference/meal-plans/exportMealPlan) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # List grocery lists (/docs/api-reference/grocery-lists/listGroceryLists) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Create a grocery list (/docs/api-reference/grocery-lists/createGroceryList) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Get a grocery list (/docs/api-reference/grocery-lists/getGroceryList) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Delete a grocery list (/docs/api-reference/grocery-lists/deleteGroceryList) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Update a grocery list (/docs/api-reference/grocery-lists/updateGroceryList) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Append items to a grocery list (/docs/api-reference/grocery-lists/addGroceryItems) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Leave a shared grocery list (/docs/api-reference/grocery-lists/leaveGroceryList) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # List wishlists (/docs/api-reference/wishlists/listWishlists) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Create a wishlist (/docs/api-reference/wishlists/createWishlist) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Get a wishlist (/docs/api-reference/wishlists/getWishlist) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Delete a wishlist (/docs/api-reference/wishlists/deleteWishlist) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Update a wishlist (/docs/api-reference/wishlists/updateWishlist) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Add an item to a wishlist (/docs/api-reference/wishlists/addWishlistItem) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Remove an item from a wishlist (/docs/api-reference/wishlists/removeWishlistItem) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Add an item to the default wishlist (/docs/api-reference/wishlists/addDefaultWishlistItem) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Upload files (/docs/api-reference/files/uploadFiles) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Get a file (/docs/api-reference/files/getFile) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Get file metadata (/docs/api-reference/files/getFileMetadata) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # List file metadata (/docs/api-reference/files/listFileMetadata) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Resolve file metadata in bulk (/docs/api-reference/files/queryFileMetadata) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Stream file metadata (/docs/api-reference/files/streamFileMetadata) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # List cook sessions (/docs/api-reference/cook-sessions/listCookSessions) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Start a cook session (/docs/api-reference/cook-sessions/createCookSession) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Get the active session (/docs/api-reference/cook-sessions/getActiveCookSession) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Get a cook session (/docs/api-reference/cook-sessions/getCookSession) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Delete a cook session (/docs/api-reference/cook-sessions/deleteCookSession) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # End a cook session (/docs/api-reference/cook-sessions/endCookSession) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Restore an ended session (/docs/api-reference/cook-sessions/restoreCookSession) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Add a recipe to a session (/docs/api-reference/cook-sessions/addRecipeToCookSession) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Remove a recipe from a session (/docs/api-reference/cook-sessions/removeRecipeFromCookSession) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # List timers (/docs/api-reference/timers/listTimers) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Create a timer (/docs/api-reference/timers/createTimer) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Get a timer (/docs/api-reference/timers/getTimer) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Acknowledge a fired timer (/docs/api-reference/timers/ackTimer) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Pause a timer (/docs/api-reference/timers/pauseTimer) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Resume a paused timer (/docs/api-reference/timers/resumeTimer) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Cancel a timer (/docs/api-reference/timers/cancelTimer) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Create a share link (/docs/api-reference/sharing/createShareLink) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Revoke a share link (/docs/api-reference/sharing/revokeShareLink) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Resolve a share link (/docs/api-reference/sharing/getSharedResource) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Save a shared resource (/docs/api-reference/sharing/saveSharedResource) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # List creators (/docs/api-reference/creators/listCreators) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Get a creator (/docs/api-reference/creators/getCreator) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Rename a creator (/docs/api-reference/creators/updateCreator) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # List a creator's profiles (/docs/api-reference/creators/listCreatorProfiles) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # List creator profiles (/docs/api-reference/creators/listAllCreatorProfiles) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Get a creator profile (/docs/api-reference/creators/getCreatorProfile) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # List notifications (/docs/api-reference/notifications/listNotifications) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Create a notification (/docs/api-reference/notifications/createNotification) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Get the unread count (/docs/api-reference/notifications/getUnreadNotificationCount) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Mark a notification read (/docs/api-reference/notifications/markNotificationRead) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Mark a notification unread (/docs/api-reference/notifications/markNotificationUnread) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Mark all notifications read (/docs/api-reference/notifications/markAllNotificationsRead) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Register a device for push (/docs/api-reference/push-tokens/registerPushToken) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # List my registered devices (/docs/api-reference/push-tokens/listMyPushTokens) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Unregister a device (/docs/api-reference/push-tokens/deletePushToken) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Search cookbooks (/docs/api-reference/books/searchBooks) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # List cookbooks by author (/docs/api-reference/books/listBooksByAuthor) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Embed a search query (/docs/api-reference/ai/embedQuery) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Create a realtime assistant session (/docs/api-reference/ai/createAiSession) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Get the current user (/docs/api-reference/users/getCurrentUser) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Subscribe to live events (/docs/api-reference/events/streamEvents) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Register (legacy) (/docs/api-reference/auth/register) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml # Log in (legacy) (/docs/api-reference/auth/login) See the machine-readable contract at https://docs.flambe.dev/openapi.yaml