API och integrationer Headless commerce
EnglishHeadless commerce-referens
API:et bakom Fluits egen butik: 94 anrop i 24 grupper och 186 scheman, för katalog, kundvagn, kassa och kundens egna sidor. Sidorna genereras ur API:ets OpenAPI-dokument och uppdateras automatiskt efter varje driftsättning.
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
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.
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.
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.
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.
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.
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.
Blog and news posts for the channel, with tag filtering and related-post lookup.
Knowledge base articles: browse by category, read by slug, search, and look up the articles attached to a specific product.
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.
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.
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.
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.
The signed-in customer's own contact details.
The signed-in customer's delivery addresses, including which one is the default.
The signed-in customer's order history and order details, with line level fulfilment status.
The signed-in customer's invoices.
Deliveries against the signed-in customer's orders, with carrier tracking where the carrier provides it.
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.
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.
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.
Product reviews: read the approved ones, submit a new one. Submissions are anonymous-capable and go through moderation before they appear.
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.
The shopping assistant: a server-sent event stream that answers product questions against the channel's own catalogue and content.
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.
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.
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.
Requests from crawlers are recognised by user agent and served without persisting a session, so indexing a catalogue does not fill the session table.
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.
That shape also decides the CORS answer: browser requests come from your origin, which is allow-listed per tenant in configuration rather than open to the world. Ask us to add a domain if you need direct browser calls. Server-side calls have no such restriction.
Two practical consequences of proxying:
- Forward the visitor's address in
X-Forwarded-For. The rate limiter partitions on it, and without it every visitor shares your server's quota. - 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.
Catalog and search
Products are addressed by slug or id — GET /ecom/catalog/products/{idOrSlug} accepts
either, so a URL can carry the readable one. Lists take category, search, sort,
page and pageSize, plus attribute filters as attr_{code}=value1,value2.
GET /ecom/catalog/filters returns the facets available for a given category or search,
with counts, so the filter panel reflects what is actually in the result rather than the
full attribute vocabulary. GET /ecom/catalog/suggest powers type-ahead.
Search is index-backed with relevance ranking, synonyms and typo tolerance — not a
substring match — so results are ordered by relevance unless you pass an explicit sort.
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.
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:
POST /ecom/cart/itemswith an item id and a quantity.GET /ecom/checkout/datafor the shipping and payment methods this channel offers, plus the known customer details when signed in.POST /ecom/checkout/sessionsto start payment with the channel's provider.POST /ecom/checkout/placeto place the order.GET /ecom/checkout/sessions/{sessionId}/confirmationon 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.
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.503withRetry-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.
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-IdandX-Channel. Both are headers, neither appears in the URL, and both decide what the response contains. We sendVary: X-Tenant-Id, X-Channelon every cacheable response for exactly this reason — a cache keyed on the URL alone would serve one tenant's catalogue to another.
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,409and500responses areapplication/problem+jsonper RFC 7807, withtype,title,statusanddetail, plus anerrorsobject on validation failures. - Some endpoints — mainly the channel-
404on catalogue, content and blog reads, and the400on the id-list endpoints — answerapplication/jsonwith a flat{ "error": "…" }instead. Each operation's documented response schema is the truth; where it saysProblemDetailsyou get RFC 7807, otherwise expect the flat shape. 401has no body at all. The status code is the whole message.429isapplication/jsonwith 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 |
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 depend on X-Forwarded-For reaching us. 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.