Back to Arky

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.find lists 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.
  • load reads the cart the browser selected for this customer, buyer and catalog, or null. 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.
  • ensure loads the selected cart, or creates one under an id from your createId; a lost answer is retried under the same cart id.
  • A product line names only its variant. Its id comes from your createId; pass id to 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);
  • shipTo sends 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 your createId. To split the items or time the deliveries yourself, set the groups with setDeliveryGroups.
  • selectShippingMethod sets one method on every delivery; each delivery still gets its own rate.
  • Each offer names its shipping method with shipping_method_id, shipping_method_key and the method’s shipping_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 offers and listed in its unavailable with the code SHIPPING.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.
  • ready is false while something blocks checkout, and blockers says 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_RULE instead 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. Its cart is the cart store eshop.cart answers for them, and open() loads the cart the browser selected there and quotes it.
  • state is a nanostores atom. Its step is cart (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 for recover()) or placed (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.
  • place takes the order id and token from your factories. At a total of zero it pays with free; otherwise it uses the chosen payment option (the quote’s suggestion until selectPaymentOption picks another) with its return_url. It keeps the request before sending it, then reads the payment with the kept order token. Mount state.placed.payment_action with mountCheckoutAction.
  • When the answer is lost, the step is paying with state.pending set: call recover(). Until then place is refused, so one checkout never makes a second order.
  • Every step throws a CheckoutFlowError and also records it in state.error. Its code is busy, not_ready, pending, presentation_changed (the new quote is shown), refused, interrupted, unavailable or failed.

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’s order_id, number, payment_id and payment_action, or already_member with the customer_group_member_id when 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_token come from your createId and createOrderToken, 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: call retainCheckout before posting and recoverCheckout on the next load. A definite refusal (any 4xx except 408 and 429) drops the kept request.
  • The order’s language is the client’s language (X-Arky-Locale); checkout takes no language.
  • payment is { type: "free" } exactly when the total is zero, a payment option of the market, or on_account for a company or company location whose purchasing policy is on account.
  • To save the card for later payments, set save_payment_method and send, in payment_method_terms, the exact saved_card_terms text 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 pending for 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.
Note

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_token request 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 for history.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_cancelled or cancelled.
  • Checkout emails the store’s order_received wording 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 initialize as the storefront entry point and the server cart as the source of truth.
  • Let createId pick 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 checkout to create orders.