Cart Management
Add, update, and remove items from the shopping cart
Get Cart
const result = await client.cart.get();
// SdkResult<Cart>: handle result.error before reading result.data.
// result.data contains items, bundleLines and server totals on success.Add Item
productId is the eshop product id of the sellable unit (ProductListItem.id /
ProductDetail.id). Each cart line is keyed by that product, and quantity
stacks when you add the same product again.
const result = await client.cart.addItem({
productId: 'eshop-product-id',
quantity: 2,
});
// Cart session token is auto-saved internallyAdding a variant
A variant is its own sellable product, so a variant's id (from
ProductDetail.variants[].id) is exactly what you pass as productId, and there is
no separate variant field:
const result = await client.catalog.getProduct(slug);
if (result.error) throw new Error("Product could not be loaded.");
const variant = result.data.variants.find((v) => v.sku === chosenSku);
if (!variant) throw new Error("Choose an available variant.");
await client.cart.addItem({productId: variant.id, quantity: 1});Linking a cart line back to the product (SDK 1.1.0)
item.product.slug is the slug of the page the line belongs to. For a variant
line that is the parent's slug, and item.product.variantSlug holds the
deep-link token, because a variant has no page of its own:
import {variantHref} from '@behio/storefront-sdk';
<a href={variantHref(item.product.slug, {variantSlug: item.product.variantSlug ?? ''})}>
{item.product.name}
</a>Before 1.1.0 the API returned the variant's own slug here and the link answered 404 for every variant line.
Language of the current page
Create the SDK client with the current page's locale. Cart reads and mutations
use it for product names and localized links. Checkout preview and submission
must use the same locale, including calls made from server actions.
The API accepts ?locale=en on cart requests and checkout preview. A supported
language applies only to that response; reading the cart does not change its
stored language. Two tabs can therefore show one cart in different languages
without overwriting each other. Unsupported languages retain the saved cart
language, or the shop default if the saved language is no longer enabled.
Missing product translations use the existing product fallback. Changing the
language does not change the selected currency or destination.
This behavior is part of the certification candidate and is not yet deployed.
Update Quantity
const result = await client.cart.updateQuantity('cart-item-id', 3);Availability and quantity controls (SDK 2.0+)
SDK 2 opts in with X-Behio-Cart-Contract: 2 on reads and mutations. Direct API
clients must send the same header to request this contract. Older clients on
the v1 route keep numeric snapshots in the cart's own currency when an offer
disappears. Preview and checkout still validate current prices and availability;
the stored snapshot never authorizes a purchase. Upgrade the template and SDK
together to use the following availability and repair interface.
The server revalidates retained rows against the current product settings,
selling window, domain, currency and combined quantities. A product can become
unavailable after it was added. Keep that row visible so the shopper can remove
it. isPurchasable: false and unavailableReason explain the current state:
ITEM_UNAVAILABLE, CURRENCY_UNAVAILABLE or QUANTITY_UNAVAILABLE.
When a current price cannot be resolved, unitPrice, totalPrice, the row's
financial amounts and product.currentPrice are null. Show an unavailable
label and omit price analytics. Never convert that value to zero or display an
old snapshot as the current offer.
If any row or bundle is unavailable, cart.totalsAvailable is false. Hide
monetary summaries and checkout actions in both the cart and mini cart. The
checkout page must re-read the cart on the server and reject this state too.
An API failure is a separate error state, not an empty or unavailable cart.
Use item.quantityControls.decreaseTo and increaseTo as the actual quantity
targets. A null target disables that direction. These targets account for
units of the same product inside bundles and for stock shared by product
aliases; adding or subtracting one can violate the merchant's minimum or pack
step. Older API responses can omit quantityControls, so retain a fallback
until all servers support this contract. In TRACKED + BACKORDER, the
targets also respect the merchant's positive backorder allowance. A changed
allowance can invalidate an existing ordinary line or bundle; preserve it and
offer the server's repair target or removal. Zero allowance means unlimited
backordering, and non-tracked modes do not enforce this inventory ceiling.
Render the current state in the first HTML response. Native server forms must also allow a valid quantity repair or removal when JavaScript is disabled. Quantity mutations validate before writing, and cart mutation responses retain the request's domain restrictions.
Cart reservations (SDK 2.15+)
A merchant can switch on cart reservations per shop. While a reservation is
active, the pieces on that cart line are guaranteed to that cart and nobody
else can buy them. shop.checkout.cartReservationEnabled says whether the shop
uses them, and every cart line carries its own deadline:
item.reservedUntil // epoch milliseconds, or null
line.reservedUntil // the same on bundle linesnull means the line is not reserved: reservations are off, the merchant
excluded that product, or the reservation has already expired. Products can
have their own length, so never compute the countdown from
shop.checkout.cartReservationMinutes (that is only the shop default). Render
it from reservedUntil:
function reservationLabel(until: number | null, locale: string, timeZone: string) {
if (until === null) return null;
const time = new Intl.DateTimeFormat(locale, {hour: '2-digit', minute: '2-digit', timeZone}).format(until);
return reservedLabel(time); // cs: "Rezervováno do 14:35", en: "Reserved until 2:35 PM"
}Use the shop's time zone and the page locale, not the server's. Every write that touches a line (add, quantity change, merge after sign-in) renews its reservation to the full length, so the deadline moves forward while the shopper keeps working with the cart.
Expiry is lazy. Nothing is pushed to the storefront when a reservation ends and
no job runs: the server simply stops counting it. Re-fetch the cart (on focus,
on the checkout page, and when your timer reaches the deadline) and treat
reservedUntil: null as "no longer guaranteed". An expired line stays in the
cart; if somebody else has taken the pieces meanwhile it comes back with
isPurchasable: false and unavailableReason: 'QUANTITY_UNAVAILABLE', and
quantityControls carries the repair target.
Adding or increasing a quantity can fail with HTTP 400. When the pieces exist
but another cart holds them, the key is be.storefront.stockReservedByOthers
(translated by errorMessage(err, locale)); when they are really out of stock,
it stays be.storefront.insufficientStock. Show the first one as "try again
later", not as sold out.
Where the catalog counts reservations, and where it does not:
- Product detail (including its variants) and cross-sell subtract the pieces
held by OTHER carts. When you send the shopper's
X-Cart-Session(the SDK does this on every call), their own reserved pieces stay in stock for them, so a detail shows "in stock" to its owner and sold out to everybody else. BundlequantityControlsand the cart do the same. - Listings (
products,categories/:slug/products,featured,product-groups), filters and facets show the physical stock. They are shared, cached responses, so a fully reserved product can still show a card as in stock until the shopper opens it. Treat the detail page and the cart as the source of truth, and handlebe.storefront.stockReservedByOtherson add to cart. - A page you cache or prerender without the session header shows the guest view, so read availability for the add-to-cart button from a request that carries it.
Under heavy load (a flash sale on one product) a cart write can briefly wait for
the stock item. If it cannot get it within two seconds the API answers HTTP 409
with be.storefront.stockBusyRetry ("many people are buying this item right
now, try again"). Retry once or twice with a short delay; it is never a server
error and nothing was written.
Prices that require login
When the merchant hides prices from guests, the cart read can return HTTP 401
with error.body.key === 'be.storefront.customerLoginRequired'. Render an
explicit sign-in explanation in the first HTML of the cart and checkout, and a
sign-in action in the mini cart. Do not turn this into HTTP 503, a false empty
cart, or a retry-only message. Do not expose stored snapshot prices to bypass
the price entitlement.
Keep the cart's HTTP-only cookie and a localized, validated internal return path. After successful sign-in, merge the anonymous cart and return to that path with its current server prices. Native login forms must work without JavaScript; a rejected login keeps the e-mail and return path while clearing the password. Token expiry and service outages retain their own recovery flows.
A template snapshot endpoint may return a typed, price-free requiresLogin
state with HTTP 401. Its React consumer must accept that specific state while
keeping unexpected 401 and 5xx responses as errors. The response remains private
and contains no auth tokens or protected prices.
Remove Item
const result = await client.cart.removeItem('cart-item-id');Clear Cart
await client.cart.clear();Clearing removes ordinary items, bundle lines, the discount code and applied gift cards in one transaction. It keeps the cart session and currency. Removing a gift-card application does not change the card's balance. After calling the core client, refresh your server-rendered cart or invalidate the cart and bundle queries so the interface reflects the empty basket.
With SDK 2.0.1, useCart() cancels older cart reads before a mutation and
before accepting its result. This also covers adding, clearing, discount changes
and merging, so a delayed response cannot restore an outdated basket.
Switch Cart Currency (SDK 1.6.0)
The cart holds price snapshots in its own currency. Switching the shop
currency therefore has two halves: client.setCurrency('EUR') changes what
the catalog displays, and client.cart.setCurrency('EUR') re-prices the cart
server-side. When you use useCurrency() or <CurrencySwitcher/> from
@behio/storefront-sdk/react, both happen automatically.
const result = await client.cart.setCurrency('EUR');Rules the backend enforces:
- The currency must be one of the shop's
supportedCurrencies, otherwise 400. - Every cart line must have a price configured in the new currency. Money is never FX-converted. If any item lacks one, the call returns 400 with the item names and the cart keeps its previous currency and prices.
- When no cart exists yet, an empty cart is created in the requested currency, so items added later are priced correctly from the start.
Delivery country and VAT in the cart
VAT depends on where the order ships. Tell the cart as soon as the shopper picks a country (SDK 1.17.0) and the breakdown matches the checkout:
await client.cart.setDestination({ country: "SK" }); // OSS rate of SK when the merchant has OSS on
await client.cart.setDestination({ country: "CH" }); // 0 % when the merchant exports outside the EU without VAT
await client.cart.setDestination({ vatId: "SK2020000001" }); // B2B: checked in VIES, reverse charge when validcart.vatMode tells you which rule applied (DOMESTIC, OSS, REVERSE_CHARGE,
EXPORT); for the last two taxBreakdown is empty, so render a one-line note
("VAT is paid by the customer" / "Export outside the EU, no VAT"). cart.vatIdValid
is false for an invalid or unverifiable VAT ID; the checkout re-checks the VAT
ID from the billing address on its own, so a cart value is never trusted blindly.
Bundles in Cart
Bundles are a separate cart concept from items. The cart exposes them as
cart.bundleLines (each with its own id, quantity, bundlePriceSnapshot
and the items it contains), and their totals are already included in
cart.subtotal / cart.grandTotal. Manage a bundle line by its line id
(cart.bundleLines[].id), not the bundle id.
// Fetch the offer with the current customer and cart session first.
const offer = await client.catalog.getBundle('starter-kit');
if (offer.error || !offer.data.isPurchasable) throw new Error('Offer unavailable.');
const quantity = offer.data.quantityRules?.minimum ?? offer.data.minQuantity;
const result = await client.cart.addBundle({slug: offer.data.slug}, quantity);
// Read the lines
if (result.error) throw new Error("Bundle could not be added.");
const line = result.data.bundleLines[0];
if (!line) throw new Error("The bundle line is missing.");
// Update quantity / remove, always with the bundle LINE id
const next = line.quantityControls?.increaseTo;
if (next != null) await client.cart.updateBundleQuantity(line.id, next);
await client.cart.removeBundle(line.id);Use each bundle line's quantityControls for exact targets. Null disables that
direction, including when merchant limits leave no valid next step. Refresh
catalog bundle offers after mutations: their quantityRules describe additional
sets for the current visitor basket. Keep those responses out of shared caches.
Cart Merge
After login, merge the anonymous cart into the customer cart:
const login = await client.auth.login({ email, password });
if (login.error) throw new Error("Sign-in failed.");
const mergedCart = await client.cart.merge();
if (mergedCart.error) throw new Error("Cart could not be merged.");Session Persistence
// Save to cookie / localStorage
const token = client.getCartSession();
// Restore on page load
client.setCartSession(savedToken);Events
client.on('cart:updated', (data) => {
if (data && typeof data === 'object' && 'itemCount' in data && typeof data.itemCount === 'number') {
updateCartBadge(data.itemCount); // Includes quantities inside bundles.
}
});
client.on('cart:cleared', () => {
updateCartBadge(0);
});Cart operations across a context change
SDK 2.0.3 discards an older response if the customer, anonymous session, currency, country or locale changed while it was pending. It returns SDK status 409 so the caller can explain that the operation needs to be repeated in the current context. An old response cannot save an obsolete anonymous token or restore a previous currency. This does not undo a write already accepted by the server; read the current cart before the customer chooses to try again.
Cart links from Behio Chat (SDK 2.4.0)
When the AI in Behio Chat assembles a cart for a visitor, it sends a button
that opens https://<your shop>/cart/link/<token>. Handle that route on the
server, add the products with cart.claimLink(token) and redirect to checkout:
// app/cart/link/[token]/route.ts (Next.js route handler)
import { getBehio } from '@behio/storefront-sdk/next';
export const dynamic = 'force-dynamic';
const headers = { 'cache-control': 'private, no-store' };
const redirect = (location: string) => new Response(null, {
status: 303, headers: { ...headers, location },
});
const probe = () => new Response(null, {
status: 204, headers: { ...headers, 'x-behio-cart-link': '1' },
});
type Context = { params: Promise<{ token: string }> };
export async function GET(_req: Request, { params }: Context) {
const { token } = await params;
if (token === '_probe') return probe();
if (!/^[A-Za-z0-9_-]{16,64}$/.test(token)) return redirect('/cart?link=expired');
const unavailable = () => redirect(`/cart?link=unavailable&linkToken=${encodeURIComponent(token)}`);
try {
const behio = await getBehio();
const { data, error } = await behio.cart.claimLink(token);
if (error || !data) {
const expired = error?.status === 404 && typeof error.body === 'object' &&
error.body !== null && 'key' in error.body &&
error.body.key === 'be.storefront.cartLinkExpired';
return expired ? redirect('/cart?link=expired') : unavailable();
}
const partial = data.linkSkippedItems > 0;
const refused = Boolean(data.linkDiscountCode) && !data.linkDiscountApplied;
const notice = partial && refused ? 'partial-discount' : partial ? 'partial' : refused ? 'discount' : null;
return redirect(notice ? `/cart?link=${notice}` : '/checkout');
} catch {
return unavailable();
}
}
export async function HEAD(_req: Request, { params }: Context) {
return (await params).token === '_probe'
? probe()
: new Response(null, { status: 405, headers: { ...headers, allow: 'GET' } });
}- The cart page must render these notices on the server. For
unavailable, validatelinkTokenwith the same pattern and render a native retry anchor to/cart/link/<token>. Preserve that target even if reading the cart also fails. Never use an arbitrary return URL or prefetch a link that changes the cart. Localize the paths and messages using your storefront's locale conventions. - A link raises each product quantity to at least the offered quantity. It preserves larger quantities and unrelated items. Repeated and concurrent claims do not add the same target again, for anonymous and signed-in visitors.
linkDiscountCodeidentifies the offered coupon even when it was refused;nullmeans the link offered no coupon.linkDiscountAppliedconfirms whether validation accepted it. A refusal preserves an already applied coupon and must be visible before checkout. This behavior is fixed in the backend release of 26 September 2026; the existing SDK 2.5.0 response type is compatible.- Products that can no longer be bought are skipped (
linkSkippedItems). A service or database failure is an error to retry, not a skipped product. - Only HTTP 404 with
be.storefront.cartLinkExpiredconfirms an expired or unknown link. Rate limits, transport failures and other errors cannot establish expiration. An interrupted claim may have added some items; retry uses minimum quantities and does not duplicate those successful additions. GETandHEADof_probeadvertise support without creating a session or changing a cart. OtherHEADrequests return 405 and do not claim the link.
Cart event in the browser (SDK 2.6.0)
Every cart response the SDK receives in the browser also dispatches a
behio:cart event on window, so scripts outside your app can react to the
cart without access to the SDK instance. Behio Chat uses it for page greetings
based on cart value and for the {cartTotal} and {cartItems} variables in
greeting texts. A cart without valid amounts (totalsAvailable: false), a failed
read or a changed customer/pricing context clears the previous summary with
detail: null. Null means unknown, never an empty cart. Older responses cannot
restore previous amounts. itemCount includes units in bundles.
If your storefront changes the cart on the server (Server Actions, your own API route that returns a cart snapshot), the browser never sees a cart response and no event fires. Publish the summary yourself whenever the client receives a fresh cart:
import { publishCartSummary, clearCartSummary, type Cart } from '@behio/storefront-sdk';
// Call after hydration and each accepted response; pass null on errors.
export function syncChatCart(cart: Cart | null) {
if (!cart || cart.totalsAvailable === false) {
clearCartSummary();
} else {
publishCartSummary({ total: cart.grandTotal, currency: cart.currency, itemCount: cart.itemCount });
}
}window.addEventListener('behio:cart', (event) => {
const summary = (event as CustomEvent).detail;
if (summary === null) {
// Remove the previous total. Its price or visibility is no longer known.
return;
}
const { total, currency, itemCount } = summary;
console.log(`Cart: ${itemCount} items, ${total} ${currency}`);
});The last value is also available as window.__behioCart. Sites that do not use
the SDK can pass the cart to Behio Chat with
window.behioChat.setCart({ total, currency, itemCount }), or
window.behioChat.setCart(null) to revoke it. Call clearCartSummary() on
snapshot errors and login-required prices, including server-action flows.
These helpers are no-ops on the server. After hydration, publish the valid
SSR seed; publish zero only for a confirmed empty cart. Keep the usual request
sequence/session checks so stale server responses cannot call the helper.
Behio Chat cancels pending cart-value greetings and removes stale cart text
when the summary becomes unknown or falls below the greeting threshold.