Behio Storefront SDK
Cart & Checkout

Checkout

Create orders from the cart

Review the final amount

Use checkout.preview(input) after the address, shipping method and payment instrument are complete. It returns SdkResult<CheckoutPreview>. This runs the same calculation as order creation, including shipping, payment fees, discounts, gift cards, loyalty points and merchant rounding. Preview does not create an order, reserve a number, claim stock, redeem balances or start payment. Shipping quotes may be refreshed.

const {data: preview, error} = await client.checkout.preview(input);
if (error || !preview) return showPreviewError();
// Show preview.grandTotal and its breakdown, then wait for the shopper's submit.
// Keep preview.previewToken with this exact set of inputs for up to five minutes.

Send previewToken alongside the final inputs in checkout.createOrder. It is optional for older clients, but new templates must use it. The signed token is scoped to the shop, cart, resolved line prices, customer and delivery/payment choices. Changed or expired reviews return be.storefront.checkoutChanged; show a fresh preview and obtain a new submit. Do not automatically resubmit an order after that response.

Invalidate the UI preview immediately when an address, quantity, currency, method, instrument, points selection or applied code changes. Ignore late responses for previous inputs and disable submit while recalculating. Do not derive the final total by adding shipping to cart.grandTotal: a gift card may cover shipping too.

discountTotal includes loyaltyDiscount and giftCardDeducted. These two fields are informational breakdowns; do not subtract them twice. loyaltyPointsUsed is the actual integer count after applying the program's percentage cap. roundingAdjustment is a signed amount. Financial reconciliation is:

subtotal + taxTotal + shippingTotal + paymentFee - discountTotal + roundingAdjustment = grandTotal

Here taxTotal is the goods VAT before the order discount, so the formula above reconciles the amounts shown line by line. The VAT the customer actually pays is vatTotal (SDK 2.12.0) with its per-rate split in taxBreakdown: the discount, always taken from the VAT-inclusive amount, lowers base and VAT per rate, and shipping and the payment fee carry the goods rates, exactly as on the invoice. Render the "VAT 21 %: X" rows from taxBreakdown, never from taxTotal.

Payment fees are returned in the requested currency in the payment picker, preview and receipt. cart.checkoutLimits similarly contains min/max order values in cart currency, compared against the goods subtotal before shipping and code discounts. Do not compare an EUR cart against raw CZK values from the shop's default settings.

Create Order

const {data: order, error} = await client.checkout.createOrder({
  previewToken: reviewedPreview.previewToken,
  email: '[email protected]',
  phone: '+420123456789',
  shippingAddress: {
    firstName: 'Jan',
    lastName: 'Novak',
    street: 'Vodickova 12',
    city: 'Praha',
    zip: '11000',
    country: 'CZ',
  },
  billingAddress: {
    firstName: 'Jan',
    lastName: 'Novak',
    street: 'Vodickova 12',
    city: 'Praha',
    zip: '11000',
    country: 'CZ',
  },
  customerNote: 'Ring the bell twice',
  termsConsent: form.termsConsent, // independent; required only by shop settings
  gdprConsent: form.gdprConsent,   // never pre-accept on behalf of the shopper
  newsletterOptIn: form.newsletterOptIn,     // optional, first-party newsletter opt-in
  smsConsent: form.smsConsent,          // optional, consent to marketing SMS
  pickupPointId: 'Z-12345',  // required when the shipping method supportsPickupPoints
  paymentMethodId: gopay.id,           // from catalog.listPaymentMethods()
  paymentInstrument: 'BANK_ACCOUNT',   // chosen instrument of that method (SDK 1.14.0)
  paymentSwift: 'FIOBCZPP',            // chosen bank, BANK_ACCOUNT only
  redeemLoyaltyPoints: 500,  // optional, redeems loyalty points
});

The response is SdkResult<CheckoutResult>. Check error before reading order. For guest checkout, orderAccessToken provides 30-minute read access to exactly the new order through orders.getByAccessToken. Store it in an HttpOnly, Secure cookie scoped to the receipt, or server-side. Never put it in the URL, logs, analytics, localStorage or page props. Authenticated checkout returns no guest access token.

Every checkout (guest or signed in) also returns trackingToken (SDK 2.7.0), the order's permanent secret, the same one used in the /track/{token} links of order e-mails and SMS. Store it in an HttpOnly, Secure cookie of the browser that placed the order and render the confirmation page with orders.track(trackingToken) when the visitor is not signed in. The page then keeps working after reloads and later visits, without a code and without an expiry. orders.track returns a PII-minimized view (status, items, totals, masked e-mail, city), so the storefront also needs a /track/[token] page for the links in e-mails and SMS. On the receipt show the persisted fee/discount/rounding fields, not the merchant's current payment fee. Historical orders have null financial snapshot fields; do not invent their values.

After a successful checkout:

  • Cart is automatically cleared
  • order:created and cart:cleared events are emitted
  • Cart session is reset

Localized shipping names

catalog.shipping.listMethods() and catalog.shipping.quote() return name and description in the locale the SDK sends (locale config or setLocale()), with the merchant's default language as fallback. Nothing to do in the template beyond keeping the SDK locale in sync with the page language.

Payment methods per country

listPaymentMethods({currency, country, shippingMethodId}) returns only the methods the merchant enabled for the delivery country, in the order set for that country, and hides cash on delivery when the chosen shipping method cannot collect it. Re-fetch when the shopper changes the country or the shipping method; the API enforces the same rules at checkout.

Pickup point map

When a shipping method with supportsPickupPoints has publicConfig.pickupWidget, the shopper can pick the branch on a map instead of a search list. Three shapes (SDK 2.16.0):

  • {provider: "packeta", apiKey}: the carrier's own widget.
// load once: <script src="https://widget.packeta.com/v6/www/js/library.js" />
Packeta.Widget.pick(pickupWidget.apiKey, (point) => {
  if (!point) return;
  setPickupPointId(String(point.id)); // same id as catalog.shipping.getPickupPoints()
  // The widget also serves partner points Behio may not have cached yet:
  // send a snapshot so the order and the admin still show the branch.
  setPickupPoint({name: point.name, street: point.street, city: point.city, zip: point.zip, country: point.country});
}, {country: "cz", language: "cs"});
  • {provider: "zaslat", carrier, territory}: the Zaslat map widget (docs.zaslat.cz/map_widget), no key needed. carrier is the Zaslat carrier code of the method ("ZASILKOVNA", "PPL", ...) or null for all carriers; territory is "cz" or "sk" when the method ships to exactly one of them, otherwise null and you take it from the delivery country. Load the script on the first click, not on page load:
type ZaslatPlace = {id: number; code?: string; name: string; street?: string; city: string; zip: string; country: string};
declare global {
  interface Window {
    zslt?: {openMap?: (config: Record<string, unknown>) => Promise<ZaslatPlace | undefined>};
  }
}

let zaslatScript: Promise<void> | null = null;
function loadZaslatMap(): Promise<void> {
  if (window.zslt?.openMap) return Promise.resolve();
  zaslatScript ??= new Promise<void>((resolve, reject) => {
    window.zslt = window.zslt ?? {};
    const s = document.createElement("script");
    s.id = "zaslat-map";
    s.async = true;
    s.src = "https://app.zaslat.cz/map/zaslat-map.umd.min.js";
    s.onload = () => resolve();
    s.onerror = () => {
      zaslatScript = null; // allow a retry
      reject(new Error("zaslat-map"));
    };
    document.head.appendChild(s);
  });
  return zaslatScript;
}

async function pickWithZaslat(widget: {carrier: string | null; territory: "cz" | "sk" | null}) {
  await loadZaslatMap(); // on failure show an error and keep the search list
  const country = shippingAddress.country.toLowerCase();
  const place = await window.zslt!.openMap!({
    addressType: "recipient",
    addressQuery: `${shippingAddress.zip} ${shippingAddress.city}`.trim(),
    carriers: widget.carrier ? [widget.carrier] : undefined,
    territory: widget.territory ?? (country === "sk" ? "sk" : "cz"),
    lang: locale === "cs" ? "cz" : locale === "sk" ? "sk" : "en",
    style: {colorPrimary: "#1f6f50"}, // optional, your accent colour
    value: selectedZaslatPlaceId ?? undefined, // preselect the current point (widget id)
  }).catch(() => undefined); // rejected = closed without a choice
  if (!place) return;
  selectedZaslatPlaceId = place.id;
  // Same id Behio stores for Zaslat points (getPickupPoints().externalId).
  setPickupPointId(place.code || String(place.id));
  // The widget lists more points than Behio has cached: always send the snapshot.
  setPickupPoint({name: place.name, street: place.street, city: place.city, zip: place.zip, country: place.country.toUpperCase()});
}
  • {provider: "behio"}: no carrier widget (PPL, GLS, Aramex), but every point from behio.shipping.getPickupPoints({methodId, query, country, limit}) carries latitude and longitude, so render your own map (the reference template uses Leaflet with OpenStreetMap tiles) and send the chosen externalId as checkout.pickupPointId.

Methods without pickupWidget keep the search list only. createOrder accepts an optional pickupPoint snapshot ({name, street?, city?, zip?, country?}) next to pickupPointId; it is used only when Behio's cache does not know the point. For uncached widget points, include country matching the delivery address. Cached point data takes precedence over the client snapshot. Checkout returns HTTP 400 with be.storefront.pickupPointCountryMismatch when the point's country is missing or differs from the delivery country. Clear the selected point after changing the country or shipping method and require a new choice.

A point that Behio's cache does not know is verified with the carrier before the order is created. An unknown point returns be.storefront.pickupPointNotFound (ask for another point). When the carrier does not respond, checkout returns be.storefront.pickupPointsUnavailable instead: keep the selection and offer a retry, the point itself may be valid. Packeta and Zaslat widget points outside Behio's cache are accepted from the snapshot (it needs name) and marked unverified on the order. errorMessage(err, locale) translates both keys (SDK 2.14.1).

Currency by delivery country

getShopInfo().currencyByCountry is a map like {SK: "EUR"} the merchant sets in Currencies and price lists. When the shopper picks such a country, switch the display currency and the cart (client.setCurrency + client.cart.setCurrency, or useCurrency().setCurrency in React) unless they chose a currency themselves. Prices in that currency come from the merchant's manual rows or the automatic FX price list, so the checkout can always complete in it.

Address per country

shippingAddress.state is accepted for countries that need it (US, CA, AU, BR, IN, MX). Validate the postal code per country and prefix the phone with the country code client-side; the API only checks presence and length.

Delivery countries

getShopInfo() returns shippingCountries: the union of the countries the merchant's enabled shipping methods deliver to. Build the country select from it so a shopper from an unserved country finds out before typing an address:

const {data: shop} = await behio.getShopInfo();
// null = ships anywhere (show your full country list), [] = no shipping method yet
const countries = shop.shippingCountries ?? ALL_COUNTRIES;

A checkout whose shippingAddress.country has no matching shipping method is rejected by the API, so the select is a courtesy, not the enforcement.

Checkout Input

FieldTypeRequiredDescription
emailstringYesCustomer email
phonestringNoCustomer phone
shippingAddressAddressYesShipping address
billingAddressAddressNoBilling (defaults to shipping)
customerNotestringNoNote from customer
termsConsentbooleanConditionalMust be true when the shop requires terms consent
gdprConsentbooleanConditionalMust be true when the shop requires GDPR consent
newsletterOptInbooleanNoSubscribe the email to the shop newsletter
digitalDeliveryConsentbooleanNoConsent to start delivering digital content before the withdrawal period ends (EU 2011/83 art. 16(m)). Render the checkbox when cart.requiresShipping === false. Stored on the order consent record, never blocks the order (SDK 2.13.0)
smsConsentbooleanNoConsent to marketing SMS; render only when checkout.collectSmsConsent
pickupPointIdstringConditionalPickup point externalId; required when the method supportsPickupPoints
paymentMethodIdstringConditionalChosen method id from catalog.listPaymentMethods(); required when the shop has payment methods
paymentInstrumentstringConditionalInstrument code of the chosen method (PAYMENT_CARD, GPAY, APPLE_PAY, BANK_ACCOUNT, ...); send it whenever the method lists instruments
paymentSwiftstringConditionalBank SWIFT from instruments[].swifts[]; send it with paymentInstrument: "BANK_ACCOUNT"
redeemLoyaltyPointsnumberNoPoints to redeem (1-1,000,000)

Payment methods and instruments

catalog.listPaymentMethods({ currency, locale }) returns the merchant's methods for the cart currency. Since SDK 1.14.0 every method carries instruments: PaymentInstrument[] | null:

  • null means the provider has no instrument choice (cash on delivery, plain bank transfer, Stripe hosted page). Render the method as before, send only paymentMethodId.
  • a non-empty array (GoPay today) means the gateway requires the customer to pick the concrete instrument ALREADY IN THE CHECKOUT. This is part of the GoPay certification: the payment is created with that instrument and the gateway opens straight on it, without a second choice screen.
interface PaymentInstrument {
  code: string;          // "PAYMENT_CARD" | "BANK_ACCOUNT" | "GPAY" | "APPLE_PAY" | "PAYPAL" | ...
  label: string;         // localized by the `locale` you requested
  image: string | null;  // small logo
  imageLarge: string | null;
  swifts: PaymentSwift[]; // banks, non-empty only for BANK_ACCOUNT
}

interface PaymentSwift {
  code: string;          // SWIFT/BIC, e.g. "FIOBCZPP"
  label: string;         // bank name
  image: string | null;  // bank logo
  online: boolean;       // instant online bank payment, show these first
}

Rules for the UI:

  1. Pass the page locale so the labels come back in the shopper's language.
  2. Under the SELECTED method, render the instruments as tiles (logo + label). Preselect the first one; for GoPay it is the card.
  3. When the selected instrument has swifts, render a bank grid below the tiles, online: true banks first. A bank must be chosen before submit.
  4. Send the chosen codes as paymentInstrument and paymentSwift. A combination the method does not offer returns 400 with be.storefront.paymentInstrumentInvalid; re-read the methods and let the customer pick again.
  5. On a phone (390 px) keep two tiles per row. Never scroll horizontally.
const {data} = await client.catalog.listPaymentMethods({currency: cart.currency, locale: "cs"});
const method = data.items.find((m) => m.id === selectedMethodId);
const instruments = method?.instruments ?? [];
const [instrument, setInstrument] = useState(instruments[0]?.code ?? "");
const [swift, setSwift] = useState("");
const banks = [...(instruments.find((i) => i.code === instrument)?.swifts ?? [])]
  .sort((a, b) => Number(b.online) - Number(a.online));

<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
  {instruments.map((i) => (
    <button key={i.code} type="button" aria-pressed={i.code === instrument}
      onClick={() => { setInstrument(i.code); setSwift(""); }}>
      {i.image ? <img src={i.image} alt="" /> : null}
      {i.label}
    </button>
  ))}
</div>
{banks.length > 0 ? (
  <div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
    {banks.map((b) => (
      <button key={b.code} type="button" aria-pressed={b.code === swift} onClick={() => setSwift(b.code)}>
        {b.image ? <img src={b.image} alt="" /> : null}
        {b.label}
      </button>
    ))}
  </div>
) : null}

await client.checkout.createOrder({
  ...input,
  paymentMethodId: method.id,
  ...(instruments.length > 0 ? {paymentInstrument: instrument} : {}),
  ...(banks.length > 0 ? {paymentSwift: swift} : {}),
});

The same two fields exist on catalog.purchaseGiftCard() for gift card orders. The reference implementation lives in apps/storefront-test (PaymentInstrumentPicker) and in storefront-core CheckoutForm.

Checkout Settings Contract

ShopInfo.checkout carries the full merchant-configured checkout contract. Read it once and render the checkout to match:

const { data: shop } = await client.getShopInfo();
const c = shop.checkout;
// c.requirePhone, c.requireTaxId, c.requireTermsConsent, c.requireGdprConsent,
// c.newsletterOptInDefault ('CHECKED' | 'UNCHECKED' | 'HIDDEN'),
// c.collectSmsConsent (boolean),
// c.allowOrderNote, c.allowDiscountCodes, c.minOrderValue, c.maxOrderValue,
// c.minItemsInCart, c.maxItemsPerProduct, c.freeShippingThreshold,
// c.stockBehavior, c.showLowStock, c.lowStockThreshold,
// c.cartReservationEnabled, c.cartReservationMinutes,
// c.addressAutocompleteEnabled

Cart reservations

checkout.cartReservationEnabled is true when the merchant turned on cart reservations and the shop tracks stock (stockMode: 'TRACKED'). checkout.cartReservationMinutes is the default length (0 when off); a product may override it, so the countdown always comes from the cart line's reservedUntil (epoch milliseconds, null = not reserved or expired), never from this number. Render "Reserved until 14:35" next to the line using the page locale and the shop time zone, and re-fetch the cart when it runs out, because expiry is lazy and nothing is pushed. Details and the error keys are in Cart management.

At checkout the shopper's own reservation counts as available and other carts' reservations are subtracted. If another cart holds the remaining pieces, createOrder and the preview return 400 be.storefront.stockReservedByOthers.

checkout.collectSmsConsent says whether the merchant collects consent to marketing SMS. Render an OPTIONAL, unchecked checkbox next to the newsletter one and send the answer as smsConsent. Rules that differ from the other consents:

  • Never required. Order updates are transactional messages and go out without any consent. Only marketing needs it, so the checkbox must never block the order button.
  • The phone comes from the order, not from the checkbox. Consent is stored against the order phone, falling back to the shipping address phone. When neither can be turned into an E.164 number, nothing is stored and the order goes through unchanged.
  • Sending smsConsent: true to a shop with collectSmsConsent: false stores nothing. The server only records consent the customer was actually shown.

The backend enforces these settings unconditionally. When requireTermsConsent or requireGdprConsent is on (an EU legal requirement), createOrder without termsConsent: true / gdprConsent: true returns 400. The same applies to requirePhone (phone), requireTaxId (companyId on the billing address; vatId stays optional because many companies are not VAT registered), minItemsInCart, maxItemsPerProduct and minOrderValue/maxOrderValue. Render the matching UI and send the fields.

Security

The checkout transaction validates and records its financial operations together:

  • Stock is deducted once across the shop's selected warehouses, including bundle components and offers which share an inventory item. Item locks serialize competing checkouts. HIDE/SHOW_SOLD_OUT reject quantities above stock. BACKORDER permits negative stock; a positive safetyStock limits that debt, while zero leaves backorders unlimited. The backend records the original deductions so cancellation restores only the outstanding units, even after warehouse settings change or a partial return has been received.
  • Gift cards use conditional balance check, so double-spend is blocked
  • Discount codes with usage limits are claimed atomically
  • Loyalty points are deducted inside the same transaction
  • Cart is verified non-empty inside the transaction

Since SDK 0.37.0 ShopInfo.checkout.stockMode (TRACKED | ALWAYS_AVAILABLE | MADE_TO_ORDER) tells you the shop's stock mode: non-TRACKED shops (resellers, made-to-order producers) are always purchasable regardless of stock, stockQuantity is null and availability derives to in-stock / on-order. Hide stock UI entirely for those shops.

Customer cancellation requires an authenticated owner and an order which is both PENDING and UNPAID. Hide the action for other states and handle a rejection if payment or fulfillment changes while the page is open. Repeated cancellation of an already cancelled order does not restore stock again. The allocation ledger is internal and is never part of the public order response.

Guest checkout may be disabled by the shop admin. If allowGuestCheckout is false, customers must register before checking out.

Events

client.on('order:created', (order) => {
  router.push(`/thank-you?order=${order.orderNumber}`);
});

Merchant presentation and account rules

ShopInfo.checkout provides checkoutLayout (SINGLE_PAGE / STEP_BY_STEP), showCartPriceBreakdown, showEstimatedDelivery and plain-text orderConfirmationText. Keep previous step fields mounted or save a bounded, expiring draft bound to the cart; never persist payment credentials or consent defaults in that draft. Legal checkboxes are independent and required only when their matching flags are enabled. The preview does not require consent; creating the order does.

allowGuestCheckout and requireAccountForCheckout are enforced on the server. Preserve the cart and a safe same-origin return path through login. ShopInfo.account exposes passwordMinLength and requireEmailVerification; render registration, reset and password change consistently. Registration can independently require admin approval and email verification, including both at once. Inactive/unverified accounts must not receive access under the corresponding policy.

Disabled coupon/gift-card settings reject new code applications and remove the price effect of previously applied codes. A stale final preview then requires a new review. Loyalty redemption enforces the active program's currency, minimum, percentage cap and combineWithDiscounts; invalid requests are rejected instead of silently ignored.

Online-only carts

Use cart.requiresShipping to decide whether to collect delivery details. When it is false, send the actual billingAddress and omit shippingAddress, shippingMethodId, shippingQuoteId and pickup fields. Hide COD. Mixed carts and bundles containing physical products require delivery. An attached PDF does not automatically remove shipping; the merchant explicitly configures each product. See merchant settings.

On this page