@sixtyninecommerce/client
v0.38.0
Published
JavaScript/TypeScript client for sixtynine-commerce storefronts
Readme
@sixtyninecommerce/client
JavaScript/TypeScript client for sixtynine-commerce storefronts.
Install
npm install @sixtyninecommerce/clientQuick Start
import { createStorefrontClient } from '@sixtyninecommerce/client';
const client = await createStorefrontClient({
storeCode: 'my-store',
cdnUrl: 'https://cdn.my-store.com',
// Optional: retry transient GETs (default { attempts: 2, baseDelayMs: 200 })
retry: { attempts: 3, baseDelayMs: 200 },
// Optional: notified when a SIGNED-IN customer's session is gone (never for a guest)
onAuthExpired: () => router.push('/sign-in'),
});
// Products
const product = await client.commerce.getProduct('blue-shirt');
const results = await client.commerce.searchProducts({ term: 'shirt' });
// Collections (hierarchical taxonomies, auto-synced to facet values)
// Filter by a collection and get descendants automatically via the
// FacetValue bridge (`coll--{channelCode}--{id}`) on the server:
const byCollection = await client.commerce.searchProducts({
collectionSlug: 'wines',
});
// The page's whole <head>, as the platform resolves it (the path without its
// language prefix): title from the store's templates, robots (per crawler too),
// canonical, hreflang with x-default, Open Graph and X, one JSON-LD @graph,
// verification meta, the store's tag integrations — and status (a redirect,
// 404, 410 or 451). Render it with resolveMetadata & co. (`/next`) or
// renderHeadTags (`/web`); see "The resolved head" below.
const head = await client.seo.resolve({ path: '/product/chateau-x', languageCode: 'nl' });
// AI summary + llms.txt rendering for AI agents
const product = await client.commerce.getProduct('chateau-x');
const summary = client.seo.aiSummaryFor({ kind: 'product', metadata: product.metadata ?? null });
const llmsLine = client.seo.llmsTxtEntryFor({
kind: 'product',
product: { slug: product.slug, collections: product.collections },
metadata: product.metadata ?? null,
url: head.canonical ?? '',
name: product.name,
description: product.description,
});
// Cart
await client.commerce.addToCart(product.variants[0].id, 1);
const order = await client.commerce.getActiveOrder();
// Customer subscriptions, VAT validation, MyParcel shipping
const subs = await client.commerce.getMySubscriptions();
const vat = await client.commerce.getMyVatValidationStatus();
const dropOffs = await client.commerce.getDropOffPoints({ postalCode: '1011AB' });
const tracking = await client.commerce.trackShipment('3SXXXXXX', '1011AB');
// CMS content
const page = await client.content.resolvePath('/about-us');
const menu = await client.content.getMenu('main-menu');
const sitemap = await client.content.getSitemap('en');
// Resolve CMS reference fields (image[], content_item[], etc.)
const images = await client.content.lookupAssets(item.values.gallery);
const linked = await client.content.lookupContentItems(item.values.related_posts);
// Routes
const routes = await client.navigation.getRoutes();
// Cancel a slow read (typeahead, page navigation, SSR)
const controller = new AbortController();
const results = await client.commerce.searchProducts(
{ term: 'shoes' },
{ signal: controller.signal }
);
// AbortSignal is supported on read methods (catalog, content, navigation, sitemap)
// Mutations (cart, checkout, customer auth) intentionally don't accept signal.
// Pricing helpers: label an amount with the currency it came with
client.pricing.formatPrice(variant.price, variant.currencyCode); // "€29.99"
client.pricing.getTierForQuantity(variant.priceTiers, 10);
client.pricing.validateOrderMinimums(order, await client.commerce.getOrderConstraints());
// Media helpers
client.media.assetUrl('shop/source/photo.jpg');
client.media.getPresetUrl(asset, 'm', 'webp');
client.media.getResponsiveImageData(asset); // { src, srcSet, sizes, alt }
// The store's alt text in the page's language (its image descriptions, per language), before
// the image's own alt text and its file name:
const descriptions = await client.content.lookupVendureAssetDescriptions([asset.id]);
client.media.getResponsiveImageData(asset, undefined, {
languageCode: 'nl',
descriptions: descriptions[asset.id],
});
client.media.altTextOf(asset, { languageCode: 'nl', descriptions: descriptions[asset.id] });
// Language & auth
client.setLanguage('nl');
client.setAuthToken(token);Performance: one request for many entities
Every single-entity read is one request: commerce.getProduct(slug), getProductById(id),
getCollection(slug), getCollectionById(id), getProductProperties(id),
getProductReviews(id), getReviewRatingSummary(id), getProductRecommendations(id) and
content.getAsset(id). A product
detail read costs the platform about 35 SQL statements, and a signed-in shopper's reads bypass the
platform's catalogue cache. Never call one in a loop, a .map or a Promise.all over a list.
One storefront page that read 200 products that way, for every signed-in visitor, held most of the
platform's database connections, for every store on it. Read the one entity a page is about with a
single read, and a list of them with a list read:
import { productCacheTag } from '@sixtyninecommerce/client';
// Many products by id (a CMS `product` field, related products, a quick-order list): ONE request.
const { items } = await client.commerce.getProducts(
{ filter: { id: { in: ids } }, take: 100, detail: 'card' },
{ cacheTags: ids.map(productCacheTag) } // product.* events purge this read, as they do getProductById's
);
// The rows come back in the list's order, not yours, and a product that is gone (deleted,
// disabled, hidden from this shopper) is absent rather than a NOT_FOUND error.
const byId = new Map(items.map((product) => [product.id, product]));
const products = ids.flatMap((id) => byId.get(id) ?? []);
// Many products by slug.
const bySlug = await client.commerce.getProducts({
filter: { slug: { in: slugs } },
take: 100,
detail: 'card',
});
// Many collections by id (a CMS `product_collection` field): ONE call, each id's
// getCollectionById answer at its position, null for a collection that is gone.
const collections = await client.commerce.getCollectionsByIds(collectionIds);
// Many CMS assets or items.
const images = await client.content.lookupAssets(assetIds); // up to 50 ids per call
const linked = await client.content.lookupContentItems(itemIds); // split into calls of 50 for youtake: it defaults to 20 and the Shop API refuses more than 100, so always pass it, and send at most 100 ids or slugs per request (split a longer list into chunks of 100).- Card or full rows:
detail: 'card'is a grid tile; leave it out for the full list row (every variant with prices, stock, tiers and availability). Neither carries the galleryassets, variantoptions/optionGroups,documentsormetadata: read those withgetProductfor the one product a page shows. Full product details for many products:getProductsBySlugs/getProductsByIds(Many products' details below). - By slug: on the current engine a row read by the slug filter still carries every translation (measured 2026-10-30), so hreflang can be built from it.
- Cache tags: a list read carries only the catalogue-wide
catalogtag; pass the ids' tags (above) soproduct.*events purge it. Next.js keeps 128 tags per fetch and a multi-store router (createStorefrontClient({ resolveStore })) sends each tag twice, so there keep a tagged read to 50 ids. - Collections:
getCollectionsByIds(ids)/getCollectionsBySlugs(slugs)answer whatgetCollectionById/getCollectionanswer (20 variants with their stock each), in pages of 50, taggedcollection:<id>; Many collections below. A tile needs less: onegetCollections({ filter: { id: { in: ids } }, take: 100 }, { cacheTags: ids.map(collectionCacheTag) })reads the list rows (name, slug, image, breadcrumbs, children), in the list's order;listCollections()is the same page withtotalItems, to page through every collection 100 at a time.
Batching single product reads (batching: true, opt-in)
A client created with batching: true batches the getProduct(slug) and getProductById(id)
calls it makes in one turn (a Promise.all over a list, the product cards of one render). The
slugs go out in one productsBySlugs request per 50 (an engine that has the field: each position
is what getProduct answers), the ids as the details pages of getProductsByIds
(Many products' details below), and each call still resolves or throws on its own with what it
would have unbatched.
const client = await createStorefrontClient({ storeCode, cdnUrl, batching: true });- Off unless asked in this release, while it is measured on a live storefront.
- Cost: on a demo store, 200 concurrent calls (120 by slug, 80 by id) went from 200 requests and 4,460 database statements to 4 requests and 320, every answer identical.
- A call alone in its turn, or with calls for the same product only, sends the request it always
sent. Never batched: a call with
metafields, an id that is not a uuid, and a call whose signal has already fired. - Calls are batched only with calls made under the same language, currency, order scope, head,
session and
buyerIp, as they stood when each call was made, and each request goes out under them: asetLanguageor a sign-in before the batch is sent changes nothing for earlier calls. - A network error, timeout or rate limit fails every call that request answers. A page the engine refuses with GraphQL errors is asked again product by product, so only that product's calls see its failure.
- An engine without
productsBySlugsrefuses it once per client; from then on the slugs go to ids first (pages of 100) and a slug two products share is asked on its own. - A
productsBySlugsrequest carries its calls' own cache tags andcatalog, exactly what their unbatchedgetProduct(slug)carries; it cannot carryproduct:<id>, which no read by slug knows before it answers. - A call whose signal fires rejects at once (
REQUEST_ABORTED); its request is cut only once no call waits for it. - Differences from unbatched answers: an engine that leaves a variant's
optionsor thetranslationslists unordered may list them in another order in a page of products, and, on an engine withoutproductsBySlugsonly, a slug a visible product shares with a hidden one in another language is answered with the visible one (getProductsBySlugs' divergence there). - Next.js: batch on a client per request (
stores.forRequest(req), or one client per render). Next records a request, and its cache tags, on the render that sent it.
Batching rescues code that reads one product at a time; where a page already has the list, the list reads above stay the better call.
Reads in flight: a cap per session, and identical reads shared (on by default)
Two defaults bound what a page can send at once, whatever its code does:
createStorefrontClient({ storeCode, cdnUrl }); // both on (sharing: see below)
createStorefrontClient({ storeCode, cdnUrl, readAdmission: { limit: 4 } }); // another cap
createStorefrontClient({ storeCode, cdnUrl, readAdmission: false, dedupeReads: false }); // both offreadAdmission: 8 Shop API reads in flight per shopper session. The rest wait in line, first
come, first served.
- Per session, across the process. Reads sent with a session token are capped per engine, store and token, across every client in the process, so the per-request clients of a signed-in shopper's overlapping renders share the 8.
- A store router's clients (
forRequest/forStore) cap their reads without a session token too, per client: each serves one request. - Never capped: reads without a session token on a client built once for the whole process (that would throttle every visitor to 8), mutations, sign-ins, uploads, and CMS and plugins reads.
- Waiting does not count toward the 15-second request timeout. A read still waiting after 30
seconds fails with
REQUEST_QUEUE_TIMEOUTand was never sent; a read whose signal fires while it waits fails withREQUEST_ABORTED. - A batched request (
batching: true) counts once, however many calls it answers.
dedupeReads: identical Shop API reads in flight share one request. Layout, page and metadata
asking for the same thing at once cost one request. Two reads share only when their requests are
the same in everything the engine reads (document, variables, store, language, currency, order
scope, head, session token), and each caller gets its own copy of the answer, keeps its own signal
and gets its own error. Mutations, sign-ins and uploads are never shared, and a write stops its
session's reads in flight from being joined: a read made after addItemToOrder never gets the cart
from before it. Nothing is kept once the answer is read.
On by default only on the runtime's own fetch. A fetch you hand in (fetch: myFetch) does
not share unless you pass dedupeReads: true: reads are matched on what the client sends, so a
fetch that adds something per shopper itself (a session cookie forwarded from the incoming
request, say) would make two shoppers' reads look identical, and one would receive the other's cart
or account. Pass true only for a fetch that adds nothing per caller, or use a separate fetch
function per shopper. A global fetch patched to add such a credential needs dedupeReads: false.
Next.js: sharing is off by default on Next's patched fetch (it carries __nextPatched). Next
records a request and its cache tags on the render that sent it, so a render that joined another
render's request would record none of that request's tags, and revalidateTag would miss its
cached page. Pass dedupeReads: true for pages not cached by tag. The cap is on there too: it
changes when a read goes out, not which render records it.
Fan-out notices (onPerformanceNotice)
The client tells you when code reads many entities one at a time, and names the call that reads them all:
[@sixtyninecommerce/client] 184 commerce.getProduct calls for 180 products in one turn on one client, one request each. Read them with one call: commerce.getProductsBySlugs(slugs) for each product's full details, or commerce.getProducts({ filter: { slug: { in: slugs } }, take: 100, detail: 'card' }) for a grid; or create the client with batching: true. …- Fan-out:
commerce.getProduct,getProductById,getCollectionorgetCollectionByIdcalled for ten or more different products or collections on one client in one turn (aPromise.allover a list, the cards of one render). Calls for the same product do not count twice. Withbatching: truethe notice says how many requests the calls went out as. - A deep queue: 16 or more Shop API reads of one session waiting for a slot on one client
(
readAdmission, above). - Development (
NODE_ENVanything butproduction): printed withconsole.warn, at most once a minute for each kind and method. Production: nothing is printed. onPerformanceNoticegets the notice instead of the console: every notice in development (same once-a-minute limit), each kind and method once per process in production. Send it to your logs or telemetry:
createStorefrontClient({
storeCode,
cdnUrl,
onPerformanceNotice: (notice) => logger.warn(notice.message, notice), // FanOutNotice | ReadQueueNotice
});A hook that throws or rejects is logged, never thrown into the read. A store router hands it to every per-request client.
Several stores in one process
One storefront app can serve several stores. Pass resolveStore instead of storeCode, and you
get a store router back (not a promise). Each request asks it for a client of its own:
import { createStorefrontClient, storeFromHostMap } from '@sixtyninecommerce/client';
export const stores = createStorefrontClient({
cdnUrl: 'https://cdn.example.com',
// Your rule for which store a request belongs to. Return null for "none".
resolveStore: storeFromHostMap({
'fashion.example.com': 'fashion-store',
'trade.fashion.example.com': { storeCode: 'fashion-store', head: 'trade' },
'gear.example.com': { storeCode: 'gear-store', language: 'nl' },
}),
stores: ['fashion-store', 'gear-store'], // optional allow-list
privateToken: { 'fashion-store': process.env.FASHION_TOKEN! }, // per store, server only
});
// Per request (a route handler, a server component, middleware):
const client = await stores.forRequest(request, {
customerToken: cookies.get('token'), // this shopper's session
currencyCode: cookies.get('currency'), // this shopper's currency
buyerIp: clientAddress, // sent only to a store with a private token
onAuthToken: (token) => cookies.set('token', token),
});
await client.commerce.getProduct('blue-shirt');
// Or name the store yourself:
const gear = await stores.forStore('gear-store');- Nothing a shopper does crosses requests. The sign-in, language, currency, order scope, head
and buyer address belong to the client
forRequest/forStorereturned and to nothing else — two concurrent requests, for one store or two, never send each other's. That is also why the router's config refusescustomerToken,currencyCodeandbuyerIp: pass them per request. - What a store shares between its requests: the channel (read once, refreshed when the platform's answer changes), its CDN, its redirect map and its private token. One channel lookup serves every store.
- Private tokens are per store. A token is bound to one channel, and the platform ignores it on
any other store's requests, so
privateTokenis{ [storeCode]: token }or a function. - Which store is your rule (
resolveStore(request) → code | { storeCode, head?, language? } | null). Two ready-made rules for a fixed list:storeFromHostMap(theHostheader the server received — exact, then on any port — and the URL only when there is none, since self-hosted Next buildsrequest.urlon its own host; neverX-Forwarded-Host, which a visitor can send) andstoreFromPathPrefix({ '/nl': 'store-nl', '/be': 'store-be' }, whole segments, longest prefix first). Host keys may be written in Unicode or punycode (bücher.example=xn--bcher-kva.example). The URL you pass must be absolute — on a plainnode:httpserver, build it from theHostyou trust. - Refusals are
StorefrontErrors with status 404, so a page can render its not-found view fromerror.status:STORE_NOT_RESOLVED(your rule returned null),STORE_NOT_ALLOWED(outsidestores),CHANNEL_NOT_FOUND(an unknown code, the bootstrap root, or an organization's workspace — a hub is never a store). Setstoreswhenever your rule passes on anything taken from the request, so a visitor cannot make the process look up codes it does not serve. - Canonical links are always built on each store's main shop address (
storefrontUrl), whichever host or head the request came in on. - Cache tags carry the store: every tag a read is sent with also goes out as
store:<code>:<tag>(storeCacheTag), so one store's events purge that store's pages only (see Next.js revalidation). The/nextand/webadapters accept the router too:createSeoArtifactHandler(stores, 'sitemap')/createSeoArtifactResponder(stores, 'sitemap'),createProductFeedHandler(stores)/createProductFeedResponder(stores)andcreateRedirectMiddleware({ client: stores, onError })serve each request from its own store, and answer a request no store serves with 404 (the middleware falls through; itsonErroralso hears when a store's redirect map fails to load). The/nexthandlers tag their upstream fetch store-scoped too;/websets no cache tags. - Per router, not per request: a
conditionalReads: { maxEntries, maxBytes }cache, and the once-per-route deprecation warning, are kept for the router's whole life.
Heads (head)
A store can have several sites — a consumer shop and a trade portal, say — registered as heads.
head names the one a client speaks for, sent as x-head, and the platform composes every link it
emails for that request (sign-in, magic link, invite, password reset) on that head's address:
const client = await createStorefrontClient({ storeCode: 'fashion-store', cdnUrl, head: 'trade' });Any key the store registered (lowercase letters, digits, single hyphens); the platform honours a
web, trade or native head and ignores any other, falling back to the store's default head.
Unset, nothing is sent — exactly as before. Canonical links do not follow it.
Deprecated: the /admin surface
@sixtyninecommerce/client/admin (createCommerceAdminClient) is deprecated. Use
createAdminClient from
@sixtyninecommerce/admin instead: it
carries the platform's error hierarchy, cursor pagination and tenant context, and it is where every
other back-office capability lands. Every method here has its successor there:
| Here | There |
|---|---|
| forms.getBySlug / create / update / upsertBySlug | admin.content.forms.* of the same name (update still renames a slug) |
| forms.remove | admin.content.forms.delete |
| forms.addField / updateField / removeField | admin.content.forms.fields.create / update / delete |
| forms.listSubmissions | admin.content.forms.submissions.list |
| redirects.list / create / update | admin.seo.redirects.* of the same name |
| redirects.remove | admin.seo.redirects.delete |
| redirects.import / upsert | admin.seo.redirects.import (adds or updates each row by its source path) |
This surface still works and is unchanged. Since 0.37.0 the factory also says so at runtime: once
per process, or to createCommerceAdminClient({ onDeprecation }). It is removed in 1.0.0 if that
ships at least 60 days after 0.37.0, otherwise in 2.0.0 (see DEPRECATIONS.md, shipped in the package and on unpkg).
The 1.0 group names (auth, checkout, store, orders)
The storefront methods added in 2026-09 were first placed in client.commerce. Their home is now
the group the design gives them, under the design's names; the old names keep working, as the very
same methods, and each says once per process that it moved:
| Old name (deprecated) | New name |
|---|---|
| commerce.authenticate(strategy, input) | auth.authenticate(strategy, input) |
| commerce.setOrderCustomFields(fields) | checkout.setOrderCustomFields(fields) |
| commerce.unsetShippingAddress() / unsetBillingAddress() | checkout.unsetShippingAddress() / unsetBillingAddress() |
| commerce.setOrderCurrency(code) | checkout.setCurrency(code) |
| commerce.addPayment(input, options) | checkout.addPayment(input, options) |
| commerce.getActivePaymentMethods() / getActiveShippingMethods() | store.activePaymentMethods() / activeShippingMethods() |
| commerce.getCapabilities() | store.capabilities() |
| commerce.getNextOrderStates() | orders.nextStates() |
| commerce.transitionOrder(state) | orders.transition(state) |
Same arguments, same request, same result: renaming the call is the whole migration. The old names
go in 1.0.0 if that ships at least 60 days after 0.37.0, otherwise in 2.0.0
(see DEPRECATIONS.md, shipped in the package and on unpkg); onDeprecation routes or silences their notices like any
other. Every other commerce method stays where it is.
Deprecated: client.graphql()
client.graphql(query, variables) — a raw Shop API document — is deprecated. It keeps
working until 1.0, and the first call in a process prints one line saying so. Every common use has
a typed method now:
| Instead of a raw query for | Call |
|---|---|
| a collection or product by id | commerce.getCollectionById(id), commerce.getProductById(id) |
| storefront metafields | commerce.getProduct(slug, { metafields: true }) (also getProductById, getCollection, getCollectionById) |
| the session currency | createStorefrontClient({ currencyCode }), checkout.setCurrency(code) |
| what the store is ready to serve | store.capabilities() |
| one content string | content.getContentString(id) |
| logo, cover, favicon / a CMS asset | commerce.getBranding() / content.getAsset(id) |
| a named sign-in strategy, order custom fields, unsetting addresses, order states | auth.authenticate, checkout.setOrderCustomFields, checkout.unsetShippingAddress / unsetBillingAddress, orders.nextStates / orders.transition |
The Shop API fields no method sends yet are listed in the MCP capability catalog
(storefront://capability-catalog). Route the notice to your logger, or silence it, with
onDeprecation:
const client = await createStorefrontClient({
storeCode: 'fashion-store',
cdnUrl: 'https://cdn.fashion-store.com',
// kind 'method' (client.graphql, a regrouped commerce name) or 'endpoint' (a platform route sent
// a Deprecation header)
onDeprecation: (notice) => logger.warn({ deprecation: notice }),
});Unset, a deprecated method is printed once per process, a deprecated platform route once per
client. Set, the hook is called once per client for each (once per router with resolveStore).
Modules
| Module | Description |
|--------|-------------|
| commerce | Products, collections, property trees, search, cart, checkout steps, customer auth, store pickup (getStorePickupLocations, chooseStorePickup, clearStorePickup, getActiveOrderStorePickup; see docs/headless/storefront/store-pickup.md), the receipt account claim (claimReceipt; see below) |
| auth | Sign-in through a named strategy (authenticate) |
| checkout | The active order's custom fields, address unsets, currency (setCurrency) and payment (addPayment) |
| store | What the store offers whatever the order: payment and shipping methods, the capability manifest (capabilities) |
| orders | The active order's state machine (nextStates, transition) |
| content | CMS resolve, content items, assets, forms, content strings (all, or one by id), menus |
| navigation | Unified route manifest, route lookup |
| pricing | Price formatting, tier lookup, order validation and orderability (canAddToCart, isAwaitingStock, …; pure), resolved tax display mode |
| media | CDN URLs, preset URLs, responsive image data (pure functions) |
| currency | Exchange rates, currency conversion |
| feeds | Product-feed URLs (Google Shopping, Meta, Pinterest, TikTok) |
Sessions and onAuthExpired
The client sends one session token to the Shop API: a guest's (the anonymous session its cart lives
on) or a signed-in customer's. onAuthToken hands you every new one to store; setAuthToken (or
customerToken) puts a stored one back. A new token is handed over only while the token its call was
sent with is still the current one, so a slow call that answers after a sign-in (or a sign-out, or a
setAuthToken) cannot replace the newer token with an older session's anonymous one. A sign-in's own
token always applies.
onAuthExpired fires when a signed-in customer's session is gone: a call is refused and the
platform confirms the session no longer has a customer. It fires once per lost session. It
never fires for a guest: a guest is refused with FORBIDDEN for ordinary reasons (an order
placed in another browser or in an earlier session of this one, someone else's order code), and its
session and cart are still alive, so do not clear a guest's token on FORBIDDEN.
The client counts itself signed in only when it saw it: a successful login, authenticate,
loginWithMagicLink, loginWithMagicCode, verifyAccount or resetPassword, or a
getCurrentCustomer() that returned a customer. A token restored with setAuthToken is not known
to be a customer's, so a storefront that wants the callback after a reload reads the customer
first:
client.setAuthToken(storedToken);
const me = await client.commerce.getCurrentCustomer(); // a customer → onAuthExpired is armedlogout, setAuthToken and clearAuthToken reset it. A refusal on a session that is still
signed in (another customer's order) is an ordinary FORBIDDEN StorefrontError and does not
fire it.
Registration
register creates the account and signs nobody in. The store emails the address a
"finish creating your account" link; the account works once the shopper opens it and chooses a
password there, which proves the address is theirs. Opening it signs them in. So after register
show "check your email", never a signed-in page, and do not call login: until the link is used,
a login answers PASSWORD_RESET_REQUIRED and emails the link again.
await client.commerce.register({ emailAddress, firstName, lastName });
// → { success: true } whether or not the address already has an account (no enumeration).
showMessage('Check your email to finish creating your account.');- The link lands on the reset route you already serve:
/reset-password?token=…(a trade account:/b2b/login/reset?token=…), finished withresetPassword(token, password), which sets the password, verifies the account and starts the session. A magic-link sign-in on the address finishes it too. Nothing new to build. - A password sent to
registeris checked, then ignored. It still answersPASSWORD_VALIDATION_ERRORfor one that breaks the length rule, so a form that keeps the field behaves as before, but nothing stores it: drop the field and ask for the password on the reset page. - A lost email recovers on its own: registering again, "forgot password" and a sign-in attempt each send the link again (rate-limited like a reset).
NATIVE_AUTH_STRATEGY_ERRORfromregistermeans the store has no storefront address to link the email to; itsmessagesays so. Nothing was created.- The store sends its welcome email once the account is finished, not at registration.
Passwordless (Magic Link) Login
Customers can sign in without a password. requestMagicLink emails a one-tap
link and a 6-digit code; consuming either starts a session. It coexists
with password login (login) — both are available. For unknown emails the server
creates a verified account on first sign-in (when the channel enables signup).
// 1. Request — resolves whether or not the account exists (anti-enumeration).
// Show "check your inbox" regardless. Throws only on a rate limit or a real error.
await client.commerce.requestMagicLink(email);
// A B2B portal says where the shopper started, so the link opens the B2B page:
await client.commerce.requestMagicLink(email, { portal: 'B2B' }); // → /b2b/login/magic
// 'CONSUMER' → /sign-in/magic. Omitted: derived from the account (a B2B customer gets the B2B path).
// A sign-up form can send the shopper's name: the account the link creates takes it.
await client.commerce.requestMagicLink(email, { firstName: 'Ada', lastName: 'Lovelace' });
// 2a. Consume the LINK (token from the emailed URL's ?token= param)
const user = await client.commerce.loginWithMagicLink(token);
// 2b. …or consume the 6-digit CODE (cross-device: typed on the same tab that requested it)
const user = await client.commerce.loginWithMagicCode(email, code);
// Either path starts a session and fires onAuthToken — no extra token handling needed.portal picks the landing page only and grants nothing: B2B portal access is still checked when
the link or code is consumed.
firstName / lastName are used only when redeeming the link or code creates the account, or
when the store holds a record of the address with no sign-in credential (an imported customer, for
example): that record's blank names are then filled. An existing name is never changed, the name is
ignored for an address that can already sign in, and the email never shows it. Over 100 characters, a control character or a web
address is refused before anything is sent: a thrown StorefrontError with
code: 'CUSTOMER_NAME_INVALID' and error.details[].field naming the field. A request without a
name works against every engine; one with a name needs an engine from 2026-10-28 on.
Errors thrown (via MutationError.errorCode): MAGIC_LINK_INVALID_ERROR,
MAGIC_LINK_EXPIRED_ERROR, MAGIC_LINK_LOCKED_ERROR (too many wrong codes), and, from
requestMagicLink, MAGIC_LINK_RATE_LIMITED_ERROR with the wait on error.retryAfterSeconds.
Sign-in rate limits
Two limiters can refuse a sign-in call. Both put the wait in error.retryAfterSeconds, and neither
says whether the account exists, so the message is safe to show.
| Limiter | Calls | Thrown as |
|---|---|---|
| The store's magic-link limiter (per email, per address) | requestMagicLink | MutationError, errorCode: 'MAGIC_LINK_RATE_LIMITED_ERROR' |
| The platform's credential limiter (by default 5 a minute per email per store, plus a per-address budget) | login, loginWithMagicCode, requestMagicLink, register, requestPasswordReset, refreshVerification | StorefrontError, statusCode: 429, code: 'RATE_LIMITED', retryable: true |
The credential limiter answers with HTTP 429 before the mutation runs, so login throws rather
than resolving to a LoginResult. The SDK never retries a mutation, so wait retryAfterSeconds
before letting the shopper try again:
import { StorefrontError } from '@sixtyninecommerce/client';
try {
await client.commerce.login(email, password);
} catch (error) {
const rateLimited =
error instanceof StorefrontError &&
(error.statusCode === 429 || error.errorCode === 'MAGIC_LINK_RATE_LIMITED_ERROR');
if (!rateLimited) throw error;
showError(`Too many attempts. Try again in ${error.retryAfterSeconds ?? 60} seconds.`);
}Server-side storefronts. When your server calls the Shop API, every shopper shares your server's address, so they all spend one per-address budget. Create the client with the head's private storefront token and the shopper's address, and each shopper is metered on their own:
// Per request, on the server only (the client refuses a private token in a browser).
const client = await createStorefrontClient({
storeCode: 'my-store',
cdnUrl: 'https://cdn.my-store.com',
privateToken: process.env.SIXTYNINE_PRIVATE_TOKEN, // s69p_…, issued with admin.heads.privateTokens.issue
buyerIp: shopperIp, // the address YOUR server saw the shopper connect from
});Setting a first password
A customer who signs in by link or code has no password, so updatePassword(current, next) cannot
serve them. getCurrentCustomer() returns hasPassword; when it is false, offer "Set password"
and call setPassword:
const me = await client.commerce.getCurrentCustomer();
if (me && !me.hasPassword) {
await client.commerce.setPassword(newPassword); // the account is emailed a notice
} else {
await client.commerce.updatePassword(currentPassword, newPassword);
}setPassword works only while the account has no password: afterwards it throws MutationError
PASSWORD_ALREADY_SET_ERROR. It also throws PASSWORD_VALIDATION_ERROR (the reason is in
error.fields.validationErrorMessage), NATIVE_AUTH_STRATEGY_ERROR (password sign-in is not
available for this account on this store) and SET_PASSWORD_REQUIRES_EMAIL_SIGN_IN_ERROR (the
session did not sign in to this store by email link or code, for example it signed in on another
store: have the customer sign in with a link or code again, or send them through
requestPasswordReset; error.message says so in words a shopper can read). The platform's
credential limiter meters it like updatePassword.
Reference UI (storefront)
Two pages. Request page — collect the email, then offer the code field:
function MagicLinkRequest() {
const [email, setEmail] = useState('');
const [status, setStatus] = useState<'idle' | 'sent'>('idle');
const [code, setCode] = useState('');
async function request(e: React.FormEvent) {
e.preventDefault();
try {
// never reveals existence; a B2B portal passes { portal: 'B2B' }
await client.commerce.requestMagicLink(email.trim());
} catch (error) {
const rateLimited =
error instanceof StorefrontError &&
(error.statusCode === 429 || error.errorCode === 'MAGIC_LINK_RATE_LIMITED_ERROR');
if (!rateLimited) throw error;
/* show "too many requests — try again in {error.retryAfterSeconds}s" */
return;
}
setStatus('sent'); // always advance — even if no account exists
}
async function submitCode(e: React.FormEvent) {
e.preventDefault();
try {
await client.commerce.loginWithMagicCode(email.trim(), code.trim());
window.location.assign('/account');
} catch {
/* show "invalid or expired code — request a new link" */
}
}
if (status === 'sent') {
return (
<form onSubmit={submitCode}>
<p>If an account exists for <b>{email}</b>, we’ve emailed a sign-in link and code.</p>
{/* npx shadcn add input-otp */}
<input inputMode="numeric" maxLength={6} value={code} onChange={(e) => setCode(e.target.value)} />
<button type="submit">Sign in</button>
</form>
);
}
return (
<form onSubmit={request}>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
<button type="submit">Email me a sign-in link</button>
</form>
);
}Callback page (/sign-in/magic?token=…) — do not auto-consume on load. Require
a click. Email-security scanners pre-fetch links (which would burn the single-use
token), and React StrictMode double-mounts effects. A confirm button avoids both:
function MagicLinkCallback() {
const token = new URLSearchParams(location.search).get('token');
const [state, setState] = useState<'confirm' | 'working' | 'invalid'>('confirm');
if (!token) return <p>Invalid sign-in link.</p>;
async function confirm() {
setState('working');
try {
await client.commerce.loginWithMagicLink(token!);
window.location.assign('/account'); // session token already captured via onAuthToken
} catch {
setState('invalid');
}
}
if (state === 'invalid') return <p>This link expired or was already used. <a href="/sign-in/magic">Request a new one</a>.</p>;
return <button disabled={state === 'working'} onClick={confirm}>Sign in</button>;
}Cross-device note: opening the link on a phone signs in the phone. To sign in the desktop tab that started checkout, enter the 6-digit code there instead.
Price Lists & Volume Pricing
Every variant fragment returned by commerce.getProduct, searchProducts, getCollection, getMyFavorites, and getProductRecommendations already includes the price-list metadata:
| Field | Meaning |
|---|---|
| price / priceWithTax | The actual price the visitor will pay (already discounted) |
| retailPrice / retailPriceWithTax | The undiscounted list price (use for <s> strikethrough) |
| priceListName | Human label of the winning price list (e.g. "Wholesale", "Buy 5+ deal") |
| priceTiers | [{ minQuantity, price, priceWithTax, discountLabel }] — full ladder for upsells |
Three audiences see populated values:
- Logged-in customers with a customer-direct or group assignment
- Logged-in customers in a channel that has a price list with
appliesToAllCustomers = true - Anonymous (guest) visitors in a channel that has a price list with
appliesToAllCustomers = true
Resolution priority is contract → customer-direct → group → channel-wide → retail, then higher list priority: the strongest list that prices the product wins, even when another list is cheaper. Between lists of the same level and priority, the lower price wins for each product.
// Render a "Buy 5 for €1,249" upsell on the product page
const product = await client.commerce.getProduct(slug);
const variant = product.variants[0];
const nextTier = client.pricing.getTierForQuantity(variant.priceTiers ?? [], 5);
if (nextTier) {
// nextTier.priceWithTax · nextTier.minQuantity · nextTier.discountLabel
}Tax display (incl./excl. VAT)
pricesIncludeTax is the channel's storage mode — which of price / priceWithTax the merchant typed in. It is not the answer to "which figure does this shopper see". A channel can quote Dutch consumers gross (the law requires it), Dutch B2B buyers net, and US shoppers net (US sales tax varies by state and district, so a tax-inclusive price is simply wrong there).
The engine resolves that per request; ask it once and let the display helper follow:
// Once per request (SSR) or per session (client) — cheap and cached.
await client.pricing.getTaxDisplayMode(); // 'gross' | 'net'
// Then everywhere you render a price — no mode argument:
const display = client.pricing.selectDisplayPrice(variant);
client.pricing.formatPrice(display.amount, display.currencyCode); // display.isGross tells you which it is| Method | Use |
|---|---|
| getTaxDisplayMode({ refresh? }) | Resolve the mode from the server. Cached per identity; refresh: true forces a round-trip |
| currentTaxDisplayMode() | The last resolved mode, synchronously — for render paths |
| selectDisplayPrice(variant, mode?) | Picks price/priceWithTax (and the matching retail field). Omit mode to follow the resolved one; pass one only to override (a side-by-side table, a shopper-driven toggle) |
| resolveTaxDisplayMode(policy, inputs) | Pure preview of the same precedence rule — for a "who sees what" table |
| parseTaxDisplayPolicy(activeChannel.customFields) | Read the channel's policy (taxDisplayDefault, taxDisplayB2b, taxExclusiveCountries) |
Precedence, resolved server-side: shopper's country is on the channel's tax-exclusive list → net; else B2B account (activeCustomer.isB2B) or the trade order scope → the channel's B2B setting; else the channel default; else pricesIncludeTax. The country comes from the active order's shipping address, else the signed-in customer's default one — never from the currency or the locale.
The mode is re-resolved automatically after a sign-in/out, a setOrderScope(...), and a setShippingAddress(...) (moving the cart to a listed country flips the whole cart to net). It never throws: an unreachable or older engine falls back to pricesIncludeTax ? 'gross' : 'net', i.e. the behaviour before this existed.
How an amount is written
Every amount the engine answers is in hundredths of its currency, in EVERY currency: ¥2,999 is
299900, 12.34 KWD is 1234, €29.99 is 2999 ("minor units" in this README means exactly
that). formatPrice divides by 100 and writes the result with the currency's own digits for the
locale, as the dashboard's order pages write money:
| Currency | Engine amount | formatPrice(amount, currency, 'en-US') |
|---|---|---|
| EUR | 2999 | €29.99 |
| JPY | 299900 | ¥2,999 |
| KWD | 1234 | KWD 12.340 |
If you write a figure yourself, divide by 100, never by 10 ** <the currency's digits>: that
reads ¥2,999 as ¥299,900 and 3.00 BHD as 0.300. The digits Intl writes a few currencies with
depend on the JavaScript engine's ICU (Node 24 writes HUF with none, Node 22 with two), as they do
for the dashboard.
Which currency labels an amount
Every amount the engine answers comes with the currency it was priced in: a variant's or a search
row's currencyCode, an order's currencyCode (the money view's too), a quote's, a credit
status's, a subscription's. Pass it: formatPrice(amount, currencyCode) always uses the currency
you give it.
Without one, formatPrice(amount) uses client.context.currencyCode: the session currency
(createStorefrontClient({ currencyCode }), checkout.setCurrency), else the currency the engine
last priced this store's catalogue in. That is read from the engine's own answers, never from the
CMS's copy of the store's default (context.channel.defaultCurrencyCode), which lags a change of
the store's default currency by up to 5 minutes (for a single-store client: until it is
re-created). So after such a change the next product, search or getActiveChannel() answer
relabels everything, without a restart. A store router shares it between the requests for a store.
A bare amount is still ambiguous: an order placed before the change, or a cart not changed since,
keeps its old currency. Format order, quote and account money with its own currencyCode, or
with orderMoneyRows, which carries it.
Order money (cart, checkout, account)
Print an order's money from the engine's money view, never by adding the order's fields up:
the view's rows are the ones the dashboard, the emails and the invoice print, and they add up to
the order's totals to the cent (linePriceWithTax + discounts + taxSummary miss by a cent on
about half the discounted carts in the basis the store did not price in).
import { formatPrice, orderMoneyRows } from '@sixtyninecommerce/client';
const view = await client.commerce.getOrderMoneyView('active'); // or { code }
if (view) {
const { lines, rows, payments, stillToPayOmitted } = orderMoneyRows(view);
// lines: line totals by orderLineId; a unit price only when unitAmount is not null
// rows: promotions, fees, Subtotal, Shipping, VAT per rate, Rounding, Total
// payments (placed orders): Paid, Gift card, Store credit, Credited, Refund pending, Refunded,
// Still to pay
} // null: no cart, or an engine before the view: print the order's own fields as before
const page = await client.commerce.getCustomerOrders({ take: 10, moneyView: {} });
// page.items[n].moneyView.total is the total the order page prints (null on an older engine)| Method | Use |
|---|---|
| commerce.getOrderMoneyView(order, { basis? }) | 'active' or { code }; basis defaults to CUSTOMER (how the shop shows prices to that customer); also STORE, GROSS, NET |
| commerce.getCustomerOrders({ moneyView: { basis? } }) | the order list with each order's view |
| orderMoneyRows(view, { billedOnAccount? }) | the printed rows, pure; kind is stable for translation, label an English default |
"Still to pay" holds what was paid on a B2B account statement too. stillToPayOmitted:
'KEPT_IN_ERP' means the customer's account is kept in an external accounting system: print "Paid
via your account in the accounting system." in its place. A refundPending row (kind
refundPending) is a refund the payment provider accepted and has not paid out yet, and a
refundedUncredited row ("Refunded") a refund paid out that no credit note credits yet: each is
already off Paid, and never part of Still to pay. billedOnAccount is read only for a
view from an engine before the balance read the account ledger. The full guide is
docs/headless/storefront/order-money.md.
Many products' details (getProductsBySlugs, getProductsByIds)
getProduct(slug) sends one request, and each one costs the platform dozens of database reads.
A page that needs the full details of several products (a comparison table, a bundle, a
"products in this order" view) asks for all of them in one call instead of a Promise.all of
getProduct:
const products = await client.commerce.getProductsBySlugs(['oak-table', 'oak-chair']);
// (Product | null)[], in the order asked: products[i] is what getProduct(slugs[i]) answers.
for (const product of products) {
if (!product) continue; // not found, disabled or hidden for this shopper
render(product);
}
const byId = await client.commerce.getProductsByIds(route.ids); // the same, by product idnullwheregetProductthrowsNOT_FOUND: a slug or id with no enabled product this shopper may see. The other positions are unaffected. Any other failure (network, timeout, a refused read) rejects the whole call, as onegetProductcall would. On an engine withproductsBySlugsevery position is exactlygetProduct's answer, the engine's own pick for a slug two products share included. On an older engine there is one divergence, by slug only: when a slug names a visible product in one language and a disabled or hidden one in another, the batch answers the visible product, whilegetProductmay pick the other and throwNOT_FOUND.- The same answer as
getProductfield for field, with every translation and the shopper's own prices, except the ORDER offacetValues: the platform defines no order for a product's facet values, and a read of many products may list the same values in another order. - Cost: by slug,
productsBySlugsin pages of 50 slugs. A client that tags its reads (the default;cacheTags: falseturns tagging off) first resolves the slugs to ids (pages of 100), so each page can carry its products' tags: two requests. With tagging off it is one request per page. An engine withoutproductsBySlugsrefuses it once per client, and the client reads slugs to ids, then details by id in pages of 50, from then on. By id, details in pages of 50. At most 4 requests in flight. On a production-shaped store, 100 products cost 3 requests instead of 100, about a sixth of the database statements and under a third of the server CPU; 50 products byproductsBySlugscost 40 statements on a demo store, against 1,534 for 50getProductcalls. - Cache tags:
catalog, plusproduct:<id>for every product a details page answers, plus the call's owncacheTags. - Refused before any request (
INVALID_ARGUMENT): anything but an array of strings, agetProductsByIdsid that is not a uuid, andmetafields(read those withgetProduct(slug, { metafields })).
A product GRID needs less than the full details: getProducts({ filter: { id: { in: ids } },
detail: 'card' }) reads a page of cards.
Many collections (getCollectionsBySlugs, getCollectionsByIds, listCollections)
The same, for collections: getCollection(slug) and getCollectionById(id) send one request each,
and each also loads 20 of the collection's variants with their stock. A block that shows several
collections (a CMS product_collection field, a category strip) asks for all of them in one call:
const collections = await client.commerce.getCollectionsByIds(field.value);
// (CollectionDetailFragment | null)[], in the order asked: what getCollectionById(ids[i]) answers.
const shown = collections.filter((collection) => collection !== null);
const bySlug = await client.commerce.getCollectionsBySlugs(['wines', 'beer']);nullwheregetCollectionById/getCollectionthrowsNOT_FOUND: no collection with that id or slug the store shows (unknown, private, below a private collection). Any other failure rejects the whole call. Two divergences: a slug a shown collection shares with a hidden one in another language answers the shown one, wheregetCollectionmay pick the hidden one; and the store's root collection (the invisible top of the tree, theparentof a top-level collection) is never in the collection list, so it isnullhere while the single getters answer it.- The same answer as
getCollectionfield for field, except the ORDER oftranslations: the platform defines no order for a collection's translations, and a read of many collections may list the same languages in another order. - Cost, cache tags and refusals as for products: pages of 100 slugs and 50 collections, at
most 4 requests in flight,
catalogpluscollection:<id>per details page plus the call's owncacheTags; anything but an array of strings, a non-uuid id andmetafieldsare refused before any request (INVALID_ARGUMENT).
The list rows are lighter (no variants): getCollections({ filter }) reads them, and
listCollections(listOptions) reads the same page with totalItems:
const page = await client.commerce.listCollections({ take: 100, skip: 0 });
// page.items: the rows; page.totalItems: how many collections there are in all.Both read every level of the tree; { topLevelOnly: true } reads the top-level collections alone.
Each row carries customFields.isRoutable: false on a collection marked filter-only (a tree of
wine regions, say, or one branch of a category tree). The dashboard marks a collection at any
level, and the mark covers everything below it, so a collection has URLs only when neither it nor
any collection above it is false. A row's own flag is not enough below the top level: its
breadcrumbs name the collections above, and their rows carry their flags.
A menu therefore reads every level and judges each row by the rows above it.
routableCollections(rows) does that (no network; also in @sixtyninecommerce/client/paths): it
keeps, in order, the rows that have URLs, dropping a marked collection and everything below it at
any depth. Build every level of the menu from what it keeps, never from a level's own flags alone:
import { type CollectionFragment, isUserFacingRoot, routableCollections } from '@sixtyninecommerce/client';
const rows: CollectionFragment[] = [];
for (let skip = 0; ; skip += 100) {
const page = await client.commerce.listCollections({ take: 100, skip });
rows.push(...page.items);
if (page.items.length === 0 || rows.length >= page.totalItems) break;
}
const shown = routableCollections(rows);
const shownIds = new Set(shown.map((row) => row.id));
const menu = shown.filter(isUserFacingRoot); // the first level
// A row's `children` name every child, marked or not: keep the ones it kept.
const childrenOf = (row: CollectionFragment) =>
(row.children ?? []).filter((child) => shownIds.has(child.id));listCollections({ topLevelOnly: true }) minus the rows whose customFields.isRoutable is false
is a correct first level, but only that: a deeper level built from children (which carry no
flag) or from a child's own flag keeps a branch marked further up.
A filter-only collection has no URLs: the routes manifest leaves its whole subtree out, products
included (no /regions/bordeaux/chateau-x). isRoutableCollection(collection) is the rule. For a
nested collection, pass EVERY collection above it in ancestors, each with its
customFields.isRoutable. ancestorsAmong(collection, rows) picks them, by its breadcrumbs, from
rows you already hold (the menu's, above); a page without them reads the crumbs' rows in one
request. Then seo.canonicalFor points a collection under a filter-only one at the storefront
root, as it does the collection marked:
import { ancestorsAmong, collectionIdsAbove } from '@sixtyninecommerce/client';
client.seo.canonicalFor({ kind: 'collection', ...collection, ancestors: ancestorsAmong(collection, rows) });
// Without the menu's rows: the collections above, in one request (a top-level one has none).
const aboveIds = collectionIdsAbove(collection);
const above = aboveIds.length > 0
? await client.commerce.getCollections({ filter: { id: { in: aboveIds } } })
: [];
client.seo.canonicalFor({ kind: 'collection', ...collection, ancestors: ancestorsAmong(collection, above) });A collection above that the rows passed do not hold reads as routable, as the routes manifest reads
a collection above that it never read: pass them all. Passing only the top-level ancestor
(breadcrumbs[1]), which older versions of this README advised, misses a mark in the middle of
the chain.
A nested collection's page lives under the collections above it, /regions/france, never
/france: the routes manifest builds every collection's path from its breadcrumbs, and
canonicalFor builds it the same way (collectionPathOf, the one rule) from the breadcrumbs
the collection carries, which getCollection selects and the spread above passes. For another
language's URL (alternatesFor), give each crumb its translations (the rows
getCollectionsByIds answers for the collections above); a crumb without them keeps its slug in
every language. Those rows also decide which languages alternatesFor lists: the routes manifest
serves a collection in a language other than the store's default only when it and every
collection above it are translated into it (collectionRoutedInLanguage, the manifest's own rule),
so a language one crumb's translations lack gets no hreflang row. A crumb without them withholds
nothing. A collection passed without breadcrumbs gets /{its own slug}, which is right for a
top-level collection only, and the languages of its own translations.
A page a storefront still renders for a filter-only collection (a route of its own, an old link)
is not for the index: seo.metaTagsFor, given the same ancestors, answers robots noindex with
its follow kept, and seo.robotsDirectiveFor (its collection given) default.index: false,
as the platform's head (seo.resolve) does. It has no URL in any language either, so
seo.alternatesFor answers no row for it, x-default included (metaFor's alternates is empty,
and metaTagsFor emits no hreflang and no og:locale:alternate).
The routes manifest builds a store's URLs only in the languages the store offers (and its default).
An entity can keep a translation in a language the store has dropped, so hand the store's list to
the helpers that list languages (alternatesFor, metaFor, metaTagsFor), and a language outside
it gets no hreflang row. The client's startup channel does not carry that list; the Shop API does:
const { availableLanguageCodes } = await client.commerce.getActiveChannel();
client.seo.alternatesFor({ kind: 'product', ...product }, { availableLanguageCodes });Without it, every language the entity has a URL in is listed, as before. A list that is not an
array of strings (or null) is refused with INVALID_ARGUMENT.
A page served in one language only (after that list and a collection's ancestors) has no other
page to pair with, so alternatesFor answers no row for it, x-default included, as the
platform's head answers it: metaFor's alternates is empty (its xDefault still names the
default-language URL), and metaTagsFor emits no og:locale:alternate and no hreflang. One
exception, for hreflang only: a product whose hreflangOverrides name another language keeps
its hreflang (its own row, the override's and x-default), since the head counts overrides too;
og:locale:alternate lists only the languages the page itself is served in, so it stays empty.
Every facet (listFacets)
getFacets() reads one page, the first 100 facets. A store has a facet per filterable product
property, so it can hold more: listFacets({ take, skip }) reads them a page at a time with
totalItems, ordered by facet code so the pages neither overlap nor skip a facet. Each facet
carries all of its values.
const first = await client.commerce.listFacets();
const second = await client.commerce.listFacets({ skip: 100 }); // when first.totalItems > 100Product Documents
commerce.getProduct(slug) and commerce.getProductById(id) return a documents: PublicProductDocument[] field with downloadable files attached to the product (manuals, spec sheets, data sheets, certificates). The list is already filtered by the current customer's visibility before it leaves the server — the storefront just renders what it gets.
const product = await client.commerce.getProduct('drill-pro-2000');
for (const doc of product.documents ?? []) {
// <a href={doc.fileUrl} download={doc.fileName}>{doc.title}</a>
// {doc.description}
// {doc.fileMimeType} · {formatFileSize(doc.fileSize)}
}| Field | Notes |
|---|---|
| title, description | Resolved for the active language; fall back to the channel default |
| fileUrl | Already absolute. Public docs use the channel CDN URL; non-public docs are short-lived presigned R2 URLs (default 15min). Don't pass through any media helper |
| fileMimeType, fileSize, fileName | Use for the meta line and the download attribute |
| requiresAuth | true when the URL is presigned. Storefront pages with any requiresAuth: true doc must not be cached publicly — set Cache-Control: private, no-store on those responses |
| position | Display order — render in this order |
Two language modes are transparent to the storefront — the resolver picks the right row before responding:
- Universal: one file used regardless of the active language (covers English-only docs and multi-lingual single PDFs).
- Localized: per-language title/description with optional per-locale file. Falls back to channel default, then to any sibling translation that has a file.
Visibility is gated server-side: PUBLIC (everyone), AUTHENTICATED (logged-in only), CUSTOMER_GROUP (allowlist of customer groups). Anonymous visitors only ever see PUBLIC docs.
Back in stock ("Notify me")
A shopper who cannot buy a variant leaves an address, and the platform emails them when the variant can be bought again in this store. The storefront owns the form, two link pages and an account list; the full contract is docs/headless/storefront/stock-notifications.md.
import { MutationError, type StockNotificationFragment } from '@sixtyninecommerce/client';
// Product page: offer the form when the variant cannot be bought now and the store takes alerts.
const settings = await client.commerce.getStockNotificationSettings();
const offer = settings.enabled && client.pricing.isAwaitingStock(variant, settings); // NOT !canAddToCart
// A guest names an address; a signed-in customer omits it (their account address is used).
try {
const { confirmationRequired } = await client.commerce.requestStockNotification(variant.id, email);
// confirmationRequired: "check your inbox to confirm"; otherwise "we'll email you".
} catch (error) {
if (error instanceof MutationError) {
// STOCK_NOTIFICATION_EMAIL_REQUIRED_ERROR | _INVALID_EMAIL_ERROR | _UNAVAILABLE_ERROR
}
}
// Signed in: the PDP's state and the account's "My alerts".
const alert: StockNotificationFragment | null =
await client.commerce.getActiveStockNotification(variant.id); // state: WAITING, NOTIFIED, …
const { items, totalItems } = await client.commerce.getMyStockNotifications({ take: 20 });
if (alert) await client.commerce.cancelStockNotification(alert.id); // true: it was theirs here
// The two pages the emails link to.
await client.commerce.confirmStockNotification(token); // /stock-notifications/confirm?token=
await client.commerce.unsubscribeFromStockNotification(token); // /stock-notifications/unsubscribe?token=The answer to a request is the same whether or not the address was already waiting: never tell a
shopper they were "already subscribed". isAwaitingStock reads stockLevel on a store that hides
stock numbers (the default), where canAddToCart is true for a sold-out variant. It is true
for a coming-soon variant, and for an open pre-order with nothing saleable unless the store mails
pre-order openings (settings.preorderEmails): that store counts it buyable, so offer "Pre-order"
there. Once a capped pre-order's cap is used up the engine waits for stock again: a signed-in
shopper allowed to backorder, on a store that shows stock, reads that as backorderAllowance 0
and isAwaitingStock answers true for them; for everyone else (backorderAllowance null) it
still answers false, a known limitation.
Invoices and credit notes (customer account)
A signed-in customer reads the invoices and retail credit notes of their OWN orders, by order code
(a credit account's accounts-receivable credit notes are not listed yet).
Anything else — another customer's order, a guest checkout's, another store's — is refused
FORBIDDEN, indistinguishable from an order that does not exist.
const invoices = await client.commerce.getMyOrderInvoices(order.code);
const creditNotes = await client.commerce.getMyOrderCreditNotes(order.code);
// creditNotes[n]: { id, creditNoteNumber, issuedOn, totalWithTax, currencyCode,
// kind, reason, invoiceNumber, createdAt }
// Mint on the Download click, never earlier and never cached — e.g. a route handler
// behind the button:
const { url } = await client.commerce.getMyCreditNoteDownloadUrl(creditNoteId);
return Response.redirect(url, 303);| Field | Notes |
|---|---|
| totalWithTax | The credited amount, positive minor units; the PDF prints it negative |
| kind | adjustment (an over-charge, goods kept), return (a settled return), cancellation (the whole invo
