Hoppa till innehållet

API och integrationer Headless commerce

English

Headless commerce-referens

API:et bakom Fluits egen butik: 114 anrop i 24 grupper och 263 scheman, för katalog, kundvagn, kassa och kundens egna sidor. Sidorna genereras ur API:ets OpenAPI-dokument och uppdateras automatiskt efter varje driftsättning.

Bas-URL https://api.erp.fluit.cloud/ecom Autentisering X-Tenant-Id swagger.json llms.txt

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.

Endpoints

Session och kanal 2

Establishing context: map a domain to a tenant and channel, then open a session. The session response also carries the channel's whole configuration — currency, language, feature flags, branding, navigation and SEO — so a storefront can render its shell from one call.

GET
Kanaler 4

The storefronts a tenant runs, and the home page each one presents. A channel owns its assortment, price list, currency and branding, so the same item can be published in two channels at different prices.

PATCH POST GET
Katalog 18

Products, categories, brands, search, facets and type-ahead. Products are addressed by slug or id, and only what is published in the current channel is visible. Includes the product feed for Google Merchant and the binary endpoints that serve images and documents.

GET POST
Priser 2

Prices for a set of items in one call. The answer depends on the channel and on whether the session is signed in, because a signed-in customer gets their agreement prices. Do not cache the result across visitors.

GET POST
Omdirigeringar 1

The channel's URL redirect table, for a storefront that handles its own routing. Read once and cache — it is channel-wide and changes rarely.

GET
Innehållssidor 5

CMS pages: the navigable list and a page by slug, with its sections. Used for terms, about, delivery information and anything else the tenant edits without a deploy.

GET POST
Blogg 2

Blog and news posts for the channel, with tag filtering and related-post lookup.

GET
Hjälpartiklar 4

Knowledge base articles: browse by category, read by slug, search, and look up the articles attached to a specific product.

GET
Varukorg 10

The cart hangs off the session, so there is no cart id to pass around. Anonymous sessions have carts too, and the cart survives signing in.

GET DELETE POST PATCH
Kassa 13

Checkout from available methods to placed order, including discount coupons, gift cards, shipping price calculation and postal code lookup. Payment runs through a provider-agnostic session: the response says how to render it, not which provider produced it.

POST GET PATCH
Klarna Checkout 4

Klarna Checkout, the provider-specific predecessor of the checkout session endpoints. Still supported, but new builds should use Ecom.Checkout instead. The push endpoint here is an inbound callback from Klarna, not something a storefront calls.

POST PUT GET
Konto 7

Signing in: password, one-time code by email, and password reset. All of them upgrade the existing session rather than replacing it, so the cart carries over.

POST GET
Profil 2

The signed-in customer's own contact details.

GET PATCH
Adresser 5

The signed-in customer's delivery addresses, including which one is the default.

GET POST PATCH DELETE
Ordrar 3

The signed-in customer's order history and order details, with line level fulfilment status.

GET
Fakturor 1

The signed-in customer's invoices.

GET
Leveranser 2

Deliveries against the signed-in customer's orders, with carrier tracking where the carrier provides it.

GET
Offertförfrågningar 5

Quote requests: a signed-in customer asks for a price on a set of items, then accepts or declines what comes back. An accepted quote becomes a sales order in the ERP.

GET POST
Returer 9

Customer-initiated returns: check what an order is eligible to return, register the return, print the label and follow it. Registering moves no stock — receiving at the warehouse does.

GET POST
Ärenden 7

Support cases the customer opens from their own pages, with the message thread and attachments. Cases land in the same queue as those created inside the ERP and from the support mailbox.

GET POST PATCH
Recensioner 2

Product reviews: read the approved ones, submit a new one. Submissions are anonymous-capable and go through moderation before they appear.

GET POST
Nyhetsbrev 3

Newsletter sign-up with double opt-in, and one-click unsubscribe per RFC 8058. The unsubscribe endpoint is quota'd per token rather than per IP, because mail providers share addresses across many recipients.

POST
Shoppingassistent 1

The shopping assistant: a server-sent event stream that answers product questions against the channel's own catalogue and content.

POST
Analys 2

Page-view ingestion for the tenant's own storefront reporting, feeding the SEO and traffic views inside Fluit. It records what your storefront tells it; it is not a public analytics service.

POST

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.

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.

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.

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 GET /ecom/channels, or from the domain resolution above.

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:

"custom": {
  "blockKey": "prislista",
  "blockVersion": 4,
  "useShell": true,
  "html": "<ul class=\"prislista\">…</ul>",
  "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 <style> element.

fields is data. Keys are the block's field keys. Text is a string, formatted text sanitized HTML, a number a number, a choice { key, label }, a date ISO 8601, an image { url, alt, width, height } and a link { url, openInNewTab } — a link to a page already has the page's current address. A list is an array of objects with the same shapes. A block whose definition is missing, or that cannot be rendered, is left out of the page. A new version of a block changes every page that uses it at once, without the pages being republished.

Prices, stock and VAT

Prices come from the channel's price list, and from the customer's agreement prices when the session is signed in. The same product therefore has no single price: it has the price for this channel and this visitor. Fetch prices for a set of items in one call with GET /ecom/catalog/prices?itemIds=… rather than reading them off cached product payloads.

unitPrice is what the customer pays. Two things can lower a price: the price list hierarchy, and discount rules — campaign rules that apply to everyone, and agreement discounts bound to one customer. unitPrice carries both. The price before any line discount is unitPriceBeforeDiscount, and discountAmount, discountPercent and discountName say what the difference was and what it is called. The cached catalog payloads (listPrice on a product or a list row) carry the same net price for an anonymous visitor.

Changed 2026-09-16. unitPrice previously carried the price before discount rules, so a storefront that displayed it showed a higher price than the cart charged. If you were reading unitPrice you now get the right number and need no change; if you were relying on it as a pre-discount reference, read unitPriceBeforeDiscount instead.

isCustomerSpecific decides what you may claim. It is true when the price or the discount is bound to this customer — an agreement price list, a blanket agreement, a customer discount, or a rule conditioned on the customer or their group. Such a price is that customer's own terms, not a reduction the shop advertised, so it must never be presented against lowestPrice30Days as a price drop. Do not infer this from source: a customer discount lowers the price without changing where the price came from. Cart lines (GET /ecom/cart) carry the same flag with the same meaning: when a line's isCustomerSpecific is true, its discountAmount is the customer's agreement, not a reduction to strike through.

quantityBreaks lists the quantities where the unit price changes, from price list tiers and from discount rules with a minimum quantity alike, with the unit price at each tier already net of discounts.

Whether amounts include VAT is a channel setting, and some channels let the visitor toggle it. Read channel.features.showPricesIncludingVat and allowCustomerVatToggle from the session response and render accordingly — the numbers on the wire follow the channel, and a front end that assumes one convention will be wrong on the other.

Stock is available separately through GET /ecom/catalog/stock?itemIds=…, aggregated according to the channel's stockAggregation setting. showStock and showOutOfStockProducts decide whether a storefront is supposed to display it at all.

Both endpoints take at most 100 ids per request, and both have a POST variant that takes the same ids in a JSON body:

curl -X POST "https://api.erp.fluit.cloud/ecom/catalog/prices" \
  -H "X-Tenant-Id: $FLUIT_TENANT_ID" -H "X-Channel: web" \
  -H "Content-Type: application/json" \
  -d '{ "itemIds": ["…", "…"] }'

The POST exists because of URL length, not because of the limit: with GUIDs a typical URL budget runs out around 50 ids, well before the 100 the server actually allows. Same handler, same response body, so you can switch without touching your parser. The limit is the same on both — it protects response time, since the price engine runs per item — so chunk into batches of 100 either way. One difference worth knowing: the GET silently skips ids it cannot parse, while a malformed id in the JSON array fails the whole request with 400.

Neither variant is cacheable. Both answer no-store, because the price depends on the signed-in customer's agreement.

From cart to order

The cart hangs off the session, so there is no cart id to carry:

  1. POST /ecom/cart/items with an item id and a quantity.
  2. GET /ecom/checkout/data for the shipping and payment methods this channel offers, plus the known customer details when signed in.
  3. POST /ecom/checkout/sessions to start payment with the channel's provider.
  4. POST /ecom/checkout/place to place the order.
  5. GET /ecom/checkout/sessions/{sessionId}/confirmation on the return page.

Step 3 is provider-agnostic. The response carries a renderMode and exactly one of htmlSnippet, redirectUrl or clientSecret, and your front end acts on the mode rather than on the provider's name. That is what lets a tenant change payment provider without a front-end release.

POST /ecom/checkout/apply-code takes both discount coupons and gift cards; the response says which it was and what it did to the total.

Pickup. A shipping method with isPickup in GET /ecom/checkout/data is collected by the buyer. When the channel has pickup locations, POST /ecom/checkout/pickup-locations lists them with the cart's availability at each (InStock, AvailableFrom with a date, or Unavailable), and the buyer chooses one instead of entering a shipping address. Send its warehouseId as pickupWarehouseId on POST /ecom/checkout/place. The order is placed on that location, stock is checked there rather than across the channel, and the location's address becomes the delivery address, which is also what decides the VAT. A billing address is still required when the buyer is a company or pays by invoice. A channel without pickup locations handles a pickup method exactly as before.

The endpoints under /ecom/kco/* are the Klarna-specific predecessor of the same flow. They still work and our own storefront still uses them, but new builds should use /ecom/checkout/sessions. The KCO endpoints will not gain features.

Payment provider callbacks

POST /ecom/checkout/webhooks/{provider} and POST /ecom/kco/push are inbound. The payment provider calls them when a payment settles; you never do. They are documented because you may need to configure their URLs in the provider's dashboard, and because seeing them here explains how an order can change state without your front end doing anything.

Customer portal

Everything under /ecom/portal/* is the signed-in customer's own record: orders, invoices, shipments, quote requests, returns, support tickets, addresses and profile. All of it requires a session that has been authenticated, and all of it is scoped to that customer — there is no way to read another customer's data through these endpoints.

This is not the same thing as Fluit's partner portal, which lives under /portal and has its own API. The names are close; the surfaces are unrelated.

Idempotency

A timeout on POST /ecom/checkout/place is the one failure that a storefront cannot reason its way out of on its own: the order may or may not exist, and asking again without protection either places a second one or answers that the cart is already converted — which tells you the order exists but not what it was called.

Send an Idempotency-Key header to close that gap. Use a unique value per logical attempt, a UUID is the obvious choice, and reuse the same value on every retry of that attempt:

curl -X POST "https://api.erp.fluit.cloud/ecom/checkout/place" \
  -H "X-Tenant-Id: $FLUIT_TENANT_ID" \
  -H "X-Channel: web" \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ … }'

The first call runs normally. A retry with the same key replays the original response — the same status, the same body and the same Location — with Idempotency-Replayed: true added, and without the handler running again. Other response headers are not replayed, so read the outcome from the body rather than from them. Keys are scoped to the session and kept for 24 hours.

The header is optional here. That is a deliberate difference from the Fluit Public API, where it is required on every POST: this surface already has clients, and making it mandatory would have broken all of them at once. Omit it and the call behaves exactly as it did before.

Two responses exist only when you send the header:

  • 422 — the key was already used for a different request, meaning another endpoint or the same endpoint with a different body. Use a new key for new requests; reuse a key only when retrying the same one.
  • 503 with Retry-After — a request with the same key is still in flight. Retry with the same key once it finishes.

A 5xx is never replayed. Server errors are not a final answer to your request, so the key is released and a retry genuinely runs the operation again.

A 4xx is a final answer, and it is replayed. Once a call with a key has been rejected (out of stock, an expired cart, a validation error), that attempt is over. The shopper's next try, typically after changing the cart, is a new request and needs a new key. Reuse the old one and you get the old error back for 24 hours. Keep a key only while the outcome is unknown: no response, a network error or a 5xx.

When you cannot simply retry. If the response to POST /ecom/checkout/place was lost and the page has since been reloaded, the cart may already be an order, and sending the request again would only answer that the cart is converted. Ask GET /ecom/checkout/attempts/{key} instead, with the same session: it tells you whether the attempt is still running, failed, or created an order, and if so which one, so you can show the confirmation the shopper missed.

Honoured on POST /ecom/checkout/place and POST /ecom/checkout/sessions. POST /ecom/cart/items deliberately does not honour it, and that is not an oversight: adding the same item twice is a thing shoppers legitimately do, and suppressing the second add would silently drop a real one.

Pagination

List endpoints return a fixed envelope:

{
  "items": [],
  "totalCount": 0,
  "page": 1,
  "pageSize": 20,
  "totalPages": 0,
  "hasPreviousPage": false,
  "hasNextPage": false
}

page is 1-based. Page sizes are clamped per endpoint; ask for more than the maximum and you get the maximum, not an error.

Caching

Every response carries a Cache-Control header, and following it is better than inventing your own TTLs. There are two kinds.

Channel-wide responses — the catalogue, search, categories, brands, filters, content pages, widgets and the redirect table — answer public, max-age=… with an ETag:

Response max-age
Category tree 3600
Product detail 600
Product feed 3600
Product lists, search, filters, brands 300
Content pages, widgets, redirects 300
Type-ahead suggestions 300

Those numbers are not advice — they are the same TTLs the API uses for its own internal cache. Honouring them therefore adds no staleness that we do not already have.

Revalidation is cheap: send the ETag back as If-None-Match and an unchanged response answers 304 with no body.

curl -H "If-None-Match: $ETAG" \
     -H "X-Tenant-Id: $FLUIT_TENANT_ID" -H "X-Channel: web" \
     "https://api.erp.fluit.cloud/ecom/catalog/categories"

Everything else answers no-store and carries no ETag. That is the default for the whole surface, not a list we maintain: the cart, checkout, the customer portal, prices and stock all fall under it, and so does any endpoint we add tomorrow. Prices in particular depend on the signed-in customer's agreement, so there is no shared version of them to keep. Caching those per authenticated customer inside your own layer is fine — that is a distinction only you can draw.

If you put a shared cache or CDN in front of this API, you must vary on X-Tenant-Id, X-Channel and X-Language. All three are headers, none appears in the URL, and each decides what the response contains. We send Vary: X-Tenant-Id, X-Channel, X-Language on every cacheable response for exactly this reason — a cache keyed on the URL alone would serve one tenant's catalogue to another, and one visitor's language to the next.

Images and documents under /ecom/catalog/assets/* are immutable for practical purposes and answer public, max-age=86400 and 3600 respectively.

Errors

Status Means
400 The request is malformed, or a value is invalid
401 The endpoint needs a session and none was sent, or the token has expired
403 The session exists but is not allowed to see this record
404 No such product, page, channel or record in this channel
409 The record is not in a state where this makes sense — a cancelled order, a used coupon
429 Rate limit — see below

Do not assume one body shape. This surface predates the reference and carries three, and a client that parses every failure as RFC 7807 will throw on two of them:

  • Most 400, 403, 404, 409 and 500 responses are application/problem+json per RFC 7807, with type, title, status and detail, plus an errors object on validation failures.
  • Some endpoints — mainly the channel-404 on catalogue, content and blog reads, and the 400 on the id-list endpoints — answer application/json with a flat { "error": "…" } instead. Each operation's documented response schema is the truth; where it says ProblemDetails you get RFC 7807, otherwise expect the flat shape.
  • 401 has no body at all. The status code is the whole message.
  • 429 is application/json with problem-like fields, but not the problem media type.

Branch on the status code and the Content-Type, not on the assumption. Consolidating these onto one shape would break clients that read the current one, so it will happen as an announced change rather than quietly.

A 404 from a catalogue endpoint usually means "not published in this channel" rather than "does not exist". That distinction is deliberate: an unpublished product should be indistinguishable from a missing one.

Rate limiting

Every endpoint is rate limited. Quotas are per tenant and channel, and then per visitor — by client IP for anonymous traffic and by session token once there is one — so one busy visitor cannot spend the channel's budget.

Traffic Limit
Catalogue and search 300 / minute per IP
Session 60 / minute per IP anonymous, 120 with a token, 600 for recognised crawlers
Cart 60 / minute
Checkout 10 / minute
Sign-in 5 / minute per IP
Request a login code 3 / 15 minutes per IP
Verify a login code 10 / minute per IP
Help articles 100 / minute per channel
Page-view tracking 300 / minute per channel
Shopping assistant 20 / minute
Submit a review 5 / hour per IP
Newsletter sign-up 10 / hour per IP
Newsletter unsubscribe 20 / hour per token
Form submission 10 / hour per IP

There are no X-RateLimit-* headers on this API. You discover the quota by hitting it: a rejected request returns 429 with Retry-After in seconds and a problem-shaped application/json body. Honour Retry-After rather than retrying on a fixed delay.

The per-IP partitions use X-Forwarded-For only from a caller with a storefront key. See Architecture above.

Status and stability

This is the API behind our own storefront, and it moves with it. Additive changes — new endpoints, new optional fields, new enum members — happen without notice, so read defensively and ignore fields you do not recognise. Changes that break an existing contract are announced in the Fluit changelog before they ship.

It is a different promise from the Fluit Public API, which is versioned for third-party integrations. If you are synchronising an external system rather than building a storefront, that is the surface you want.