Hoppa till innehållet

API and integrations Headless commerce

Svenska

Headless commerce reference

The API behind Fluit's own storefront: 94 calls across 24 groups and 186 schemas, covering catalogue, cart, checkout and the signed-in customer's pages. The pages are generated from the API's OpenAPI document and refreshed automatically after every deployment.

Base URL https://api.erp.fluit.cloud/ecom Authentication 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 and channel 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
Channels 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
Catalog 15

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
Pricing 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
Redirects 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
Content pages 2

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
Blog 2

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

GET
Help articles 4

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

GET
Cart 5

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
Checkout 10

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
Account 6

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
Profile 2

The signed-in customer's own contact details.

GET PATCH
Addresses 5

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

GET POST PATCH DELETE
Orders 2

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

GET
Invoices 1

The signed-in customer's invoices.

GET
Shipments 2

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

GET
Quote requests 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
Returns 6

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
Tickets 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
Reviews 2

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

GET POST
Newsletter 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
Shopping assistant 1

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

POST
Analytics 1

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.

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.

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:

  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.

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.

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 and X-Channel. Both are headers, neither appears in the URL, and both decide what the response contains. We send Vary: X-Tenant-Id, X-Channel on 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, 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

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.