npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/client

Quick 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 you
  • take: 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 gallery assets, variant options/optionGroups, documents or metadata: read those with getProduct for 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 catalog tag; pass the ids' tags (above) so product.* 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 what getCollectionById / getCollection answer (20 variants with their stock each), in pages of 50, tagged collection:<id>; Many collections below. A tile needs less: one getCollections({ 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 with totalItems, 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: a setLanguage or 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 productsBySlugs refuses 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 productsBySlugs request carries its calls' own cache tags and catalog, exactly what their unbatched getProduct(slug) carries; it cannot carry product:<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 options or the translations lists unordered may list them in another order in a page of products, and, on an engine without productsBySlugs only, 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 off

readAdmission: 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_TIMEOUT and was never sent; a read whose signal fires while it waits fails with REQUEST_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, getCollection or getCollectionById called for ten or more different products or collections on one client in one turn (a Promise.all over a list, the cards of one render). Calls for the same product do not count twice. With batching: true the 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_ENV anything but production): printed with console.warn, at most once a minute for each kind and method. Production: nothing is printed.
  • onPerformanceNotice gets 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/forStore returned 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 refuses customerToken, currencyCode and buyerIp: 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 privateToken is { [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 (the Host header the server received — exact, then on any port — and the URL only when there is none, since self-hosted Next builds request.url on its own host; never X-Forwarded-Host, which a visitor can send) and storeFromPathPrefix ({ '/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 plain node:http server, build it from the Host you trust.
  • Refusals are StorefrontErrors with status 404, so a page can render its not-found view from error.status: STORE_NOT_RESOLVED (your rule returned null), STORE_NOT_ALLOWED (outside stores), CHANNEL_NOT_FOUND (an unknown code, the bootstrap root, or an organization's workspace — a hub is never a store). Set stores whenever 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 /next and /web adapters accept the router too: createSeoArtifactHandler(stores, 'sitemap') / createSeoArtifactResponder(stores, 'sitemap'), createProductFeedHandler(stores) / createProductFeedResponder(stores) and createRedirectMiddleware({ client: stores, onError }) serve each request from its own store, and answer a request no store serves with 404 (the middleware falls through; its onError also hears when a store's redirect map fails to load). The /next handlers tag their upstream fetch store-scoped too; /web sets 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 armed

logout, 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 with resetPassword(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 register is checked, then ignored. It still answers PASSWORD_VALIDATION_ERROR for 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_ERROR from register means the store has no storefront address to link the email to; its message says 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:

  1. Logged-in customers with a customer-direct or group assignment
  2. Logged-in customers in a channel that has a price list with appliesToAllCustomers = true
  3. 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 id
  • null where getProduct throws NOT_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 one getProduct call would. On an engine with productsBySlugs every position is exactly getProduct'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, while getProduct may pick the other and throw NOT_FOUND.
  • The same answer as getProduct field for field, with every translation and the shopper's own prices, except the ORDER of facetValues: 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, productsBySlugs in pages of 50 slugs. A client that tags its reads (the default; cacheTags: false turns 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 without productsBySlugs refuses 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 by productsBySlugs cost 40 statements on a demo store, against 1,534 for 50 getProduct calls.
  • Cache tags: catalog, plus product:<id> for every product a details page answers, plus the call's own cacheTags.
  • Refused before any request (INVALID_ARGUMENT): anything but an array of strings, a getProductsByIds id that is not a uuid, and metafields (read those with getProduct(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']);
  • null where getCollectionById / getCollection throws NOT_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, where getCollection may pick the hidden one; and the store's root collection (the invisible top of the tree, the parent of a top-level collection) is never in the collection list, so it is null here while the single getters answer it.
  • The same answer as getCollection field for field, except the ORDER of translations: 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, catalog plus collection:<id> per details page plus the call's own cacheTags; anything but an array of strings, a non-uuid id and metafields are 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 > 100

Product 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