Behio Storefront SDK
Cart & Checkout

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 internally

Adding 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 lines

null 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. Bundle quantityControls and 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 handle be.storefront.stockReservedByOthers on 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 valid

cart.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.

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, validate linkToken with 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.
  • linkDiscountCode identifies the offered coupon even when it was refused; null means the link offered no coupon. linkDiscountApplied confirms 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.cartLinkExpired confirms 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.
  • GET and HEAD of _probe advertise support without creating a session or changing a cart. Other HEAD requests 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.

On this page