# Fluit Headless Commerce API > The commerce API behind Fluit's own storefront, available for your own front end. > Catalogue, search, pricing, cart, checkout and the signed-in customer's pages — the same > endpoints our storefront calls, reading the same ERP that runs the warehouse and the > ledger. There is no separate commerce database to keep in sync: stock is the stock, the > price is the price a salesperson would quote, and an order placed here is an order in > Fluit. - Base URL: `https://api.erp.fluit.cloud` — the paths listed below already include the `/ecom` prefix. - Authentication: send the tenant in the `X-Tenant-Id` header and the storefront in `X-Channel` on every request, plus a session token from GET /ecom/session as `Authorization: Bearer` on everything that concerns a visitor. - Version: `v1`. - OpenAPI document, with the complete request and response schemas: https://api.erp.fluit.cloud/swagger/ecom/swagger.json - Reference documentation for humans: https://fluit.se/api-docs/commerce-reference - Contact: info@fluit.se ## Getting started Three calls get you from a domain name to a product listing. **1. Resolve the domain to a tenant and a channel.** This is the only call that needs no headers — it runs above tenant context and exists to establish it. ```bash curl "https://api.erp.fluit.cloud/ecom/session/resolve?domain=shop.acme.com" # { "tenantId": "…", "channelCode": "web", "channelName": "Acme Web" } ``` You can skip this call and configure the tenant id and channel code directly if you know them. It exists for the multi-domain case, where one deployment serves several storefronts and the domain decides which. **2. Open a session.** The response carries the token you send from here on, plus the channel's full configuration: currency, language, feature flags, branding, navigation and SEO settings. ```bash curl "https://api.erp.fluit.cloud/ecom/session" \ -H "X-Tenant-Id: $FLUIT_TENANT_ID" \ -H "X-Channel: web" # { "token": "…", "expiresAt": "…", "isAuthenticated": false, "cartItemCount": 0, "channel": { … } } ``` **3. Call everything else** with the tenant, the channel and the session token. ```bash curl "https://api.erp.fluit.cloud/ecom/catalog/products?pageSize=20" \ -H "X-Tenant-Id: $FLUIT_TENANT_ID" \ -H "X-Channel: web" \ -H "Authorization: Bearer $SESSION_TOKEN" ``` ## Tenants and channels Two headers scope every request. `X-Tenant-Id` selects the company. It is a GUID, and it is not a secret: this surface only ever returns what the channel has published, so knowing the id gets you the same catalogue a visitor sees in the browser. Everything that is not public — cost prices, other customers, the ledger — lives behind endpoints this API does not have. `X-Channel` selects the storefront within that company. A channel owns its assortment, price list, currency, language, VAT display and branding, so the same item can be published in two channels at different prices under different names. **Omitting the header falls back to the first active channel**, which is convenient in a single-channel tenant and a silent source of wrong prices in a multi-channel one. Send it explicitly. Channel codes come from the domain resolution above, or from the channel settings in Fluit. `X-Language` selects the language for translated content, as an ISO 639-1 code (`sv`, `en`). It is optional and forgiving: omit it, or send a language the channel does not publish, and you get the channel's default — never an error. The channel decides which languages exist, and the session response lists them in `channel.languages` alongside the resolved `channel.language`. It applies to the endpoints that actually return translated text — the session, the category tree, filters, product lists, product detail, search, type-ahead and the shopping assistant. Each operation's parameter list says whether it takes the header. Note that every cacheable response varies on it regardless, so a shared cache in front of us can never mix languages between visitors. ## Sessions `Authorization: Bearer` carries an `EcomSession` token. It is an opaque handle, not a JWT — do not try to decode it, and do not expect claims inside it. A session is not a login. `GET /ecom/session` issues one to an anonymous visitor, and that anonymous session carries a cart. Signing in through `POST /ecom/auth/login` or the one-time-code endpoints upgrades the session in place, so the cart survives the login and prices switch to the customer's agreement prices in the same moment. `isAuthenticated` on the session response tells you which state you are in. Anonymous is not the same as tokenless. The cart belongs to the session, so every cart, checkout and customer-portal call needs that token even before anyone has signed in — without it they answer `401`. The sign-in endpoints need one too, because signing in upgrades a session that must already exist. Get the token first, then use it throughout. Three groups work without a token: catalogue and content read the same for everyone, and `GET /ecom/session/resolve` runs before there is a session to have. Two more take one when offered and answer anyway without it: `GET /ecom/catalog/prices` and `GET /ecom/catalog/stock` fall back to list prices and channel-level stock. Each operation's `security` says which case it is. Sessions live for seven days and extend themselves as they are used. `POST /ecom/auth/logout` ends one early, and ending it is the only thing that makes the token stop working — dropping your copy of it stops *you* from sending it, but the token stays valid for whoever else has one until it expires on its own. On a shared device, or after a token has been anywhere you did not intend, that difference is the whole point. Call it before you clear your cookie or your token store, because afterwards you no longer have the token the call needs. It is idempotent: a token that is already ended, expired or simply unknown answers `204` too, so a retry after a lost response, or two browser tabs signing out at once, needs no special handling. Only a request with no `Authorization` header at all is an error. The cart hangs off the session and goes with it. The next session starts empty whether or not the visitor signs back in — which is what you want on a shared device, and worth knowing if you meant to keep something. Requests from crawlers are recognised by user agent and served without persisting a session, so indexing a catalogue does not fill the session table. ## Accounts There is no `POST /ecom/auth/register`, and its absence is a design decision rather than a gap. A customer in Fluit is a record in the ERP that a salesperson may hold an agreement and a price list against — not a row a visitor creates by filling in a form. Accounts come into being two ways: - **At checkout.** Send `createAccount: true` and a `password` on `POST /ecom/checkout/place`. The order creates the customer if the e-mail is new, and the account is attached to it. The two fields work only together: `createAccount` without a password creates no account and reports no error — and on a channel with `guestCheckout: false` the order is then rejected as a guest order, which is the confusing way to discover it. Send both or neither. - **In the ERP.** A salesperson creates the customer and invites the contact. This is the normal path in B2B, where the account follows an agreement that was negotiated somewhere other than a web form. Three channel settings decide what your checkout must offer, and all three are on the session response under `channel.features`: | Setting | When true | | --- | --- | | `requireLogin` | Only signed-in customers can order. Neither guests nor `createAccount` get through — offer sign-in, not a registration form | | `guestCheckout` | Ordering without an account is allowed. When false, the visitor must either sign in or pass `createAccount` | | `showPricesWithoutLogin` | Prices are visible to anonymous visitors at all | `GET /ecom/checkout/data` returns `requireLogin` and `guestCheckout` too, so the checkout page does not have to carry them from the session. A customer who has an account but no password — invited from the ERP, or simply someone who forgot — signs in with a one-time code: `POST /ecom/auth/request-code` followed by `POST /ecom/auth/one-time-login`. That is the way back in, and it is worth offering alongside the password field rather than only behind a "forgot password" link. ## Architecture: calling from your own server The intended shape is server to server. Your front end calls your own backend, and your backend calls Fluit — holding the tenant id, the session token and any customer credentials on your side, and exposing to the browser only what that page needs. This is how our own storefront is built. It is a SvelteKit app whose pages load through server routes, with a thin set of proxy endpoints under its own origin for the calls that have to happen after hydration. The browser never talks to this API directly. **CORS does not stand in your way.** Browser requests are accepted from any origin — no allow-list, nothing to ask us for. That costs nothing to give: this surface is already callable anonymously from any server, and returns only what the channel has published, so CORS was never what protected it. It only ever constrained browsers, which meant it only constrained the clients doing what we recommend. Because authentication is a bearer header and never a cookie, the policy sends no credentials. Do not expect cookies to ride along on a cross-origin call, and do not put anything in one. Five response headers are exposed for JavaScript to read: `ETag` (without it you cannot revalidate — see *Caching*), `Retry-After`, `Location`, `Idempotency-Replayed` and `Content-Disposition`. Everything outside that set and the CORS safelist is invisible to a browser client, whatever the server sent. Server to server is still the shape we recommend, for the reasons above rather than because the browser path is blocked. Two practical consequences of proxying: - Forward the visitor's address in `X-Forwarded-For`, and prove that you may by sending a storefront key in `X-Fluit-Storefront-Key`. Create the key under *Settings → API keys* with the storefront permission; a full-access key does not carry it. With the key, the rate limiter partitions on the forwarded address. Without it, the header is ignored and every visitor shares your server's quota, because anyone can call this API and anyone could otherwise pick a fresh address per request. Keep the key on your server; it never belongs in a browser. - Cache what does not change per visitor — but you do not have to work out which is which. Every response says so itself in `Cache-Control`. See *Caching* below. ## Pages, website mode and forms **Website mode.** A channel can be a plain website without a shop. The session response says so in `features.commerceEnabled`. When it is `false`, every commerce surface answers `404` with a problem body — catalog, products, cart, checkout, payment callbacks, accounts, the portal, postal-code lookup and the shopping assistant — so a website cannot take an order through the API either. Pages, the home page, navigation, forms, the newsletter and articles work as usual. **Pages live at the root.** A content page's address is its parents' slugs plus its own — `/about`, `/services/tyre-change` — and the home page is `/`. Resolve an incoming path with `GET /ecom/pages/resolve?path=/services/tyre-change`: the page, or `404`. The last segment identifies the page, so a stale path — a page moved under another parent, or the old `/pages/{slug}` address — still finds it; compare the response's `canonicalPath` with the requested path and redirect permanently when they differ. A renamed slug leaves a URL redirect behind, like a renamed product. `navigation.reservedSlugs` in the session lists the first path segments that are never pages (`cart`, `checkout`, `products`, `blog`, …), so a router can send those to the shop before asking the page API. **Sections.** A page and the home page are lists of sections with a `type`. Every section has the same heading fields (`eyebrow`, `title`, `subtitle`, `buttons`, `tone`, `spacing`, `alignment`, `anchor`) plus fields for its own type. Button targets are resolved to addresses on the server. Reusable section collections are expanded in place — a client never sees a collection reference. Treat an unknown `type` as "skip this section": new types are added without a version change. **Products in a section.** The product sections (`FeaturedProducts`, `ProductsByCategory`, …) and `ProductSpotlight` carry ids, not product data: fetch them with `GET /ecom/products/{id}` so that price, stock and documents are the ones this visitor should see rather than a copy cached with the page. A `ProductSpotlight` section names one product and says how to show it; when its `documents` is `Selected` or `Category`, list the matching entries from that product's `documents`. **Embeds.** An `Embed` section names an external https page (`url`), the frame's accessible name (`title`) and its `height` in pixels. Render it as a sandboxed `iframe` and allow the url's origin in your page's `frame-src`. `embed` is null when the stored address cannot be embedded — skip the section then. **Forms.** A `Form` section carries the whole form: fields, labels, options, whether a field is required, the consent text and a `formToken`. Submit it with `POST /ecom/forms/{formToken}/submissions`, as JSON or as `multipart/form-data` when it has file fields. Send `formLoadedAtUnixMs` (when the form was rendered), the honeypot field empty, `consentGiven` when the form has a consent text, and `sourceUrl`. A submission without `formLoadedAtUnixMs`, with the honeypot filled in, or sent faster than a person can type is accepted with the same response as a real one and then dropped. A missing required field or consent comes back as a `400` problem. A form that has been deactivated is left out of the page response, so a page never shows a form that cannot be sent. ## Custom blocks A channel can define its own blocks: fields plus an HTML template, written in Fluit by a developer and filled in by editors like any other block. A section with `type: "Custom"` carries the block in `custom`: ```json "custom": { "blockKey": "prislista", "blockVersion": 4, "useShell": true, "html": "", "css": "[data-block=\"prislista\"] .rad{…}", "fields": { "rader": [{ "tjanst": "Tyre change", "pris": 495, "fran": false, "lank": { "url": "/services/tyres", "openInNewTab": false } }], "fotnot": "All prices include VAT." } } ``` **Render the HTML, or build your own from `fields`.** The template is rendered on the server, so the HTML is what the channel's own storefront shows. It is sanitized on the server already — no scripts, event attributes or `javascript:` links — but sanitize it again before you insert it, as you would any HTML from an API. Put it in an element with `data-block="{blockKey}"`: the CSS is scoped to that attribute and does not reach the rest of the page. When `useShell` is `true`, draw the section's heading fields around it as for any section; when it is `false`, the HTML draws everything itself. **Add the CSS once per page.** Every section with the same block carries the same `css`. Deduplicate on `blockKey` and `blockVersion` and put it in one `