@asstio-dev/storefront
v0.11.0
Published
Typed client for the Asstio Storefront API (generated from OpenAPI).
Readme
@asstio-dev/storefront
Typed client for the Asstio Storefront API.
The types are generated from the OpenAPI document committed at
tests/Storefront.Api.Tests/Snapshots/openapi-v1.json, which is the contract: it only changes in a
deliberate commit, so a change here always shows up in a diff.
npm install @asstio-dev/storefrontSetting a shop up in the first place — the Asstio-side MCP calls, the API key kinds, CORS, the filter and
price model — is one document: docs/PUBLIC_STOREFRONT_ONBOARDING.md,
served publicly at https://api.storefront.asstio.com/docs/onboarding. This file is only about using the
package.
import { createStorefrontClient, filterQuery } from "@asstio-dev/storefront";
// Your key: process.env.STOREFRONT_SITE_KEY on a server, import.meta.env.VITE_… in a bundler.
declare const STOREFRONT_SITE_KEY: string;
const storefront = createStorefrontClient({
baseUrl: "https://api.storefront.asstio.com",
siteKey: STOREFRONT_SITE_KEY, // publishable in the browser, secret only on the 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", size: undefined }),
},
},
});
if (error) throw new Error(`${error.code}: ${error.detail ?? ""}`);
console.log(data.total, data.items, data.warnings);// A product with many variants comes back with variants: null and a variantCount; page them:
const { data: page } = await storefront.GET("/v1/products/{slugOrId}/variants", {
params: { path: { slugOrId: "manadslins" }, query: { filter: { power: "-1.00" } } },
});
// page.options[].values[].available drives cascading dropdowns: a value reached through the cascade is
// available, but honour the flag for hand-built URLs or restored state, which can select an unavailable one.Every example in this file is compiled as part of npm run check — the source of them is
examples/readme.ts, which also covers product lookup, ETag revalidation, crawling, and image formats.
Images
Products, cards, variants and cart lines carry media descriptors, never URLs. Combine a descriptor with the
media block of /v1/site to build src, srcset and <picture> sources. The helpers only ever produce a URL
the CDN actually serves: a width and a format the descriptor announces, and the descriptor's own recipe.
import { pictureSources, fileUrl } from "@asstio-dev/storefront";
const { data: site } = await storefront.GET("/v1/site");
const { data: product } = await storefront.GET("/v1/products/{slugOrId}", { params: { path: { slugOrId: "acuvue-oasys" } } });
if (site && product?.image) {
const { sources, img } = pictureSources(site.media, product.image, "(min-width: 900px) 33vw, 100vw");
// <picture>{sources.map(s => <source {...s} />)}<img {...img} alt={product.image.alt["sv"] ?? product.name} /></picture>
}
for (const file of product?.files ?? []) console.log(file.filename, fileUrl(site!.media, file));
// A variant with hasOwnImages: false inherits: variant.hasOwnImages ? variant.images : product.imagesvideoSources returns [] for anything that is not a video, while mediaUrl/pictureSources/fileUrl/posterUrl
throw MediaUrlError when the descriptor cannot produce that URL — branch on kind before calling.
Image formats
A site's formats (site.media.formats: card, gallery, thumb, cart, swatch, default, plus custom
keys) say how an image is framed: aspect, fit (contain · cover · natural), padding and a background per
bg class. They are published with the site, so the look changes without a frontend deploy. formatImage turns
a descriptor and a format key into everything a <picture> needs:
import { formatImage } from "@asstio-dev/storefront";
const f = formatImage(site.media, product.image, "card", [
{ media: "(min-width: 1080px)", size: "25vw" }, // your slot's width per breakpoint
{ size: "100vw" }, // the last one without a media condition
]);
// React:
// <div className={styles.cardImage}> ← your slot: your class, may be a flex/grid item
// <div style={f.box}> ← the aspect frame, the slot's only child
// <canvas ref={blurhashCanvas} style={f.placeholder} /> ← optional placeholder, same rectangle as the image
// <picture>
// {f.sources.map((s) => <source key={s.type} {...s} />)}
// <img {...f.fallback} style={f.img} alt={alt} loading="lazy" />
// </picture>
// </div>
// </div>Plain HTML (templating):
const f = formatImage(site.media, product.image, "card", [
{ media: "(min-width: 1080px)", size: "25vw" },
{ media: "(min-width: 640px)", size: "50vw" },
{ size: "100vw" },
]);
// alt is merchant text, not SDK output: escape it for the attribute.
const escapeAttr = (s: string) => s.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">");
const alt = escapeAttr(product.image.alt["sv"] ?? product.name);
// Your slot owns the width; box is its only child in every fit:
const picture =
`<picture>${f.sources.map((s) => `<source type="${s.type}" srcset="${s.srcSet}" sizes="${s.sizes}">`).join("")}` +
`<img src="${f.fallback.src}" srcset="${f.fallback.srcSet}" sizes="${f.fallback.sizes}" width="${f.fallback.width}" height="${f.fallback.height}" alt="${alt}" loading="lazy" style="${styleToCss(f.img)}"></picture>`;
const placeholder = `<canvas width="32" height="32" style="${styleToCss(f.placeholder)}"></canvas>`;
const html = `<div class="card-image"><div style="${styleToCss(f.box)}">${placeholder}${picture}</div></div>`;- Element contract. Your slot element owns the width (grid, flex,
className); it may be stretched. Put theboxinside it as its only child and give the box no padding, border or height of its own — its inline styles pin its geometry, so a stretched slot never distorts the format's aspect. - One rectangle.
imgandplaceholderare the same absolutely positioned rectangle withobject-fit: fill, computed from the descriptor: a blurhash canvas of any decode size is stretched to exactly where the image will paint. The box'sbackgroundColorshows around acontainimage and through transparent AVIF/WebP. - Natural has a box too, at the image's own aspect and without a colour;
imgandplaceholderfill it. So the layout is reserved before load in every fit, and the placeholder sits under the image the same way. sizesis your slots scaled by the image's drawn fraction withcalc();srcsetkeeps the real widths, so the browser downloads a sharp enough rendition for a cover crop and not a wasted one for a padded packshot.formats: null(a site without formats) or an unknown key falls back todefault, then tonatural; your own CSS keeps working.focal(set per placement in Asstio) positions acovercrop;showWholerenders a cover format as contain for that image.formatImagethrowsMediaUrlErrorfor video descriptors, file descriptors, a descriptor withoutw/h, a descriptor without renditions, or an emptyslotsarray.- Plain HTML: see the templating example above, or the compiled function
productCardinexamples/readme.ts.styleToCss(f.box)gives thestyleattribute value for any element. Escape anything that is not SDK output (alt, product names) before it goes into markup; the React example needs no escaping because React escapes attribute values.
Product content
description is already sanitized on the server: render description.html as-is — do not sanitize, escape or
re-parse it — and use description.text wherever you need plain text (a meta description, a snippet, an
aria-label). shortDescription is plain text. brand is the product's brand (the main product's); every variant in variants[] and on /variants carries its own
brand and responsible — render the selected variant's. brand.facet links a filtered listing
(filter[facet.key]=facet.valueKey) when the shop has a brand filter. brand.url is the brand page on this market and
is null whenever that page would not exist here (brand pages off, or nothing of the brand sold on this market) — then
render the label (and brand.logo) without a link; never build a brand URL yourself.
attributes[] arrives ordered and labelled for display. Branch on type (text | html | number | bool |
list): a list fills values, everything else fills value, and html fills html too — sanitized exactly like
description.html.
if (product.description) render(product.description.html); // as-is: sanitized server-side
for (const a of product.attributes) {
if (a.type === "html") render(a.html!); // sanitized like description.html
else if (a.values) render(a.values.join(", ")); // type: "list"
else render(a.value!); // text | number | bool
}facetKey and valueKeys turn an attribute's values into filter links — filter[facetKey]=valueKeys[i], aligned
index-for-index with values (or carrying the single value's key). valueKeys is absent entirely when any one
value has no key of its own: a list is either fully linkable or not linkable at all.
related[] holds groups of cards already resolved for this market, and bundle.components[] the cards a bundle is
made of; both leave out anything that no longer exists or is not sold here.
Brands and responsible parties
responsible is { manufacturer, importer, euResponsiblePerson }, each a party or null. Show the selected variant's
on the product page (EU GPSR manufacturer and responsible-person contact data). null means nothing applies for this
SKU: do not fall back to the product's or the brand's. phone is only present when the shop publishes it.
Brand pages: GET /v1/brands lists the brands with a page on this market (paged, by name); GET /v1/brands/{slugOrId}
returns the brand's name, description (sanitized like product descriptions), logo, website, its default parties and
facet — render the listing with GET /v1/products?filter[<facet.key>]=<facet.valueKey>. A redirectTo means 301 to
that slug. GET /v1/sitemap?type=brand lists them for sitemap.xml.
const { data: brand } = await storefront.GET("/v1/brands/{slugOrId}", { params: { path: { slugOrId: "hugo-boss" } } });
if (brand?.redirectTo) redirect(301, `/varumarken/${brand.redirectTo}`);
const { data: listing } = await storefront.GET("/v1/products", {
params: { query: { filter: filterQuery({ [brand!.facet!.key]: [brand!.facet!.valueKey] }) } },
});SEO
Pure helpers that compute schema.org JSON-LD and <head> data from the DTOs. They render nothing: your frontend
writes the markup. No backend call, no DOM, no dependency.
Product JSON-LD. productJsonLd returns a Product (one variant) or a ProductGroup with one Product and
Offer per variant sold on the market. variantUrl is required: Google needs a distinct URL per variant, and opening
that URL must preselect the variant on your page. The format is yours; ?variant= below is only an example.
variesBy maps your option names to size, color, material or pattern; unmapped options still name the
variants. A product with more than 100 variants arrives with variants: null: pass the first /variants page as
variants, or the product gets no offer (valid schema.org, but not eligible for product rich results).
const ld = productJsonLd(product, {
site, market: "se",
variantUrl: (v) => `${product.url}?variant=${v.id}`,
variesBy: { Storlek: "size", Färg: "color" },
});
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdScript(ld) }} />In a plain template: <script type="application/ld+json">${jsonLdScript(ld)}</script>. jsonLdScript escapes
<, >, &, U+2028 and U+2029, so merchant text cannot end the script element; never use plain JSON.stringify there.
Breadcrumbs. On a collection page, r.breadcrumb holds the ancestors only. Add the collection itself as current:
const crumbs = breadcrumbJsonLd(r.breadcrumb, { home: { name: "Hem", url: "https://moory.se/" }, current: r.collection });
// null below two items (Google's minimum): render nothing then.Head. productHead, collectionHead (takes your own description; the API has none for collections) and
brandHead return a framework-neutral SeoHead: title, description (cut at 160 characters on a word boundary),
canonical, hreflang alternates (keyed language-COUNTRY, x-default from the site's default market; override with
xDefault: "<market>" or false), and Open Graph / Twitter meta. titleTemplate: "%s | Moory" applies to <title>
only, never to the social titles. In Next.js:
export async function generateMetadata(): Promise<Metadata> {
return toNextMetadata(productHead(product, { site, market: "se", titleTemplate: "%s | Moory" }));
}Titles come out as { absolute } on purpose, so a layout's title.template does not apply a second time.
Errors. SeoError means a caller or configuration error: redirectTo is set (answer 301 instead), an unknown
market, a bad variesBy value, a variantUrl that is not an absolute http(s) URL or repeats for two variants, or
two markets with the same language-COUNTRY and different URLs. Catch it at your SEO boundary and fix the market
config. Holes in the data never throw: a missing image, description or price only leaves that part out.
hreflang sets are only as reciprocal as the API's alternateUrls; the SDK renders what the API gives.
Search-as-you-type and search-in-facet
/v1/search/suggest answers three short lists for what the shopper has typed so far: up to 5 product cards
(matched on the product name), 5 categories (each with the path to show above it) and 10 values from the facets
whose panel is searchable. q is required and 1–100 characters — blank, missing or longer is a 400
(INVALID_QUERY), not a truncation the way a listing's q is.
/v1/products/facets/{key} is the search box inside one facet panel: the facet's values with the counts they have
in the same filter[…] context the listing is showing, so a count is what selecting that value would leave. Send
the panel's own filters with it, and omit q for the values as they are.
const { data: suggestions } = await storefront.GET("/v1/search/suggest", { params: { query: { q: typed } } });
const { data: brands } = await storefront.GET("/v1/products/facets/{key}", {
params: { path: { key: "varumarke" }, query: { q: typed, filter: filterQuery({ kon: ["dam"] }) } },
});The endpoint reads the facet's 1000 most common values and matches q against those — label contains,
case-insensitively, or key prefix — returning at most 50. A facet with more distinct values than that is searched
over its top 1000; no regular expression ever reaches the search index. key has to be a list or swatch facet:
an unknown key is FACET_NOT_FOUND (404), and a range or toggle facet is FACET_NOT_SEARCHABLE (400).
Checkout
Two small modules, because the suspend → update → resume rule spans two runtimes. The server helpers need the
client and the cart token (an httpOnly cookie, so they run in Server Actions and route handlers);
mountCheckout needs neither key nor fetch — it mounts the provider's widget and drives it from the answers
your server hands it.
import { awaitCompletion, completeCheckout, getCheckoutStatus, getOrder, mountCheckout, startCheckout, syncCheckout } from "@asstio-dev/storefront";Server. startCheckout(client, cartToken, { option?, returnOrigin? }) creates the cart's checkout attempt
or converges on the live one; call it from a Server Action or route handler on the shopper's navigation to the
checkout page, never during a Server Component's render (a re-render would create a provider order per
mutation). syncCheckout is the same POST /v1/checkout, retrying CHECKOUT_IN_PROGRESS per Retry-After
twice by default — call it after every cart mutation on the checkout page, through the browser handle's
update; there, and for the handle's sync, pass { retries: 0 } so each call is one POST and the handle's own
burst does the retrying. getCheckoutStatus(client, attemptId, cartToken) is the non-mutating
GET /v1/checkout/{attemptId} behind the handle's status callback.
completeCheckout(client, attemptId, cartToken?) is the confirmation page's call; with the cart token
the answer carries order.accessToken, on every call, so a lost first answer or a reload still ends with the token.
awaitCompletion(() => completeCheckout(…)) polls with backoff until the order exists. getOrder(client, orderId,
token) sends the token as X-Order-Token — a header, never a query string; send Referrer-Policy: no-referrer
on the order page and keep its URL out of your logs.
// A Server Action or route handler, on the shopper's navigation to the checkout page.
const result = await startCheckout(storefront, cartToken, { returnOrigin: "https://shop.example" });
if (!result.ok) return { problem: result.problem, retryAfter: result.retryAfter };
return { checkout: result.data }; // "open" with a widget, or "completed"/"pending" with an order
// The Server Action behind mountCheckout's `sync`, and the POST inside every cart update: one HTTP POST per call.
export async function syncForBrowser() {
return syncCheckout(storefront, cartToken, undefined, { retries: 0 });
}
// The confirmation page: complete with the cart token, keep the order's access token, then poll for the number.
const first = await completeCheckout(storefront, attemptId, cartToken);
const done = await awaitCompletion(() => completeCheckout(storefront, attemptId, cartToken));
// The order page: the token is a header.
const order = await getOrder(storefront, orderId, token); // 404 ORDER_NOT_FOUND for an unknown id and a wrong token alikeBrowser. mountCheckout(container, checkout, { sync, status, onProblem, onOrder }) takes the open answer
your page was rendered with and returns a handle { attemptId, suspended, suspend, resume, remount, destroy,
sync(update) }. The container is a client component keyed by attemptId. handle.sync(update) is the whole
rule: it suspends the widget, runs your update once (mutate the cart, POST /checkout, return its answer),
then dispatches status first, identity on open: completed/pending → onOrder(order, attemptId) (you
navigate); locked → stays suspended and retries sync() per Retry-After within the API's budget (30 s, ten
calls — a 429 counts the same; a Retry-After past the 30 s is not waited out), then polls status(attemptId);
processing → polls status(); declined/expired → one sync(); open → resumes when the answer's
attemptId is the mounted one, else remounts from the answer's widget (changed only says this call created a
new attempt — never resume on it). A status() answer is never dispatched as payable: its open leads to one
non-mutating sync(). An update that throws leaves the widget suspended. The widget is suspended the moment
handle.sync is called, and stays suspended while another update is queued behind it; a newly queued update ends
the running sync's waits (a retry or a status poll), so it runs at once. CHECKOUT_IN_PROGRESS is retried like
locked, within the same burst. If the provider's script loads late, the handle keeps trying to bind its decline
signal every half second for as long as the widget is mounted. A 503 CHECKOUT_UNAVAILABLE or
CHECKOUT_PROVIDER_UNAVAILABLE is retried per its Retry-After by non-mutating syncs within the same budget, then
reported; any other 503 (SITE_KEYS_SYNCING, SITE_REGISTRY_WARMING) and every other non-2xx of the SDK's own calls
reaches onProblem(problem, { status }) at once, status being the HTTP status — a 409 CHECKOUT_CART_CHANGED
carries the corrected cart in problem.cart: show it and its warnings, then handle.sync(() => POST /checkout).
handle.sync never rejects. onProblem also receives the SDK's own codes: SDK_SYNC_FAILED or SDK_STATUS_FAILED
when one of its calls throws (a network failure), SDK_UPDATE_FAILED when your update throws, and
SDK_DISPATCH_FAILED for any other throw while dispatching (an onOrder that throws, an unknown widget controller) —
each with { status: 0 } and the widget left suspended; SDK_CHECKOUT_CLOSED when the one sync() after a
declined/expired answers closed again, and SDK_UNKNOWN_STATUS for an answer status the SDK does not know (both
{ status: 200 }); and SDK_EMPTY_ANSWER, which the server helpers return when an answer carried no body (with that
answer's HTTP status). The SDK also syncs by itself: once on the provider's
payment-declined signal where its JavaScript offers one, when the page becomes visible again while suspended, and
three minutes before expiry, timed from the DTO's expiresInSeconds (server-relative, so a browser clock ahead
of the server's still lands inside the server's five-minute window). An autonomous sync is skipped while another
sync is running or queued.
// A client component, keyed by attemptId so a re-render never resets it.
const handle = mountCheckout(container, checkout, {
sync: callSync, // a Server Action calling syncForBrowser()
status: callStatus, // a Server Action calling getCheckoutStatus(storefront, attemptId, cartToken)
onProblem: (problem) => { /* problem.cart when code === "CHECKOUT_CART_CHANGED"; otherwise the message for problem.code */ },
onOrder: (order, attemptId) => { location.assign(`/kassa/tack?attempt=${attemptId}&order=${order.id}`); },
});
// Every cart mutation on the page goes through the handle: suspend → your update once → status first, identity on open.
await handle.sync(() => callAddLine(sku)); // callAddLine mutates the cart, then syncs, and returns that answerAfter completion. Every cart endpoint answers 410 CART_EXPIRED for the converted cart, while the cart token
is still what re-delivers the order's access token — so a 410 from a cart mutation on the checkout or
confirmation path does not end the cart cookie: call GET /v1/checkout/{attemptId} (or POST /checkout, which
answers the converted cart's completed attempt) with the same token, navigate to the confirmation from its
order, and clear the cookie only once the confirmation page holds the access token.
Copy and CSP. The pending state ("awaiting payment approval") is your copy. Each provider's widget needs its
domains in your CSP: Qliro One *.qliro.com (pago.qit.nu in the sandbox), Klarna/Kustom *.klarna.com
(*.kustom.co), Walley *.walleypay.com, plus their frame-src/script-src/connect-src — the provider's
integration guide lists the exact hosts.
Problem codes you will meet here: CART_EMPTY, CHECKOUT_CART_CHANGED (with cart), CHECKOUT_PREVIEW_KEY,
CHECKOUT_MARKET_MISMATCH (switch the cart's market first, then sync), OPTION_NOT_AVAILABLE,
ORIGIN_NOT_ALLOWED, CHECKOUT_UNAVAILABLE and CHECKOUT_PROVIDER_UNAVAILABLE (503, Retry-After),
CHECKOUT_IN_PROGRESS (409, Retry-After), CHECKOUT_PROVIDER_REJECTED (502), CHECKOUT_NOT_FOUND,
ORDER_NOT_FOUND, SITE_KEYS_SYNCING (503 for a secret key on a freshly created site, Retry-After).
Qliro (qliro-q1)
- The widget is Qliro's HTML snippet;
mountCheckoutre-creates its scripts so they run. - After every cart change,
handle.sync(update)locks the widget (q1.lock()), and resumes it throughq1.onOrderUpdated, which makes the iframe re-read the order. The widget unlocks only when Qliro's order shows the confirmed total. If it does not within 10 seconds, or the change came before Qliro's script had loaded, the widget is re-rendered from the confirmed checkout instead; it is never unlocked before Qliro shows the confirmed total. - When our validation declines a purchase (the cart moved on, the attempt expired, a line is out of stock), Qliro shows
its out-of-stock message and fires
q1.onPaymentDeclined; the SDK then syncs by itself and remounts or resumes.
Headers
Every call the client makes carries these; you never set them by hand.
| Header | Required | Meaning |
| --- | --- | --- |
| X-Site-Key | yes | Which shop, and with which visibility. sfk_pub_… publishable (safe in a browser bundle), sfk_prv_… preview (draft content), sfk_sec_… secret — server-side only: a secret key sent from a page is rejected with SECRET_KEY_FROM_BROWSER. |
| X-Market | no | Market code, e.g. se. Omit for the site's default market. An unknown one is MARKET_UNKNOWN. |
| Accept-Language | no | BCP-47 tag. Omit to use the market's language. |
The client sets all three, so params.header is optional at every call site even though the contract marks
X-Site-Key required.
Errors
Every failure is application/problem+json with a stable machine-readable code; branch on code,
never on title or on the status alone.
{
"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" // the only stable field; `retryAfterSeconds` is added on RATE_LIMITED
}It is StorefrontProblem in the schema, so error.code and error.retryAfterSeconds are typed — no cast:
if (error?.code === "RATE_LIMITED") await sleep((error.retryAfterSeconds ?? 10) * 1000);Every operation documents 400, 401, 403, 404, 429 and 503; the entity 404s (PRODUCT_NOT_FOUND,
COLLECTION_NOT_FOUND) are called out on the operations that raise them.
Codes you will meet: SITE_KEY_MISSING, SITE_KEY_INVALID, SITE_INACTIVE, SECRET_KEY_FROM_BROWSER,
SITE_REGISTRY_WARMING, MARKET_UNKNOWN, RATE_LIMITED (with retryAfterSeconds, plus a Retry-After
header), PRODUCT_NOT_FOUND, COLLECTION_NOT_FOUND, INVALID_PAGE, INVALID_SINCE, INVALID_TYPE,
INDEX_UNAVAILABLE (retry — a preview index is being rebuilt), NOT_FOUND, INVALID_QUERY,
FACET_NOT_FOUND, FACET_NOT_SEARCHABLE,
and, from the checkout (above): CART_EMPTY, CHECKOUT_CART_CHANGED, CHECKOUT_PREVIEW_KEY,
CHECKOUT_MARKET_MISMATCH, OPTION_NOT_AVAILABLE, ORIGIN_NOT_ALLOWED, CHECKOUT_UNAVAILABLE,
CHECKOUT_PROVIDER_UNAVAILABLE, CHECKOUT_IN_PROGRESS, CHECKOUT_PROVIDER_REJECTED, CHECKOUT_NOT_FOUND,
ORDER_NOT_FOUND, SITE_KEYS_SYNCING, CART_EXPIRED.
SITE_REGISTRY_WARMING and INDEX_UNAVAILABLE are retries, not bugs in your call, as are the checkout's
CHECKOUT_IN_PROGRESS, CHECKOUT_UNAVAILABLE, CHECKOUT_PROVIDER_UNAVAILABLE and SITE_KEYS_SYNCING (each with Retry-After).
Warnings
A request that was almost right still answers 200 and says so in warnings[], so a listing page never
goes blank because of a bad query string:
| code | What happened |
| --- | --- |
| SORT_UNKNOWN | The sort value is not one this site offers; the default sort was used. |
| COLLECTION_UNKNOWN | The named collection does not exist; it was not applied. |
| FILTER_IGNORED | A filter[…] key is not facetable here; that filter was dropped. |
| QUERY_TRUNCATED | q was longer than 200 characters; the results are for the shortened term. |
Query parameters
q, page, pageSize, sort and filter are all in the contract, so they are typed and autocompleted.
filter is a deepObject: { brand: "dior,chanel" } goes out as filter[brand]=dior,chanel, which is the
form the API reads. In a list, swatch or hierarchy filter , separates values, \, is a comma inside a value and
\\ a backslash — value keys are raw values, and an option such as 14,2 is the key 14,2. Never join or split a
filter parameter yourself: encodeFilterValues(values) builds one and decodeFilterValues(param) reads one (for
the selected state of a URL you parse), for every facet alike. filterQuery() uses encodeFilterValues for
multi-select arrays, passes a single string (a range 100-500, a toggle true) as given and drops undefined ones.
A range is min-max: either bound may be left out and either may be negative — -10--2, -10-2, -10- (at least
−10), --2 (at most −2) — and a leading dash with nothing before it keeps its meaning, -500 is "at most 500". A
range whose lower bound is above its upper bound is ignored with a warning.
/v1/sitemap takes type (product | collection), since (ISO-8601, inclusive), page and pageSize
(max 1000, default 500).
/v1/search/suggest takes q only, and /v1/products/facets/{key} takes q plus the same filter object as a
listing — both are described under Search-as-you-type and search-in-facet.
Caching
Read responses carry an ETag and Vary: X-Site-Key, X-Market, Accept-Language — the answer depends on
all three, so a shared cache must key on all three. Send the ETag back as If-None-Match and a matching
request answers 304 Not Modified with no body:
const first = await storefront.GET("/v1/site", {});
const etag = first.response.headers.get("ETag")!;
const again = await storefront.GET("/v1/site", { headers: { "If-None-Match": etag } });
// again.response.status === 304Errors are never stored (Cache-Control: no-store) and never carry an ETag.
Development
From sdk/typescript:
npm ci
npm run build # regenerates src/schema.d.ts from the snapshot, compiles, then typechecks the examples
npm run check # fails if the committed schema.d.ts is stale, modified or untracked, or an example brokesrc/schema.d.ts is generated and committed: it is reviewed in pull requests exactly like the
OpenAPI snapshot it comes from. After changing the public API, regenerate the snapshot from the repo
root, then build and check the SDK:
UPDATE_SNAPSHOTS=1 dotnet test --project tests/Storefront.Api.Tests -- --filter-query "/*/*/OpenApiSnapshotTests/*"
cd sdk/typescript
npm ci
npm run build
npm run checkReview and commit tests/Storefront.Api.Tests/Snapshots/openapi-v1.json and
sdk/typescript/src/schema.d.ts together. An API or worker implementation change that leaves the
OpenAPI snapshot unchanged does not require a generated SDK change.
Publishing
The SDK and backend share a release version. Do not edit the placeholder version in package.json
and do not run npm publish locally. Pushing a repository tag such as v1.2.3 runs
publish-npm.yml, which derives version 1.2.3 from the tag, installs and checks the package, runs a
publish dry run, and creates a pending staged package through npm trusted publishing. No long-lived
NPM_TOKEN is used.
The tag does not make the package public. A maintainer must inspect and approve it with npm 2FA, on the npm website under Staged Packages or with npm 11.19 or newer:
npm stage list @asstio-dev/storefront
npm stage view <stage-id>
npm stage approve <stage-id>After approval, verify the registry version and a clean consumer install. The full API, worker, and
npm release checklist is in docs/ops/deploy.md.
