Customer accounts need kit 1.9.0 or later, and a publishable key with the
customer:session scope. The key from the admin’s Custom storefront view has it automatically. A key minted with an explicit list of scopes has it only if that list includes it; otherwise the platform refuses the calls with a 403, whatever the kit’s local scope check says. customers.config() also works with storefront:read alone. See Publishable keys.What the shop has switched on
customers.config() returns the merchant’s settings. They’re public and the same for every shopper, so the kit reads them once per client:
login.enabled is true. On a shop without customer login, login() throws customer_login_disabled.
The API
shopkit.customers is a resource on every client:
The qb:customer event
In a browser, every login, logout and expiry is also dispatched on document as a qb:customer event (the name is exported as CUSTOMER_EVENT). Its detail is { customerId }, or null. It never carries the profile, which other scripts on the page have no business reading.
Prices for customers and guests
Catalog responses say who they were priced for. Each variant price has asource:
The response as a whole carries
{ audience, groupId, vatDisplay }. Lists and searches carry it once per page, as page.meta.pricing. Single reads (products.get(), getBySlug()) carry it as product.pricing.
audienceis"anonymous","customer"or"customer_group".vatDisplayis how the customer’s group shows prices:"incl"or"excl"VAT, ornulloutside a group. It’s a display hint only; the amounts are the same either way.
Hidden prices
A shop can show prices to logged-in customers only (config().login.hidePricesForGuests). A guest then gets price: null with hidden: true. Show a “log in to see prices” hint instead of a price:
hideAddToCartForGuests, a guest’s cart write is refused with login_required. Price filters and price sorting are ignored for a guest who can’t see prices.
Sessions and renewal
A session is a 15-minute access token plus a refresh token that rotates on every use. It ends on logout, when the customer’s password changes, when the merchant blocks the customer, after 30 days without a renewal, and in any case 90 days after login. The kit handles renewal:- Before a request, an access token that expires within 30 seconds is renewed first. Expiry is timed from when the token arrived, so a device with a wrong clock behaves the same as any other.
customer_token_expiredfrom the platform renews once and retries the request. Concurrent requests share one renewal.- Before every renewal the kit re-reads storage. Another tab, or another client on the same storage, may already have renewed; its pair is adopted, and no request is made when its access token is still fresh. In a browser with Web Locks, renewals are also serialized across tabs. When two renewals do race, the platform accepts the previous refresh token for 30 seconds after a rotation. An older refresh token, or the previous one after those 30 seconds, is treated as stolen and revokes the session.
- Other tabs. A login or logout in another tab reaches this one through the
storageevent (localStorage), or when the tab becomes visible again (cookies send no event).onChangefires as it would for a login here. - A session the platform refuses (
customer_token_invalid, a token for another shop, or a renewal refused because the customer was blocked or login was switched off) ends. It’s forgotten,onChange(null)fires, and the cart id goes too, since the cart is bound to the customer. A GET is then retried as a guest, so a product grid shows guest prices instead of an error. A cart write or a checkout throws instead, because replaying it as a guest could create a cart the shopper didn’t mean to. - A renewal that fails any other way (offline, a 5xx, a 429) keeps the session and throws, so a network blip doesn’t log anyone out.
The cart belongs to the customer
The first cart request made with a customer’s token binds the cart to that customer, so a guest’s cart carries over on login, repriced for the customer’s group. From then on, only that customer can read or check out the cart. A guest, or another customer, getscustomer_token_required or cart_customer_mismatch. The remembered-cart calls (cart.current(), cart.add(), CartStore, useCart() and the cart elements) treat such a cart as gone, forget it and start a fresh one. To do the same by hand, call cart.forgetCurrent(), which forgets the remembered id without deleting the cart.
Errors
Customer-account refusals are thrown asShopkitAuthError, a ShopkitApiError with a code:
Where the session is stored
The session is kept under<prefix>_customer_session in the client’s storage adapter, next to the cart id. It’s the only credential the kit ever stores, and only while a customer is logged in. See Storage & SSR.
Server-rendered storefronts: an httpOnly cookie
With server rendering, keep the session in an httpOnly cookie written by the server, so no script on the page can read it:app/api/login/route.ts
- A browser client has no token, so it’s refused the customer’s cart (
customer_token_required) and starts a new, guest-priced cart. - Catalog reads from the browser work, but they’re priced for a guest. Fetch customer-priced data on the server and pass it down.
useCustomer()in the browser reports a guest. Render account UI on the server.
Renew where cookies can be written
The refresh token rotates on every renewal, and the new one has to be written back. A client whose storage can’t write never renews:- a cookie accessor without
set(a React Server Component), createRequestCookieStorage()withoutcollect,- any adapter with
writable: false, or - a client created with
customerSession: { renew: false }.
middleware.ts
Browser-only storefronts: the XSS trade-off
Without a server, the session lives where the cart id does: in a cookie written bydocument.cookie, or in localStorage. Both are readable by any script on the page, so a cross-site scripting hole on the storefront would expose the session. The damage is bounded: an access token lives 15 minutes, logout revokes the refresh token, and the session only reaches that customer’s own data and prices. It’s still real, so keep third-party scripts to a minimum, use a Content Security Policy, and prefer the server setup for a shop whose customers have meaningful group prices.
One client per request on the server
A logged-in session belongs to one shopper. On a server, never let a long-lived client hold one. Build a client per request withwithStorage(), as above, so the session comes from that request’s cookies and dies with it. The kit keeps no session anywhere but in the client instance and its storage adapter.
Don’t share caches of customer-priced data
A response for a logged-in customer belongs to that customer. Don’t put it in a cache shared between requests: nounstable_cache, "use cache" or revalidate window around a customer-priced call in Next.js, no CDN, no module-level map. The platform answers customer reads with Cache-Control: private, no-store for the same reason.
React’s cache(), which memoizes within one server request, is fine. Anything that outlives the request isn’t. The kit’s own product cache (getProductPromise()) is keyed on customers.cacheKey, so it never mixes customers. For a slug lookup shared between requests, cache the slug → id map (it’s the same for everyone) and fetch the product per request.
Async storage
With an adapter that only answers asynchronously (Next’scookies()), isLoggedIn is false until the session has been read. Any request reads it, or call await shopkit.customers.getAccessToken() to read it explicitly.
React
loginrejects with theShopkitAuthErrorand also puts it onerror.logoutnever rejects.useCustomer()reads the profile once when it finds a session without one (a reload, or aftersetSession()).- On every login and logout, the catalog hooks refetch and
useCart()re-reads the cart, so prices and the basket follow the customer with no wiring. useClientCustomerKey(client)is the bare subscription tocustomers.cacheKey, for your own data hooks.
Web components
There’s no login element. Call the ambient client’scustomers from your own form, and the elements follow: every <qb-product>, <qb-product-list> and cart element re-reads on login and logout, as it does on a currency switch.