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 = grandTotalHere 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:createdandcart:clearedevents 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.carrieris the Zaslat carrier code of the method ("ZASILKOVNA","PPL", ...) ornullfor all carriers;territoryis"cz"or"sk"when the method ships to exactly one of them, otherwisenulland 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 frombehio.shipping.getPickupPoints({methodId, query, country, limit})carrieslatitudeandlongitude, so render your own map (the reference template uses Leaflet with OpenStreetMap tiles) and send the chosenexternalIdascheckout.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
| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Customer email |
phone | string | No | Customer phone |
shippingAddress | Address | Yes | Shipping address |
billingAddress | Address | No | Billing (defaults to shipping) |
customerNote | string | No | Note from customer |
termsConsent | boolean | Conditional | Must be true when the shop requires terms consent |
gdprConsent | boolean | Conditional | Must be true when the shop requires GDPR consent |
newsletterOptIn | boolean | No | Subscribe the email to the shop newsletter |
digitalDeliveryConsent | boolean | No | Consent 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) |
smsConsent | boolean | No | Consent to marketing SMS; render only when checkout.collectSmsConsent |
pickupPointId | string | Conditional | Pickup point externalId; required when the method supportsPickupPoints |
paymentMethodId | string | Conditional | Chosen method id from catalog.listPaymentMethods(); required when the shop has payment methods |
paymentInstrument | string | Conditional | Instrument code of the chosen method (PAYMENT_CARD, GPAY, APPLE_PAY, BANK_ACCOUNT, ...); send it whenever the method lists instruments |
paymentSwift | string | Conditional | Bank SWIFT from instruments[].swifts[]; send it with paymentInstrument: "BANK_ACCOUNT" |
redeemLoyaltyPoints | number | No | Points 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:
nullmeans the provider has no instrument choice (cash on delivery, plain bank transfer, Stripe hosted page). Render the method as before, send onlypaymentMethodId.- 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:
- Pass the page
localeso the labels come back in the shopper's language. - Under the SELECTED method, render the instruments as tiles (logo + label). Preselect the first one; for GoPay it is the card.
- When the selected instrument has
swifts, render a bank grid below the tiles,online: truebanks first. A bank must be chosen before submit. - Send the chosen codes as
paymentInstrumentandpaymentSwift. A combination the method does not offer returns 400 withbe.storefront.paymentInstrumentInvalid; re-read the methods and let the customer pick again. - 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.addressAutocompleteEnabledCart 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.
Marketing SMS consent
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: trueto a shop withcollectSmsConsent: falsestores 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_OUTreject quantities above stock.BACKORDERpermits negative stock; a positivesafetyStocklimits 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.