E-commerce Store
Build a cart-first storefront with products, quotes, checkout, and payments
Arky storefronts are cart-first: the cart lives on the server, the quote says what checkout would create, and checkout turns the cart into one order. The browser keeps no checkout model of its own.
Store Setup
import { initialize } from "arky-sdk/storefront";
import { createOrderToken } from "arky-sdk/utils";
export const arky = initialize(import.meta.env.PUBLIC_ARKY_PUBLISHABLE_KEY, {
locale: "en",
market: "eu",
createVisitorIdentity: () => ({
customer_id: crypto.randomUUID(),
session_id: crypto.randomUUID(),
}),
createSessionId: () => crypto.randomUUID(),
createId: () => crypto.randomUUID(),
createOrderToken,
});
arky.pageViews.send({ path: location.pathname, referrer: document.referrer });
const { items: [catalog] } = await arky.eshop.catalog.find();
const cart = arky.eshop.cart({ buyer: { type: "customer" }, catalog_id: catalog.id });
await cart.load();
- The language and market are explicit, and the publishable key decides the sales channel. A channel with one market implies the market; otherwise send it.
catalog.findlists the open catalogs of the market where the buyer has any level. Every product read and every cart names its catalog: Arky never picks or merges catalogs, and there is no fallback catalog.loadreads the cart the browser selected for this customer, buyer and catalog, ornull. Public catalog reads create no visitor session; the first stateful call does.- Every id a call leaves out comes from your
createId: carts, lines, deliveries and orders. arky.eshop.cart({ buyer, catalog_id })answers one cart store per buyer and catalog, the same one on every call. A company or company location buys from its own cart, so a customer cart and a company cart live side by side.
Product Listing
import { formatPrice } from "arky-sdk/storefront";
const { items: products, cursor } = await arky.eshop.product.find({
catalog_id: catalog.id,
limit: 24,
include_price: true,
});
const fromPrice = formatPrice(products[0].price, "en");
A product card shows the lowest quantity-one price among its priced variants. A null price is
never zero: the product is listed without a price (on request), or the buyer may browse the catalog
but not see its prices. A partner portal passes its own catalog’s id the same way.
Product Detail And Adding To The Cart
const product = await arky.eshop.product.get({ slug: "linen-shirt", catalog_id: catalog.id, include_price: true });
const variants = await arky.eshop.productVariant.find({
product_id: product.id,
catalog_id: catalog.id,
include_price: true,
limit: 20,
});
await cart.ensure();
await cart.addProduct({
variant_id: variants.items[0].id,
quantity: 1,
purchase: { type: "catalog" },
});
- A product lists its options (such as size and colour) and each variant names one value of each. Show the options as choices and add the variant whose values match.
ensureloads the selected cart, or creates one under an id from yourcreateId; a lost answer is retried under the same cart id.- A product line names only its variant. Its id comes from your
createId; passidto pick it yourself, and a retried add with the same id doesn’t add a second line. - A slug is read in the request’s language; link pages by slug or id, never by key.
Cart UI
The cart store exposes nanostores atoms, so React, Svelte, Vue, Astro islands or plain browser code can subscribe without an adapter:
const unsubscribe = cart.item_count.subscribe((count) => renderCount(count));
const current = cart.cart.get();
const status = cart.status.get();
await cart.setProductQuantity({ line_item_id: lineId, quantity: 2 });
await cart.removeItem({ line_item_id: lineId });
await cart.setPromotionCodes({ promotion_codes: ["SUMMER20"] });
status holds loading, syncing, fetching_quote, processing_checkout, error and
quote_error, where an error is { code, message }: show your own text for each code.
Deliveries And The Quote
Physical items travel in delivery groups: each group has its items, a destination (a delivery address or a pickup location), its shipping and its timing.
await cart.shipTo({
destination: {
type: "delivery",
address: {
name: "Ana K.",
company: null,
street1: "Ferhadija 12",
street2: null,
city: "Sarajevo",
state: null,
postal_code: "71000",
country: "BA",
phone: null,
email: null,
},
},
});
const first = await cart.quote();
const offer = first.deliveries[0]?.offers[0];
if (!offer) throw new Error("No shipping method serves this address");
await cart.selectShippingMethod({ shipping_method_id: offer.shipping_method_id });
const quote = await cart.quote();
console.log(quote.totals.total, quote.ready);
shipTosends every shipped product line to one destination. It makes one delivery per shipping profile, because one delivery can’t mix profiles, keeps each delivery’s id and timing, and keeps its shipping choice only while the address stays the same. New delivery ids come from yourcreateId. To split the items or time the deliveries yourself, set the groups withsetDeliveryGroups.selectShippingMethodsets one method on every delivery; each delivery still gets its own rate.- Each offer names its shipping method with
shipping_method_id,shipping_method_keyand the method’sshipping_method_blocks, so the site shows the method’s own name next to the amount. - A rate that needs a weight Arky doesn’t know is left out of the delivery’s
offersand listed in itsunavailablewith the codeSHIPPING.WEIGHT_UNKNOWN; the other rates stay. - The quote is in the client’s language and holds the lines with their snapshots, the deliveries
with their offers, promotions, taxes, totals, the market’s payment options in order and a
suggested one, and a
presentation_digest. readyis false while something blocks checkout, andblockerssays what: a delivery without shipping, a selected rate that became unavailable, an unavailable booking, a missing billing address or purchase order number, and so on.
Money And Tax
Totals come from the quote. Never recalculate tax in the browser.
- The market’s tax mode says whether prices already contain tax (
inclusive) or tax is added on top (exclusive). - Each line’s tax category and the address’s zone pick the tax rule. A category with no rule for
the place can’t be sold there: the quote is refused with
409 TAX.NO_RULEinstead of guessing. - Promotions are discounts, never stored value. Money is integer minor units.
Checkout
The checkout flow runs a checkout page on the server cart, one step at a time:
import { mountCheckoutAction } from "arky-sdk/storefront";
const checkout = arky.eshop.checkoutFlow({ buyer: { type: "customer" }, catalog_id: catalog.id });
checkout.state.subscribe((state) => console.log(state.step, state.quote?.blockers, state.error?.code));
let state = await checkout.open();
checkout.setContact({ contact_email: "ana@example.com" });
state = await checkout.shipTo({
destination: {
type: "delivery",
address: {
name: "Ana K.",
company: null,
street1: "Ferhadija 12",
street2: null,
city: "Sarajevo",
state: null,
postal_code: "71000",
country: "BA",
phone: null,
email: "ana@example.com",
},
},
});
for (const delivery of state.quote?.deliveries ?? []) {
const [offer] = delivery.offers;
if (offer) {
state = await checkout.selectShipping({
delivery_group_id: delivery.delivery_group_id,
shipping_method_id: offer.shipping_method_id,
rate_id: offer.rate_id,
});
}
}
if (state.step === "payment") {
state = await checkout.place({ return_url: (orderId) => `${location.origin}/order/${orderId}` });
}
const placed = state.placed;
if (state.step === "paying" && placed && placed.payment_action.type !== "none") {
await mountCheckoutAction({ action: placed.payment_action, payment_id: placed.payment_id, element: "#payment" });
}
checkoutFlow({ buyer, catalog_id })answers the same flow for the same buyer and catalog. Itscartis the cart storeeshop.cartanswers for them, andopen()loads the cart the browser selected there and quotes it.stateis a nanostores atom. Itsstepiscart(no lines, or a line that can’t be bought as it is, such as a taken booking slot or a purchase limit),details(the contact email, a delivery, a billing address or a purchase order number is missing),shipping(a delivery needs its shipping choice),payment(ready to place),paying(a placed order waits for its payment, or a kept checkout waits forrecover()) orplaced(paid, to pay later, unconfirmed or free).- After
setContact, every quote sends the contact email, so a guest can use a promotion code limited to one use per customer. placetakes the order id and token from your factories. At a total of zero it pays withfree; otherwise it uses the chosen payment option (the quote’s suggestion untilselectPaymentOptionpicks another) with itsreturn_url. It keeps the request before sending it, then reads the payment with the kept order token. Mountstate.placed.payment_actionwithmountCheckoutAction.- When the answer is lost, the step is
payingwithstate.pendingset: callrecover(). Until thenplaceis refused, so one checkout never makes a second order. - Every step throws a
CheckoutFlowErrorand also records it instate.error. Itscodeisbusy,not_ready,pending,presentation_changed(the new quote is shown),refused,interrupted,unavailableorfailed.
After a payment provider redirects back, openOrder reopens the order from the link and answers
the link without its order token:
const orderId = new URLSearchParams(location.search).get("order_id");
if (orderId) {
const opened = await checkout.openOrder({ order_id: orderId, url: location.href });
history.replaceState(history.state, "", opened.url);
console.log(opened.state.outcome);
}
outcome is paid, pay_later, awaiting, unconfirmed (the provider’s answer needs checking)
or failed; paymentOutcome(payment) in arky-sdk/storefront reads the same from a payment.
Checkout Without The Flow
import { mountCheckoutAction } from "arky-sdk/storefront";
const quote = await cart.quote();
const result = await cart.checkout({
contact_email: "ana@example.com",
payment: {
type: "payment_option",
payment_option_id: quote.suggested_payment_option_id!,
return_url: window.location.href,
save_payment_method: false,
payment_method_terms: null,
},
});
if (result.type === "placed" && result.payment_action.type !== "none" && result.payment_id) {
const mounted = await mountCheckoutAction({
action: result.payment_action,
payment_id: result.payment_id,
element: "#payment",
});
}
- The answer is
placed, with the order’sorder_id,number,payment_idandpayment_action, oralready_memberwith thecustomer_group_member_idwhen the cart is a free sign-up for a group the buyer already belongs to. Then no order is made. - The order’s id and its
order_tokencome from yourcreateIdandcreateOrderToken, or pass both yourself. The token is the order’s secret: it opens that one order on any device, as the link in the order email does. - Without ids, checkout keeps the request across reloads before sending it. The same checkout of
the same cart, version and payment recovers the kept one, and a different checkout is refused
until
recoverCheckout()settles it. With both ids given, the request is sent as is: callretainCheckoutbefore posting andrecoverCheckouton the next load. A definite refusal (any4xxexcept408and429) drops the kept request. - The order’s language is the client’s language (
X-Arky-Locale); checkout takes nolanguage. paymentis{ type: "free" }exactly when the total is zero, a payment option of the market, oron_accountfor a company or company location whose purchasing policy is on account.- To save the card for later payments, set
save_payment_methodand send, inpayment_method_terms, the exactsaved_card_termstext of the option that the buyer accepted in the order’s language. - If the quote changed since the buyer saw it, checkout is refused with the new quote
(
CartPresentationChangedError); show it and ask again. - A card checkout returns a Stripe embedded checkout or a Monri card form to mount. Card lines stay
pendingfor 30 minutes until the payment is paid; the payment’s signed provider events, not the browser, confirm it. Cash on delivery and manual options confirm the order at once and record the money later.
The setup’s payment options carry their key, blocks and type. Show each option’s own text
from its blocks; account details and credentials never reach the storefront.
const setup = await arky.store.load();
const options = setup.payment_options.filter((option) => quote.payment_option_ids.includes(option.id));
After Checkout
if (result.type === "placed") {
const token = arky.eshop.order.retainedToken({ order_id: result.order_id });
const access = token ? { order_token: token } : undefined;
const order = await arky.eshop.order.get({ id: result.order_id }, access);
const deliveries = await arky.eshop.order.findFulfillments({ order_id: order.id }, access);
}
- A signed-in customer reads their orders with their session; a guest passes the order’s token as
the
order_tokenrequest option.retainedToken({ order_id })answers the token kept at checkout in this browser. eshop.order.fromLink({ id, url })opens an order link: it reads the order with the link’s#order_token, keeps the token in this browser, and answers the link without it forhistory.replaceState. A link without a token falls back to the token kept at checkout, then to the session.- The order’s status is computed from its lines:
pending,confirmed,partially_cancelledorcancelled. - Checkout emails the store’s
order_receivedwording to the contact email in the order’s language, with the order’s link. A free sign-up gets no receipt.
Mixed Carts
Products, booking lines and customer groups share one cart:
const calendar = arky.eshop.bookingCalendar({ buyer: { type: "customer" }, catalog_id: catalog.id });
await calendar.select({ booking_service_id: service.id });
calendar.selectFirstAvailableDate();
const { slots } = calendar.state.get();
calendar.selectSlot({ slot: slots[0] });
await calendar.addToCart({ slot: slots[0] });
addToCart adds to the cart store of the calendar’s buyer and catalog, the same one eshop.cart
and checkoutFlow answer for them, so the checkout sees the booking line. The line ids come from
your createId.
Storefront Rules
- Use
initializeas the storefront entry point and the server cart as the source of truth. - Let
createIdpick the ids, and send the same request again to retry it: the SDK reuses the kept ids. An id you pick yourself is picked once per user action and resent on retry. - Use the quote before showing totals, and the checkout flow or
checkoutto create orders.