Skip to main content
A shop that has the customer-login app switched on can let customers log in on a headless storefront too. Once a customer is logged in, the client sends their token with every request without being asked. Products, categories, the cart and the checkout are then priced for the customer’s group, and products visible only to that group show up. The order is linked to the account, which is what earns club points. Nothing else in the storefront changes.
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.
Registration, password reset, profile edits, order history and joining the club are not in the API yet. Send shoppers to the shop’s own account pages for those.

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:
Show a login form only when 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 a source: 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.
  • audience is "anonymous", "customer" or "customer_group".
  • vatDisplay is how the customer’s group shows prices: "incl" or "excl" VAT, or null outside a group. It’s a display hint only; the amounts are the same either way.
All of these fields are optional in the types; they’re absent from a platform older than them.

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:
With 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_expired from 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 storage event (localStorage), or when the tab becomes visible again (cookies send no event). onChange fires 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, gets customer_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 as ShopkitAuthError, a ShopkitApiError with a code:
Repeated failed logins lock an account’s headless login for a while (10 failures in 15 minutes). Anyone who knows a shopper’s email can trigger this, so word the rate_limited message as “try again later”, not as a security alert.

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. 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
That client’s cookie attributes apply to every cookie it writes, including the cart id, and a browser client never sees an httpOnly session. So in this setup every cart and checkout call goes through the server, with a client built like the one above:
  • 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() without collect,
  • any adapter with writable: false, or
  • a client created with customerSession: { renew: false }.
Such a client sends the access token while it’s valid, and treats an expired one as a guest for its own requests. It never logs anyone out. So renew before rendering, where cookies can be written, and hand the rotated cookies both to the render and to the browser. In Next.js that’s middleware:
middleware.ts
Server components then build their client from the request’s cookies as usual (read-only, so they never renew) and find a token that’s already fresh. Two renewals racing (two tabs, or middleware and a route handler) are harmless within the platform’s 30-second grace window.
A renewal that’s never written back ends the session. The browser keeps the refresh token that was just replaced, and presenting it after the 30-second window revokes the session. Never renew from a client that can’t return its cookies to the browser.

Browser-only storefronts: the XSS trade-off

Without a server, the session lives where the cart id does: in a cookie written by document.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 with withStorage(), 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: no unstable_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’s cookies()), isLoggedIn is false until the session has been read. Any request reads it, or call await shopkit.customers.getAccessToken() to read it explicitly.

React

  • login rejects with the ShopkitAuthError and also puts it on error. logout never rejects.
  • useCustomer() reads the profile once when it finds a session without one (a reload, or after setSession()).
  • 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 to customers.cacheKey, for your own data hooks.
See React hooks.

Web components

There’s no login element. Call the ambient client’s customers 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.

Troubleshooting