# Build a storefront on Asstio

**Base URL: `https://api.storefront.asstio.com`** · this document: `GET /docs/onboarding` · OpenAPI: `GET /openapi/v1.json` · browsable reference: `GET /docs`

This is the entry point for building a new webshop frontend on Asstio Storefront. It is written for an AI
agent with Asstio MCP tools: read it top to bottom, call the tools it names, and build the frontend. A human
reading it directly gets the same story, minus the ability to run step 1.

Asstio Storefront is a shared, multi-tenant, headless B2C read API. A new shop is **configuration inside
Asstio**, not a new backend to operate: no infrastructure to provision, no database, no deploy on our side.
The frontend lives in its own repository, is deployed wherever the customer likes, and talks only to the
public API documented here.

Division of labour, so you know where to put things:

| Concern | Owner |
| --- | --- |
| Products, prices, stock, categories, customers, orders | Asstio (Purchase) |
| Search index, slugs, facets, listing/detail responses | Asstio Storefront (this API) |
| Pages, layout, design, routing, SEO markup, cart UI | Your frontend |

## What exists today, and what does not

**Today the API is a catalogue read surface with images, descriptions, attributes, brand, related products and
bundles (0.3). No editorial page content yet — that is the CMS phase.** Verified against the code at `v0.5.0`,
2026-09-21 (images are live on a shop once its storefront and Asstio are on the 0.25 release):

| Available | Endpoint |
| --- | --- |
| Site bootstrap (markets, URL patterns, config version, media URL templates) | `GET /v1/site` |
| Product search & listing with facets | `GET /v1/products` |
| Product, variant and cart-line images as media descriptors (see [Images](#images)) | on every product, card, variant and cart response |
| Product detail (by slug, historical slug or id) | `GET /v1/products/{slugOrId}` |
| Product lookup by SKU (product's own or a variant's) | `GET /v1/products/by-sku/{sku}` |
| Variant paging with cascading availability | `GET /v1/products/{slugOrId}/variants` |
| Collection tree and one collection with its listing | `GET /v1/collections`, `GET /v1/collections/{slugOrId}` |
| Category tree as data (facet source, not pages) | `GET /v1/categories` |
| Sitemap rows and lastmod for revalidation | `GET /v1/sitemap` |
| Cart: create, read, add/update/remove line, change market | `POST/GET /v1/cart`, `POST /v1/cart/lines`, `PATCH/DELETE /v1/cart/lines/{id}`, `POST /v1/cart/market` |
| Product content: description, attributes, brand, related products, bundles | on every product detail response (see [Content, attributes, related products and bundles](#content-attributes-related-products-and-bundles)) |
| Search-as-you-type and search within a facet panel | `GET /v1/search/suggest`, `GET /v1/products/facets/{key}` (see [Suggest and search-in-facet](#suggest-and-search-in-facet)) |
| Brand pages and responsible parties | `GET /v1/brands`, `/v1/brands/{slugOrId}`, sitemap `type=brand`; `brand` and `responsible` on products and variants |

**Not built yet — do not design around it, do not promise it to the user:**

- **No checkout, no order endpoints.** The cart exists; paying for it does not yet. A storefront built today
  can let a shopper build a cart, not complete a purchase.
- **No video playback yet.** Video descriptors can appear in `images[]` (`kind: "video"`); render their poster
  frame like an image until the encoder's video pipeline ships.
- **No customer accounts, no wishlists, no reviews.**

Images **do** exist: every product, card, variant and cart line carries media descriptors, and `GET /v1/site`
carries the templates that turn them into URLs. See [Images](#images) below before you render a single `<img>`.

If the user needs a buy flow now, say this plainly before building anything.

---

## Step 0 — Look at the catalogue before you design anything

The most expensive mistake on this API is designing against an imagined catalogue. Five minutes of
looking first saves days. Do this **before** you draw a screen or ask the user a single question.

If the shop already exists in Asstio — the usual case when someone wants a frontend built — you can run
this now with any publishable key. For a genuinely new shop there is nothing to look at yet: do Step 1
first, then come back here before designing.

```bash
KEY=sfk_pub_…                       # an existing publishable key, or mint one (1.4) and publish (1.5)
API=https://api.storefront.asstio.com
H=(-H "X-Site-Key: $KEY" -H "X-Market: se")

curl -s "${H[@]}" "$API/v1/products?pageSize=5" | jq '{total, names: [.items[].name], facets: [.facets[].key]}'
curl -s "${H[@]}" "$API/v1/collections" | jq 'length'
```

Then read the answers honestly:

| What you see | What it means for your design |
| --- | --- |
| `total` far larger than the shop's real product count, and names that read like a variant matrix — every size, colour and pack spelled out in the product name | The tenant's variants are not linked to parent products, so every SKU lists as its own product. A product grid is unusable until that is fixed in Asstio. **Raise it before building the grid, not after.** |
| `variantCount` in the hundreds or thousands on a product | Normal wherever a product has several independent axes — size × colour × pack multiplies fast. `variants` will be `null`, so plan for the paged endpoint from the start rather than as a refactor. |
| `/v1/collections` returns `[]` | There is no navigation to build. Either collections get configured (1.3) or the nav needs a different source. |
| A facet the design assumes is absent from `facets[]` | It does not exist. Render facets from the response (see [Filters](#filters)); never hard-code the ones a mock-up drew. |
| Products missing entirely | Usually no price on that market — a product with no price is not sold there and never appears. Check `get_storefront_sync_status` for `NO_PRICE` warnings. |
| `inStock: true` on very few items | Stock-driven features (sale pages, "only N left") will look empty. Check before promising them. |

Also check `get_storefront_sync_status(siteId)` and actually read its `warnings` — they name the
products the catalogue is silently dropping and why.

---

## Step 1 — Create the shop in Asstio (MCP tools)

Do this yourself with the Asstio MCP tools. Do not send the user into the Purchase UI to click.

**Look things up before you ask.** Most of what you need is discoverable, and every question you ask
the shop owner is a question they may not know the answer to. `list_instances` names the instances,
`get_company_profile` the business, `list_storefront_sites` whatever already exists — an existing site
often answers market, currency and price-list questions by example. Ask only what discovery cannot settle:

1. **Which Asstio ecom site** the shop sells for (`ecomSiteId`). Verify with `list_instances` /
   `set_default_instance` first that you are on the right Asstio instance — the storefront tools act on the
   default instance and take no instance argument.
2. **Market**: language, currency, country, and **the real future domain** of the shop.
3. **Which Asstio price list** the market prices from (`priceListId`).

Then, in order:

### 1.1 `save_storefront_site`

```
save_storefront_site(
  code = "moory",          # names the search index: lowercase letters, digits, hyphens, max 40 chars
  name = "Moory",
  ecomSiteId = 3,
  devOrigins = ["http://localhost:3000"],
  assortmentJson = "{\"categoryRootIds\":[100],\"excludeInactive\":true}"   # optional
)
```

`code` is effectively permanent: changing it renames and rebuilds the search index. Pick it once.

`urlPatterns` is settable here too — `{ "product": "/p/{slug}", "collection": "/k/{path}" }` by default.
**Decide it before any collection exists.** Slugs are mint-once identity with history kept, so a pattern
change later leaves every cached listing URL, canonical and sitemap entry pointing at the old shape.

`devOrigins` matters more than it looks — see [CORS](#cors-which-origins-may-call-from-a-browser).

Returns the `siteId` every other tool needs.

### 1.2 `save_storefront_market`

```
save_storefront_market(
  siteId = 7, code = "se", language = "sv", currency = "SEK", country = "SE",
  urlTemplate = "https://www.moory.se{path}",
  priceListId = 12, isDefault = true
)
```

- `urlTemplate` must be absolute and contain `{path}`. The API builds every `url` and `alternateUrls` in its
  responses from it, and it is also what CORS allows. Use the real production domain, even before it resolves.
- **One market = one VAT country = one shipping country.** A second country is a second market, never a
  variation inside one.
- Exactly one market is the default; marking a new one demotes the old.
- `brandUrlPattern` (e.g. `/varumarken/{slug}`) turns brand pages on for the market, together with a brand filter (facet source
  `{ "kind": "brand" }`).

### 1.3 Facets and collections (optional)

Skip this unless the user already knows their filter set. With no configuration at all the shop gets
**zero-config facets**: every active category root becomes a facet (a hierarchy when the tree is deep enough),
plus the built-in `price` range and `in-stock` toggle. That is a working filter sidebar on day one.

When the user does know what they want:

- `save_storefront_facet_config(siteId, key, definitionJson)` — one facet per call. `price` and `in-stock` are
  reserved keys.
- `save_storefront_collection(siteId, slugsJson, namesJson, filterJson, …)` — a collection is a listing page
  defined by a filter, with its own slug per language and its own place in the shop's tree. Use
  `{"all": []}` to match everything.

Categories are a **facet source**, never pages. List pages are collections.

### 1.4 `create_storefront_api_key`

```
create_storefront_api_key(siteId = 7, kind = "publishable", label = "Next.js frontend")
```

The raw key is returned **once and stored nowhere**. Hand it to the user immediately and tell them where to
put it (`.env.local`, then the host's secret store) — you cannot retrieve it again.

| kind | Prefix | Use it for |
| --- | --- | --- |
| `publishable` | `sfk_pub_…` | Everything a browser does. Safe in a client bundle. |
| `preview` | `sfk_prv_…` | Reads the **unpublished draft** config and preview index. Editorial preview only. |
| `secret` | `sfk_sec_…` | Server-to-server only, higher rate limit. Sent from a browser it is rejected with `SECRET_KEY_FROM_BROWSER` and gets no CORS headers. |

A Next.js app rendering on the server can use either: `secret` for server components/route handlers,
`publishable` for anything that reaches the client. When in doubt, `publishable`.

### 1.5 `publish_storefront_config` — the step that is forgotten

```
publish_storefront_config(siteId = 7, note = "initial setup")
```

**Nothing above is live until this runs.** Every save lands in the draft. A freshly minted key answers
`401 SITE_KEY_INVALID` until the config that contains it has been published — this is the single most common
"the key doesn't work" cause. Check `get_storefront_draft_diff(siteId)` first if you want to see what goes out.

The shop picks up the new version within about a minute.

### 1.6 Verify

- `get_storefront_sync_status(siteId)` — the latest indexing run: mode, outcome (`ok | gated | failed |
  cancelled`), document count, errors, warnings. The first full index of a new shop takes a while; until it
  finishes, listings are empty or `503 INDEX_UNAVAILABLE`.
- `curl -s https://api.storefront.asstio.com/v1/site -H "X-Site-Key: sfk_pub_…"` — should return the site,
  its markets, a `configVersion` and a `media` block (`baseUrl` + four URL `templates`).
- `trigger_storefront_reindex(siteId)` exists but is normally unnecessary: indexing follows the catalogue by
  itself. Use it only when the index and the catalogue have demonstrably drifted.
- `build_storefront_preview(siteId)` builds a preview index from the draft for a `preview` key, leaving the
  live shop untouched.

---

## Step 2 — Talk to the API

### Headers

| Header | Required | Meaning |
| --- | --- | --- |
| `X-Site-Key` | yes | Which shop, and with which visibility. Missing → `401 SITE_KEY_MISSING`; unknown or revoked → `401 SITE_KEY_INVALID`. |
| `X-Market` | no | Market code, e.g. `se`. Omitted → the site's default market. Unknown → `400 MARKET_UNKNOWN` (the problem lists the known codes). |
| `Accept-Language` | no | BCP-47 tag. Omitted → the market's language. |

**The tenant and the market are never in the URL.** Every shop calls the same paths; the key decides which
catalogue answers. Responses carry `X-Config-Version` (which published config produced the answer) and an
`ETag`.

### Minimal call, no SDK

```ts
const BASE = "https://api.storefront.asstio.com";

async function storefront<T>(path: string, init: RequestInit = {}): Promise<T> {
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    headers: {
      "X-Site-Key": process.env.STOREFRONT_SITE_KEY!,
      "X-Market": "se",
      "Accept-Language": "sv-SE",
      ...init.headers,
    },
  });
  if (!res.ok) {
    const problem = await res.json();       // application/problem+json
    throw new Error(`${problem.code}: ${problem.detail ?? res.status}`);
  }
  return res.json() as Promise<T>;
}

const listing = await storefront("/v1/products?q=parfym&page=1&pageSize=24&sort=price_asc&filter[brand]=dior,chanel");
```

### The typed SDK

`@asstio-dev/storefront` is a generated TypeScript client for this API: the same headers, typed paths,
typed responses and typed error codes. Prefer it over hand-rolled `fetch` in a TypeScript project.

```bash
npm install @asstio-dev/storefront
```

```ts
import { createStorefrontClient, filterQuery, encodeFilterValues, decodeFilterValues } from "@asstio-dev/storefront";

const storefront = createStorefrontClient({
  baseUrl: "https://api.storefront.asstio.com",
  siteKey: process.env.STOREFRONT_SITE_KEY!,   // publishable in the browser, secret only on a server
  market: "se",
  acceptLanguage: "sv-SE",
});

const { data, error } = await storefront.GET("/v1/products", {
  params: { query: { q: "parfym", page: 1, pageSize: 24, sort: "price_asc",
                     filter: filterQuery({ brand: ["dior", "chanel"], price: "100-500" }) } },
});

if (error) throw new Error(`${error.code}: ${error.detail ?? ""}`);   // error.code is typed
```

The client sets `X-Site-Key`, `X-Market` and `Accept-Language` on every call, so you never pass them by hand.
`filterQuery()` encodes multi-select values (see [Filters](#filters)) and drops `undefined` ones; `filter` goes on
the wire as `filter[brand]=dior,chanel`. The package also exports the media URL helpers (`pictureSources`, `mediaUrl`,
`srcSet`, `posterUrl`, `fileUrl`, `videoSources`) — see [Images](#images) — and the `MediaDescriptor` /
`MediaConfig` types.

In another language, or without the package: the OpenAPI document is the contract and the SDK is generated
from exactly that snapshot, so generate your own types from it.

```bash
npx openapi-typescript https://api.storefront.asstio.com/openapi/v1.json -o src/storefront-schema.d.ts
```

### CORS: which origins may call from a browser

The API allows exactly two sets of origins, both derived from the shop's own published configuration:

1. the origin of every active market's `urlTemplate`, and
2. everything listed in the site's `devOrigins`.

So `http://localhost:3000` **must be in `devOrigins`** (via `save_storefront_site`, then published) or local
browser development fails at the preflight. Server-side rendering is unaffected — CORS is a browser rule.

### Rate limits

600 requests/minute per publishable or preview key, 6000 for a secret key, 60 per IP for calls carrying no
valid key. Over the limit is `429 RATE_LIMITED` with `Retry-After` and `retryAfterSeconds` in the body. Limits
are per key, so a server-rendered site behind one key shares one budget — cache.

### Caching

Successful reads carry `ETag`, `Cache-Control: public, max-age=60, stale-while-revalidate=300` and
`Vary: X-Site-Key, X-Market, Accept-Language`. Send the ETag back as `If-None-Match` for a `304`. Draft
(preview-key) responses are `private, no-store`; errors are `no-store` and never carry an ETag.

Do not put a shared CDN cache in front of catalogue responses in v1 — the answer depends on all three headers,
and a cache that mis-keys them serves one shop's page to another.

---

## Step 3 — Recommended stack

**Start from [`asstio-shop-starter`](https://github.com/hcbs/asstio-shop-starter)** (public; no GitHub account
needed: `git clone https://github.com/hcbs/asstio-shop-starter.git`). It is a working reference storefront on
this API — four screens, no design, no shop-specific logic. Copy it and build on top, or read it to see what the
API expects of a frontend. It already implements the three things most often got wrong: facets rendered from the response
with no facet key in the code, errors branched on `code`, and historical slugs turned into `301`s.

**Next.js (App Router) + TypeScript, Server Components for listing and detail pages.** Not a requirement — the
API is plain HTTP and stack-agnostic — but it is what the Asstio ecosystem is built and tested against:

- A public shop needs server rendering for SEO; listing and detail responses are exactly the SSR payload.
- The site key stays on the server when you render there.
- The generated client is TypeScript, and the OpenAPI document types any TS codebase today.
- `/v1/sitemap` with `?since=` is built for `sitemap.xml` and for selective revalidation (ISR/`revalidateTag`).

Suggested shape:

| Route | Call |
| --- | --- |
| `/` | `GET /v1/collections` for navigation, `GET /v1/products?sort=newest` |
| `/[collection-path]` | `GET /v1/collections/{slug}` — collection, breadcrumb, listing, hreflang in one call |
| `/search` | `GET /v1/products?q=…` — same `ListingResponse` shape, so one component renders both |
| `/p/[slug]` | `GET /v1/products/{slug}`, then `/variants` when `variants` is `null` |
| `/sitemap.xml` | `GET /v1/sitemap?type=product` and `?type=collection`, paged |

Build URLs from the API's own `url` and `alternateUrls` fields (they come from `urlTemplate`), and make your
route pattern match the shop's `urlPatterns` from `GET /v1/site`.

---

## Contract reference

### Listing: `GET /v1/products` and the listing half of `GET /v1/collections/{slugOrId}`

One response shape for search, browse and collections:

```jsonc
{
  "items": [{
    "id": "1234", "slug": "moory-parfym-50ml", "name": "Moory Parfym 50 ml", "sku": "MP50",
    "price": { "amount": "499.00", "currency": "SEK", "vatRate": "25.00", "vatIncluded": true, "compareAt": null },
    "priceVaries": false, "inStock": true, "url": "https://www.moory.se/p/moory-parfym-50ml",
    "collections": ["dam"], "updatedAt": "2026-09-16T10:00:00Z",
    "image": { "id": "sha256:…", "recipe": 1, "kind": "image", "role": "primary", "w": 1840, "h": 1226,
               "widths": [320, 640, 960, 1280], "formats": ["avif", "jpg"], "blurhash": "LKO2…", "color": "#3a4f6b",
               "bg": "white", "alt": { "sv": "Moory Parfym, framsida" }, /* …22 keys, see Images */ }
  }],
  "facets": [ /* key, label, type, match, values[] (each with help), truncated, searchable, range, help, collapsed */ ],
  "appliedFilters": [{ "facet": "brand", "value": "dior", "label": "Dior" }],
  "total": 128, "page": 1, "pageSize": 24,
  "sort": { "selected": "relevance", "available": [{ "key": "price_asc", "label": "Pris stigande" }] },
  "redirect": null, "queryCorrection": null,
  "warnings": []
}
```

Query parameters: `q` (max 200 chars, longer is truncated with a warning), `page` (default 1), `pageSize`
(default 24, max 100), `sort`, `filter[…]`, `collection`. `page × pageSize` may not exceed 10 000 —
`400 INVALID_PAGE`. Sort keys: `relevance`, `price_asc`, `price_desc`, `name_asc`, `newest`.

### Filters

**One contract for every facet, including price and stock**: `filter[<key>]=<value>`, comma-separated for
multi-select. Whether values within a facet are OR'd or AND'd comes from the facet's configuration
(`match: any | all | single`) — the frontend never special-cases a facet.

```
?filter[brand]=dior,chanel        # list facet
?filter[price]=100-500            # range facet: min-max, either bound may be empty ("100-", "-500")
?filter[in-stock]=true            # toggle: opt-in only; false means "no opinion", not "only out of stock"
```

**Values are raw, so a value may contain a comma.** A value key is what the data holds — an option such as
`14,2` is the key `14,2` — and in a list, swatch or hierarchy filter `\,` is a comma inside a value and `\\` a
backslash (a `\` before anything else is just a backslash, and a URL without one parses exactly as before).
Never build or split the parameter by hand: `encodeFilterValues(["14,2", "14.2"])` gives `14\,2,14.2`, and
`decodeFilterValues(param)` reads the selected values back out of a URL — for every facet, with no special cases.
Ranges (`min-max`) and toggles (`true`) are single values and need neither.

**Range bounds may be negative.** A numeric property keeps its sign, so either bound of `min-max` may carry a
minus: `-10--2`, `-10-2`, `-10-` (at least −10) and `--2` (at most −2). Either bound may be left out, and a leading
dash with nothing before it is still an open lower bound: `-500` means at most 500. A range whose lower bound is
above its upper bound is ignored and reported in `warnings` — never a 400.

Repeating `filter[brand]` twice is one filter with several values, not two filters.

A filter that cannot be honoured does **not** fail the page: the request answers `200` with a `warnings[]`
entry, so a stale bookmark never blanks a listing.

| Warning `code` | Meaning |
| --- | --- |
| `FILTER_IGNORED` | No such facet here, or an unparseable value; that filter was dropped. |
| `SORT_UNKNOWN` | Unknown `sort`; the default sort was used. |
| `COLLECTION_UNKNOWN` | The named collection does not exist; it was not applied. |
| `QUERY_TRUNCATED` | `q` was over 200 characters; results are for the shortened term. |

Render facets generically from `facets[]` (`type`: `list | hierarchy | range | toggle | swatch`) and build
filter URLs from facet keys. Never hard-code a facet — every shop has a different set, and zero-config shops
get theirs from their category tree. A value's `swatch` is `null` or `{ color, image }`: `color` is a CSS
colour or `null`, `image` a media descriptor (`role: "swatch"`, small `widths`) or `null` — render whichever
is present, both when both are. `help` (on a facet and on each value) is a short explanation in the request
language or `null`; `collapsed: true` means the shop wants that panel closed until the shopper opens it.

Numeric facets (a volume, a weight) use the range form like price: `filter[volume]=30-100`, and the facet's
`range.unit`/`range.step` say how to render the slider.

### Prices

- **Never send a price, discount or VAT field in a request.** Prices are resolved server-side, per market,
  from the index. Any price in a request would be ignored at best.
- Amounts are **decimal strings** (`"499.00"`), never JSON floats: `{ amount, currency, vatRate, vatIncluded,
  compareAt }`. `compareAt` is the struck-through original where one exists.
- On a listing card, `price` is the cheapest sellable variant and `priceVaries` says the variants differ —
  that is the "från 499 kr" case. A product with no price on the requested market never appears; it is not
  sold there.
- Gross per line, in minor units, is canonical internally; net is derived. Display what the API gives you.

### Products and variants

```jsonc
{
  "id": "1234", "slug": "…", "sku": "MP50", "name": "…",
  "options": [{ "name": "Storlek", "values": ["50 ml", "100 ml"] }],
  "variants": [{ "id": "…", "sku": "…", "selectedOptions": { "Storlek": "50 ml" },
                 "price": { … }, "stock": "12.00", "inStock": true,
                 "images": [], "image": null, "hasOwnImages": false }],
  "variantCount": 2,
  "images": [{ /* descriptor, role primary */ }, { /* descriptor, role gallery */ }],
  "image": { /* the display image: primary, else first gallery, else first swatch; null if none */ },
  "files": [{ /* descriptor, kind file — e.g. a PDF datasheet */ }],
  "categories": [{ "id": "100", "name": "Parfym", "pathNames": ["Skönhet", "Parfym"] }],
  "collections": [{ "id": "…", "slug": "dam", "name": "Dam", "path": "dam", "url": "…" }],
  "url": "…", "alternateUrls": { "de": "https://www.moory.de/p/…" },
  "redirectTo": null, "matchedVariantId": null,
  "seo": { "title": "…", "description": null }, "updatedAt": "…"
}
```

- **`variants` is `null` when `variantCount` > 100.** Then page `GET /v1/products/{slugOrId}/variants`, which
  returns `items`, `total`, `page`, `pageSize` and `options[].values[].available`. `available` means: a variant
  with this value exists, is sold on this market, and fits the selections on the other axes — exactly what a
  cascading dropdown needs. A value reached through the cascade is always available, but honour the flag for
  hand-built URLs and restored state. Narrow with `filter[<axis>]=value` and `filter[in-stock]=true`.
  **This endpoint's filter is not the listing's filter**: one value per axis, no comma-separation, and an
  axis the product does not have is a `400 INVALID_FILTER` — not a warning. Filter your selections against
  the product's own `options[].name` before sending. Otherwise a stale bookmarked URL — one carrying
  `filter[Färg]`, opened on a product that only has `Storlek` — breaks the page instead of being ignored.
- **`options[].values` is in no guaranteed order.** It is not sorted, and it is not safe to treat the first
  and last entries as the range. A size axis can arrive as `["38", "42", "36", "40"]`, whose first and last
  entries are neither the smallest nor the largest — so `values[0] – values[at(-1)]` renders a specification
  that looks authoritative and is wrong. Sort before deriving anything from the endpoints. And remember that
  many axes are not numeric at all: a colour or material axis has no range, so listing the values is the only
  honest rendering.
- A variant not sold on the requested market is still listed, with `price: null` — a hole in the option matrix
  reads as a broken page, not as a sold-out size.
- **`redirectTo` is set when the requested slug is historical**: issue a `301` to it. Slugs are generated per
  site and language with history kept, so old links keep working through this field.
- `GET /v1/products/by-sku/{sku}` matches the product's own SKU or a variant's; on a variant hit,
  `matchedVariantId` says which.
- `stock` is a decimal string like every other number here.

### Images

The API never sends an image URL. It sends **media descriptors**, and `GET /v1/site` sends the `media` block
that turns a descriptor into URLs:

```jsonc
// GET /v1/site → media
{ "baseUrl": "https://media.storefront.asstio.com",
  "templates": { "image":  "/r/{hash}/{recipe}/{width}.{format}",
                 "video":  "/v/{hash}/{recipe}/{variant}.{format}",
                 "poster": "/p/{hash}/{recipe}/{width}.avif",
                 "file":   "/f/{hash}/{recipe}/{filename}" } }

// a descriptor — always all 22 keys, null where not applicable, arrays never null
{ "id": "sha256:<64 hex>", "recipe": 1, "kind": "image", "role": "primary", "view": "front", "order": 0,
  "w": 1840, "h": 1226, "blurhash": "LKO2?U%2Tw=w]~RBVZRi};RPxuwH", "color": "#3a4f6b", "bg": "white",
  "alt": { "sv": "Moory Parfym, framsida", "en": "Moory Parfym, front" }, "caption": null,
  "widths": [320, 640, 960, 1280, 1920], "formats": ["avif", "jpg"],
  "poster": null, "duration": null, "variants": null,
  "filename": null, "bytes": null, "pages": null, "attributes": null }
```

Where descriptors appear: listing card `image` (or `null`); product `images[]` (kind `image`/`video`, in
display order), `image` (the display image: `primary`, else first `gallery`, else first `swatch`, else `null`)
and `files[]` (kind `file`); variant `images[]`, `image`, `hasOwnImages`; cart line `image`; facet value
`swatch.image`.

**Rules that keep pictures from breaking:**

- **Use the SDK's helpers; do not template URLs by hand.** `pictureSources(site.media, descriptor, sizes)`
  returns `<source>` entries (one per format except the last, AVIF first) and the `<img>` (`src`, `srcSet`,
  `sizes`, `width`, `height`) — spread them onto a `<picture>`. `mediaUrl`, `srcSet`, `posterUrl`, `fileUrl`
  and `videoSources` cover the rest. They only ever produce a width and format the descriptor announces; a
  hand-built URL outside `widths × formats` is a 404 on the CDN.
- **`sizes` is mandatory.** Without it the browser downloads the largest candidate on every card.
- **For framed images use `formatImage(site.media, descriptor, "card", slots)`:** it applies the site's
  published image formats (aspect, fit, padding, background, the image's focal point and "show whole") and
  returns the aspect-frame and image styles plus the `<picture>` data. See the SDK README, "Image formats".
- **Branch on `kind`, never on empty arrays.** A `file` has `widths: []` and `formats: []` by contract; address
  it with `fileUrl` (the download) and `posterUrl` (its thumbnail from `poster.widths`). A `video` has poster
  renditions in `widths`/`formats` — render those as an image until video playback exists.
- **Variants inherit.** Render `variant.hasOwnImages ? variant.images : product.images` — an empty array is not
  nullish, so `??` picks the wrong branch. `variant.image` is `null` when the variant inherits; cart lines
  already carry the resolved image.
- **Placeholder before load.** Paint `color` (dominant colour) or decode `blurhash` into a small canvas behind
  the image; `bg` says whether the packshot was shot on `white` (give the frame a white background) or
  `transparent`/`light`. Keep the frame aspect from the design (packshots are 3:2 in the Asstio ecosystem)
  and `object-fit: contain`. `w`/`h` can be `null` on a file without a poster.
- **Alt text per language.** `alt[lang] ?? alt.sv ?? product.name`. `alt` can be `{}`. All languages travel;
  `Accept-Language` does not filter it.
- **No image proxy.** The CDN serves final renditions with long-lived caching; do not route them through
  `next/image` or another optimizer, and do not upload or resize anything yourself.
- `attributes` is an opaque object Asstio passes through unchanged (in the generated types:
  `null | { [key: string]: unknown }`); `recipe` varies per descriptor and must be read from the one you render.
- Integers in the generated TypeScript types are `number | string` (an artefact of the OpenAPI export). The
  SDK helpers normalise; if you read `w`/`h`/`widths` directly, do the same.
- A product with no media has `images: []`, `image: null`, `files: []` — render your own fallback (initial,
  brand mark), never a broken `<img>`.

### Content, attributes, related products and bundles

```jsonc
{
  "shortDescription": "Klassisk chelseaboot i läder.",          // plain text, ≤ 500 chars, or null
  "description": { "html": "<p>Handgjord i <strong>Portugal</strong>.</p>", "text": "Handgjord i Portugal." },
  "seo": { "title": "Chelsea boot – köp online", "description": "Klassisk chelseaboot i läder." },
  "brand": { "label": "Hugo Boss", "facet": { "key": "brand", "valueKey": "hugo-boss" }, "id": "7", "slug": "hugo-boss", "url": "https://www.example.se/varumarken/hugo-boss", "logo": { /* MediaDescriptorDto */ } },
  "responsible": { "manufacturer": { "name": "Hugo Boss", "legalName": "Hugo Boss AG", "street": "Dieselstr. 12", "postalCode": "72555", "city": "Metzingen", "region": null, "countryCode": "DE", "email": "info@hugoboss.com", "url": null, "phone": null }, "importer": null, "euResponsiblePerson": null },
  "attributes": [
    { "key": "material", "label": "Material", "type": "text", "value": "Läder", "facetKey": "material", "valueKeys": ["lader"] },
    { "key": "care", "label": "Skötsel", "type": "list", "values": ["Handtvätt", "Impregnera"] },
    { "key": "volume-ml", "label": "Volym (ml)", "type": "number", "value": "50" }
  ],
  "related": [{ "key": "tillbehor", "label": "Tillbehör", "products": [ /* ProductCardDto */ ] }],
  "bundle": { "components": [{ "product": { /* ProductCardDto */ }, "quantity": "2" }] }
}
```

- **`description.html` is sanitized on the server** — a fixed allow-list (`p, br, ul, ol, li, strong, em, b, i,
  h2–h4, a[href], table…`), no styles, no scripts, no images. Render it as-is (`dangerouslySetInnerHTML`,
  `v-html`, …). Style it with your own CSS; the markup carries no classes. `description.text` is the same
  content without tags, for meta descriptions and previews.
- **`attributes[]` is the shop's own specification list**, in the merchant's order. Render it generically:
  `type` decides the widget (`text`, `html`, `number`, `bool`, `list`). A single-valued attribute fills `value`
  (and `html` too when `type` is `html`); a list fills `values` instead. When `facetKey` is set the value is
  also a filter: link `value`/`values[i]` to `filter[<facetKey>]=<valueKeys[i]>`. **`valueKeys` is absent
  entirely — not present with a null entry — when any one value has no key of its own**: a list is either
  fully linkable or not linkable at all, so check for the array before indexing into it.
- **`brand`** comes from Asstio's brand entity. `brand.facet` links `filter[<key>]=<valueKey>`; `brand.url` is the brand
  page on this market, or `null` when there is none here — then render the label and logo without a link. Every
  variant carries its own `brand` and `responsible`; show the selected variant's.
- **`responsible`** is the GPSR contact data for the manufacturer, the importer and the EU responsible person, each a
  party or `null` (nothing applies; never fall back from a variant to the product). It is contact data, not a
  statement that the offer is compliant.
- **Brand filters match families.** A product whose variants have different brands matches the brand filter if any
  variant has the brand, and an in-stock filter if any variant is in stock — not necessarily the same variant. The card
  shows the main product's brand; the product page shows the selected variant's.
- **`related[]`** and **`bundle.components[]`** carry full cards — price, stock and image for *this* market,
  resolved at request time. A product that is not sold on the market is simply not in the list, and a group
  left with no cards at all is omitted from `related` rather than sent empty. **At most 24 products come back
  per related group**, in the order Asstio linked them; a group with more is cut there, so do not build a
  "show all" that expects the rest. `related` is only on the detail response, never on cards.
- Cards (`items[]` in listings, and `related[].products`/`bundle.components[].product`) carry `brand` and
  `shortDescription` and nothing else of this — `description`, `attributes`, `related` and `bundle` are
  detail-only.
- Everything above is `null` or `[]` until the merchant has filled the "Webbutik" tab in Asstio (or mapped
  existing fields). Design the page so an empty description is not a hole.
- All of it is configured **per site in Asstio**, not per frontend: a `content` block in the site's config
  maps canonical fields and attributes to entity properties, with `storefront:*` types as the zero-config
  default (a "Webbutik" tab exists even when nobody has mapped anything). There is nothing to set up on the
  frontend side beyond rendering what the API returns.

### Suggest and search-in-facet

`GET /v1/search/suggest?q=chel` returns up to 5 products (cards, name prefix match across words), 5 categories
and 10 facet values whose label starts with `q` — one call for a typeahead box. `q` is 1–100 characters;
blank, missing or longer is `400 INVALID_QUERY`, not the truncation a listing's `q` gets.

`GET /v1/products/facets/brand?q=hu&filter[gender]=dam` returns the brand facet's values matching `hu`, with
counts in the given filter context, for facets with hundreds of values. Same `filter[...]` grammar as the
listing. The endpoint matches `q` **in this API**, over the facet's 1000 most common values by count — no
regular expression ever reaches the search index, so a facet with more than 1000 distinct values is searched
over its top 1000 only, and at most 50 values come back. Free text (`q` on a listing) and collection scope are
not applied here. Only `list`/`swatch` facets are searchable this way; a range or toggle key is
`400 FACET_NOT_SEARCHABLE`, an unknown key `404 FACET_NOT_FOUND`.

### Cart

Six endpoints, all under `/v1/cart`, all `Cache-Control: no-store` (a cart is personal, never shared or
cached). Every one of them — including `GET`— returns the **whole cart, freshly recomputed**: prices and
stock are re-resolved from the current catalogue on every call, never trusted from what was stored.

| Endpoint | Body | Returns |
| --- | --- | --- |
| `POST /v1/cart` | — | A new, empty cart. Read `X-Cart-Token` off the **response header** and keep it. |
| `GET /v1/cart` | — | The current cart. |
| `POST /v1/cart/lines` | `{ sku, quantity }` | Cart with the line added (or topped up if that SKU is already in it). |
| `PATCH /v1/cart/lines/{id}` | `{ quantity }` | Cart with that line's quantity changed. |
| `DELETE /v1/cart/lines/{id}` | — | Cart with that line removed. |
| `POST /v1/cart/market` | `{ market }` | Cart re-priced in the new market's currency. |

```jsonc
{
  "token": "…", "market": "se", "currency": "SEK", "status": "active", "version": 3,
  "lines": [{ "id": "…", "sku": "MP50-42", "quantity": 2,
              "price": { "amount": "499.00", "currency": "SEK", "vatRate": "25.00", "vatIncluded": true },
              "image": { /* the variant's own image, else the product's, else null — same as the product page */ },
              "product": { "id": "1234", "name": "Mono Perfume", "slug": "mono-perfume" },
              "variant": { "id": "5678", "selectedOptions": { "size": "42" } } }],
  "subtotalInc": "998.00", "grandTotalInc": "998.00",
  "warnings": []
}
```

A line stores nothing but its `sku`: `product`, `variant`, `image`, `price` and stock are what the catalogue says
*right now*, so a row renders with no further calls — `product.name` labels it, `variant.selectedOptions`
(axis → value, empty for a simple product) says which one, `product.slug` links it. `product.id` is what
`GET /v1/products/{id}` resolves; `variant.id` is **not** a product id, it only appears inside
`GET /v1/products/{productId}/variants` (and a simple product is its own single variant, so the two ids are then
equal).

**`X-Cart-Token`** — every request after `POST /v1/cart` needs it, as a header, never in the URL. It is a
256-bit value; store it in an HTTP-only cookie or `localStorage` on your frontend, the API does not remember
it for you. Missing it on any of the other five routes is `400`.

**Never send a price, discount or VAT field** on a cart request either — same rule as everywhere else.
Quantity is the only number you ever send; it must be a positive integer, `INVALID_QUANTITY` otherwise.

**`If-Match` (optional, recommended for multi-tab)** — send the cart's `version` (also served as the
response `ETag`) as `If-Match` on a mutation to detect a lost race with another tab or request. A mismatch
is `409 CART_CONFLICT` with the *current* cart embedded under a `cart` key in the problem body — reload the
UI from that, don't retry blindly. Omit `If-Match` and the mutation is last-write-wins.

**`Idempotency-Key` (optional, only on `POST /v1/cart/lines`)** — a client-generated key (e.g. a UUID) makes
a retried "add to cart" call a true no-op instead of adding the line twice, as long as you send the exact
same key for the exact same retry. Generate a fresh key per user action, reuse it only for retries of that
one action.

**Carts expire** on a 30-day sliding window (any successful mutation resets it). An expired or checked-out
cart answers `410 CART_EXPIRED` on every route except `POST /v1/cart` — create a new one and start over; do
not treat it as a bug.

**Line-level warnings** ride in the same `warnings[]` array as listing warnings, with `lineId` set:

| Warning `code` | Meaning |
| --- | --- |
| `PRODUCT_GONE` | That SKU is no longer sold here (or now out of stock) and was silently dropped from the cart. |

```ts
const cart = await storefront.POST("/v1/cart", {});
const token = cart.response.headers.get("X-Cart-Token")!;   // save this — cookie or localStorage

const added = await storefront.POST("/v1/cart/lines", {
  headers: { "X-Cart-Token": token, "Idempotency-Key": crypto.randomUUID() },
  body: { sku: "MP50", quantity: 1 },
});
if (added.error) throw new Error(`${added.error.code}: ${added.error.detail ?? ""}`);
```

### Errors

Every failure is `application/problem+json` (RFC 9457) with a stable machine-readable `code`. **Branch on
`code`**, never on `title` or status alone.

```jsonc
{
  "type": "https://docs.asstio.com/storefront/errors#PRODUCT_NOT_FOUND",
  "title": "PRODUCT_NOT_FOUND",
  "status": 404,
  "detail": "No product 'x' on this market.",
  "code": "PRODUCT_NOT_FOUND"
}
```

| Code | Status | What to do |
| --- | --- | --- |
| `SITE_KEY_MISSING` | 401 | Send `X-Site-Key`. |
| `SITE_KEY_INVALID` | 401 | Unknown or revoked key — or the config carrying it was never published (see 1.5). |
| `SITE_INACTIVE` | 404 | The shop or all its markets are inactive. |
| `SECRET_KEY_FROM_BROWSER` | 403 | A `sfk_sec_…` key was used from a page. Use a publishable key there. |
| `MARKET_UNKNOWN` | 400 | `X-Market` is not a market of this shop; the detail lists the valid codes. |
| `INVALID_FILTER` | 400 | The `filter` syntax itself is malformed (an unknown *facet* is a warning, not this). |
| `INVALID_PAGE` | 400 | `page`/`pageSize` out of range, or `page × pageSize` over 10 000. |
| `INVALID_SINCE`, `INVALID_TYPE` | 400 | Bad `/v1/sitemap` parameters. |
| `INVALID_QUERY` | 400 | `q` on `/v1/search/suggest` or `/v1/products/facets/{key}` is missing, blank or over 100 characters. |
| `FACET_NOT_FOUND` | 404 | `/v1/products/facets/{key}` named a key that is not a facet of this site. |
| `FACET_NOT_SEARCHABLE` | 400 | `/v1/products/facets/{key}` named a real facet that is not a `list`/`swatch` facet (e.g. a range or toggle). |
| `PRODUCT_NOT_FOUND`, `COLLECTION_NOT_FOUND`, `NOT_FOUND` | 404 | Render a 404. |
| `RATE_LIMITED` | 429 | Back off for `retryAfterSeconds`. |
| `SITE_REGISTRY_WARMING` | 503 | The API is warming up. Retry shortly — not your bug. |
| `INDEX_UNAVAILABLE` | 503 | The index is being rebuilt (or the first build has not finished). Retry. |
| `NOT_FOUND` (missing `X-Cart-Token`) | 400 | Send `X-Cart-Token` on every cart route except `POST /v1/cart`. |
| `INVALID_QUANTITY` | 400 | Quantity must be a positive integer. |
| `CART_CONFLICT` | 409 | Stale `If-Match`; the current cart is embedded under `cart` in the problem body — reload from it. |
| `STOCK_INSUFFICIENT` | 409 | Requested quantity exceeds available stock; current cart embedded under `cart`. |
| `CART_FULL` | 400 | 50 distinct lines is the cap; topping up an existing line is never blocked by it. |
| `CART_EXPIRED` | 410 | The cart's 30-day sliding window lapsed, or it was checked out. Create a new one. |

---

## When it looks broken

Most "the API is broken" moments are one of these.

| Symptom | Cause | What to do |
| --- | --- | --- |
| A brand-new key answers `401 SITE_KEY_INVALID` | The config carrying it was never published | `publish_storefront_config(siteId)`, then allow about a minute for the registry to pick it up. A key is inert until then. |
| Everything 401s for a minute after publishing | Registry cache | Retry. It resolves itself. |
| `503 INDEX_UNAVAILABLE` / `SITE_REGISTRY_WARMING` | Index rebuilding, or first build unfinished | Retry, and render a "rebuilding" state rather than an error page. Both are expected during an index swap. |
| `get_storefront_sync_status` shows `documentCount: 0` | On a **delta** run this means *nothing changed*, not *the index is empty* | Read `mode` before reading `documentCount`. Only a `full` run's count is the index size. |
| `outcome: gated` | A safety gate refused to swap the alias — usually the document count dropped far enough to look like data loss | **It does not retry.** A gated run needs a human: confirm the drop is real (a genuine catalogue shrink after a data fix), then re-run. The new indices are kept unaliased for inspection. |
| `outcome: failed` | A real error; `warnings` carries it | Retried automatically with backoff. |
| The listing is full of SKU-level rows | Tenant variants are not linked to parents | An Asstio-side data fix, not a frontend one. See [Step 0](#step-0--look-at-the-catalogue-before-you-design-anything). |
| A product 404s that you can see in Asstio | No price on the requested market, or it is a variant of another product | Variants are not addressable on their own — only their parent is. |
| A filter you sent did nothing | Unknown facets are a `warning`, not an error | Read `warnings[]` on the 200 and show it. The page never blanks for a bad filter. |

---

## Where to go deeper

- **The full design and decision log:** `docs/superpowers/specs/2026-09-10-storefront-design.md` in the
  `asstio-storefront` repository — why things are the way they are, including everything not built yet.
- **The contract itself:** `GET /openapi/v1.json`, browsable at `GET /docs`.
- **Asstio's own data API** (orders, customers, stock, and everything Purchase owns): `PUBLIC_API.md` in
  `asstio-order-backend`.

Ask the Asstio team before designing around anything this document lists as not built.
