Back to Arky

E-shop

Products, bookings, catalogs and prices, carts and checkout, orders, payments, returns, rentals, inventory, and companies

Commerce in Arky runs through one path: a buyer shops through a catalog, fills a cart, and checkout turns it into one order with its payment. Product variants, booking offerings and customer groups are all sellables in that path. Products, booking services, booking resources and customer groups have a key and blocks, never a separate name or title: the name a buyer sees is a block.

Every create on this page takes the id the app picks; every change carries the expected_updated_at it read. A refusal names its reason in a code such as 409 PRICE.OVERLAP; only 409 GENERAL.CONFLICT means “changed since you read it, reload”. See Core Concepts and Errors.

Products And Variants

import type { EpochMilliseconds } from "arky-sdk";

interface Product {
	id: string;
	store_id: string;
	key: string;
	slugs: Record<string, string>;
	blocks: Block[];
	options: { id: string; key: string; label: LocalizedText; values: { key: string; label: LocalizedText }[] }[];
	categories: CategoryEntry[];
	status: { type: "active" } | { type: "draft" } | { type: "archived" } | { type: "deleting" };
	created_at: EpochMilliseconds;
	updated_at: EpochMilliseconds;
}

interface ProductVariant {
	id: string;
	store_id: string;
	product_id: string;
	sku: string | null;
	blocks: Block[];
	options: { option_id: string; value_key: string }[];
	options_valid: boolean;
	fulfillment:
		| { type: "none" }
		| {
				type: "physical";
				shipping_profile_id: string;
				inventory_requirements: { inventory_item_id: string; quantity: number }[];
				backorder: { type: "disallow" } | { type: "allow" };
		  }
		| { type: "digital"; asset_ids: string[] };
	tax_category_id: string;
	status: { type: "active" } | { type: "draft" } | { type: "archived" } | { type: "deleting" };
	created_at: EpochMilliseconds;
	updated_at: EpochMilliseconds;
}
POST /v1/stores/{store_id}/products
SDK: sdk.eshop.product.create()
POST /v1/stores/{store_id}/product-variants
SDK: sdk.eshop.productVariant.create()
POST /v1/stores/{store_id}/digital-assets
SDK: sdk.eshop.digitalAsset.upload()
POST /v1/stores/{store_id}/digital-assets/{id}/archive
SDK: sdk.eshop.digitalAsset.archive()
const product = await sdk.eshop.product.create({
	store_id: storeId,
	id: crypto.randomUUID(),
	key: "linen-shirt",
	slugs: { en: "linen-shirt" },
	blocks: [{ id: crypto.randomUUID(), key: "name", type: "localized_text", value: { en: "Linen shirt" } }],
	options: [
		{
			id: crypto.randomUUID(),
			key: "size",
			label: { en: "Size" },
			values: [
				{ key: "m", label: { en: "M" } },
				{ key: "l", label: { en: "L" } },
			],
		},
	],
	categories: [],
});

const variant = await sdk.eshop.productVariant.create({
	store_id: storeId,
	id: crypto.randomUUID(),
	product_id: product.id,
	sku: "SHIRT-M",
	blocks: [],
	options: [{ option_id: product.options[0].id, value_key: "m" }],
	fulfillment: {
		type: "physical",
		shipping_profile_id: standardProfile.id,
		inventory_requirements: [{ inventory_item_id: shirtItem.id, quantity: 1 }],
		backorder: { type: "disallow" },
	},
	tax_category_id: standardTax.id,
});
  • Options belong to the product. It lists them in display order, each with a label and its values; option keys and value keys never change. Every variant that isn’t archived names one value of each option, and no two name the same values (409 PRODUCT_VARIANT.OPTIONS_TAKEN); a product without options has at most one such variant. A variant’s own pictures and texts are its blocks.
  • Option edits are always allowed. A variant that no longer fits, because a value was removed or another variant now names the same values, reads options_valid: false, is left out of storefront reads and sales, and its next save must fix its choices.
  • The product key is unique in the store (409 PRODUCT.KEY_TAKEN) and its slugs per store and language (409 PRODUCT.SLUG_TAKEN).
  • A variant is physical (shipped from stock, with a shipping profile), digital (files the buyer downloads from their library) or neither (a service or membership sold as a product). Every variant names a tax category. Checkout freezes each option’s key with the chosen value’s label in the order’s language.
  • A digital variant lists 1 to 100 digital assets: private files with their size and sha256, uploaded with an id the app picks. An asset is archived rather than deleted; archiving is refused while a variant still lists it, and past buyers keep their files.
  • Deleting a product is refused while anything but its own variants, catalog items, prices and carts points at it, such as an order line; archive a sold product instead. A deleted product and its variants read deleting until cleanup has removed them. A variant on its own is refused while anything but its prices and carts points at it.

Storefront reads name the catalog and return the price the buyer would pay:

GET /v1/storefront/catalogs
SDK: arky.eshop.catalog.find()
GET /v1/storefront/products
SDK: arky.eshop.product.find()
GET /v1/storefront/products/{id_or_slug}
SDK: arky.eshop.product.get()
GET /v1/storefront/products/by-key/{key}
SDK: arky.eshop.product.getByKey()
const { items: [catalog] } = await arky.eshop.catalog.find();
const { items } = await arky.eshop.product.find({ catalog_id: catalog.id, limit: 20 });
const variants = await arky.eshop.productVariant.find({ catalog_id: catalog.id, product_id: items[0].id });
const shirt = await arky.eshop.product.get({ slug: "linen-shirt", catalog_id: catalog.id });
  • catalog.find lists the open catalogs of the scope’s market in which the buyer has any level; which one a page uses is up to the storefront. Every product read names its catalog_id; Arky never picks or merges catalogs.
  • get reads a canonical UUID as the id and anything else as a slug in the request’s language; a miss is 404 and is never retried as a key. getByKey reads the key. Link pages by slug or id, never by key.

Bookings

A booking service is what is booked (a haircut, a fitting) and sets what every booking of it is: its segments, buffers, slot interval, booking window, how long before the start a customer may still change it, its reminders and its tax category. A booking resource is who or what does it (a stylist, a room), with its timezone, capacity and working hours. A booking offering says one resource provides one service, when and where.

POST /v1/stores/{store_id}/booking-services
SDK: sdk.eshop.bookingService.create()
POST /v1/stores/{store_id}/booking-resources
SDK: sdk.eshop.bookingResource.create()
POST /v1/stores/{store_id}/booking-offerings
SDK: sdk.eshop.bookingOffering.create()
GET /v1/storefront/booking-services/availability
SDK: arky.eshop.bookingService.getAvailability()
const haircut = await sdk.eshop.bookingService.create({
	store_id: storeId,
	id: crypto.randomUUID(),
	key: "haircut",
	slugs: { en: "haircut" },
	blocks: [],
	categories: [],
	segments: [{ type: "service", minutes: 45 }],
	buffer_before_minutes: 0,
	buffer_after_minutes: 15,
	slot_interval_minutes: 15,
	booking_window: { opens_before_start_minutes: null, closes_before_start_minutes: 120 },
	customer_changes: { type: "until_before_start", minutes: 1440 },
	reminder_offsets_minutes: [1440],
	tax_category_id: servicesTax.id,
});

const stylist = await sdk.eshop.bookingResource.create({
	store_id: storeId,
	id: crypto.randomUUID(),
	key: "lena",
	slugs: { en: "lena" },
	blocks: [],
	categories: [],
	timezone: "Europe/Berlin",
	capacity: 1,
	hours: {
		weekly_availability: {
			monday: [{ from_minute: 540, to_minute: 1020 }],
			tuesday: [{ from_minute: 540, to_minute: 1020 }],
		},
		date_overrides: { "2026-12-24": [] },
	},
});

await sdk.eshop.bookingOffering.create({
	store_id: storeId,
	id: crypto.randomUUID(),
	booking_service_id: haircut.id,
	booking_resource_id: stylist.id,
	hours: { type: "resource" },
	venue: { type: "store_location", store_location_id: salon.id },
});
  • Hours are minutes of the day in the resource’s timezone; date_overrides replaces a day’s hours by its calendar day (YYYY-MM-DD), and an empty list closes it, for every offering of the resource. An offering books inside its resource’s hours (resource) or only where its own hours overlap them (within).
  • Buffers hold the resource before and after the appointment but aren’t part of it. Edits to segments, buffers or hours affect only later bookings.
  • The venue always names a store location: where the customer comes (store_location), or where the service is supplied from (not_fixed). Booking tax uses that location’s address.
  • A service’s key is unique in the store (409 BOOKING_SERVICE.KEY_TAKEN), and so are its slugs (409 BOOKING_SERVICE.SLUG_TAKEN); a resource’s key is unique too (409 BOOKING_RESOURCE.KEY_TAKEN), and one offering links a service and a resource (409 BOOKING_OFFERING.PAIR_TAKEN for a second).
  • A resource’s capacity can’t be cut below the bookings that still hold it (409 BOOKING_RESOURCE.CAPACITY_IN_USE), and its timezone can’t change while a booking still to end, or any booking on today’s local date, holds it (409 BOOKING_RESOURCE.TIMEZONE_IN_USE). Bookings that have ended never block a change. Until a day the resource keeps in its earlier timezone has passed, a slot whose time reaches that day shows no spots and can’t be sold (ORDER.BOOKING_TIMEZONE_CHANGED).
  • Availability answers each resource’s days with slots and the spots left, for at most 60 days per request. A booking is a booking line on the cart and on the order; there is no separate booking record.
  • reminder_offsets_minutes schedules the booking_reminder email before the booking. A reminder goes only while its booking is confirmed and upcoming, and only for the start it was planned for, so reminders follow a moved booking.

See Scheduled Services for the storefront flow.

Catalogs, Access And Prices

A catalog is a price book for one market, such as public-hr (the first catalog a store gets is public- and its first market’s key), “Trade partners HR” or “Christmas 2026”. Catalog items list what is in it and in which order, catalog access says who may use it, in which channels, and whether they browse, see prices or buy, and prices say what each sellable costs there.

POST /v1/stores/{store_id}/catalogs
SDK: sdk.eshop.catalog.create()
POST /v1/stores/{store_id}/catalogs/{id}/copy
SDK: sdk.eshop.catalog.copy()
POST /v1/stores/{store_id}/catalog-items
SDK: sdk.eshop.catalogItem.create()
POST /v1/stores/{store_id}/catalog-accesses
SDK: sdk.eshop.catalogAccess.create()
POST /v1/stores/{store_id}/prices
SDK: sdk.eshop.price.create()
POST /v1/stores/{store_id}/prices/batch
SDK: sdk.eshop.price.batch()
const partners = await sdk.eshop.catalog.create({
	store_id: storeId,
	id: crypto.randomUUID(),
	key: "trade-partners-hr",
	market_id: croatiaMarket.id,
	schedule: { type: "always" },
	status: { type: "active" },
});

await sdk.eshop.catalogAccess.create({
	store_id: storeId,
	id: crypto.randomUUID(),
	catalog_id: partners.id,
	audience: { type: "all_companies" },
	channels: { type: "only", sales_channel_ids: [b2bChannel.id] },
	level: { type: "buy" },
});

await sdk.eshop.price.create({
	store_id: storeId,
	id: crypto.randomUUID(),
	catalog_id: partners.id,
	sellable: { type: "product_variant", variant_id: variant.id },
	amount: 2100,
	compare_at: null,
	min_quantity: 24,
	max_quantity: null,
	schedule: { type: "always" },
	status: { type: "active" },
});
  • A sellable is a product_variant, a booking_offering or a customer_group, named by its own id only. A catalog item lists a product, a booking_service, a customer_group or a customer_group_offering, always with a position; a new item goes to the end, and a price is created only for something the catalog lists (409 PRICE.NOT_LISTED).
  • A price is always, or scheduled with a start and an optional end; a scheduled tier in force beats the always one. Quantity tiers come from min_quantity and max_quantity: always tiers of one sellable can’t overlap in quantity, nor can scheduled tiers whose times overlap (409 PRICE.OVERLAP). Deleting a price is refused while an order line, a member’s terms or an offer names it; archive it instead.
  • Audiences are everyone, a customer_group, a customer_group_offering (members of any of its groups), a customer, all_companies, a company or a company_location; channels are all or only some; levels are browse, see_prices and buy. A buyer’s level is the highest of the rows that match them.
  • A catalog has its own schedule; outside it no cart can use it. The catalog key public-… means nothing special.
  • copy adds another catalog’s listings, after this catalog’s own in the source’s order, and their prices, when both markets share currency and tax mode. It skips what is already there, prices of items the source no longer lists and of variants that no longer fit their options, and never copies access rows, so it can run again and creates nothing new.

Carts And Checkout

POST /v1/storefront/carts
SDK: arky.eshop.cart(scope).create()
POST /v1/storefront/carts/{id}/product-items
SDK: arky.eshop.cart(scope).addProduct()
POST /v1/storefront/carts/{id}/quote
SDK: arky.eshop.cart(scope).quote()
POST /v1/storefront/carts/accept
SDK: arky.eshop.cart(scope).checkout()
import { initialize } from "arky-sdk/storefront";
import { createOrderToken } from "arky-sdk/utils";

const arky = initialize("arky_pk_...", {
	locale: "hr",
	market: "hr",
	createVisitorIdentity: () => ({
		customer_id: crypto.randomUUID(),
		session_id: crypto.randomUUID(),
	}),
	createSessionId: () => crypto.randomUUID(),
	createId: () => crypto.randomUUID(),
	createOrderToken,
});

const cart = arky.eshop.cart({ buyer: { type: "customer" }, catalog_id: catalog.id });
await cart.ensure();

await cart.addProduct({
	variant_id: variant.id,
	quantity: 2,
	purchase: { type: "catalog" },
});

await cart.selectShippingMethod({ shipping_method_id: courier.id });
const quote = await cart.quote();

const accepted = 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,
	},
});
  • A storefront cart sells through its publishable key’s sales channel and names its catalog; there is no fallback catalog.
  • Every line carries its own id, from the app’s createId unless it passes one. Lines are product, booking and customer_group; a product line names only its variant. A product line’s purchase is catalog, or one of the customer group routes to member prices: existing_purchase_access (an access grant the buyer’s order gave), same_cart_purchase_access (a group joined in this cart) or assigned_purchase_access (a member the team assigned, with its entitlement).
  • The quote is what checkout would create: lines with snapshots, deliveries with their shipping offers, promotions, taxes and totals, the payment options and a presentation_digest. It is in the client’s language. ready is false while something blocks checkout; blockers says what. A shipping rate that needs a weight Arky doesn’t know is left out of a delivery’s offers and listed in its unavailable with the code SHIPPING.WEIGHT_UNKNOWN, and a selected one becomes a shipping_rate_unavailable blocker; the other rates stay. A booking line that can’t be sold reads availability: { type: "unavailable", code }, with a booking_unavailable blocker carrying the same code (Scheduled Services).
  • Checkout carries the order_id and order_token (the SDK takes them from the app’s createId and createOrderToken unless you pass both), the cart’s expected_updated_at, the quote’s presentation_digest, the contact email, and how the order is paid: free (exactly when the total is zero), a payment_option, or on_account for a company or company location whose purchasing policy is on account. To save a card for later, set save_payment_method and send the exact saved_card_terms wording the buyer saw in payment_method_terms. The order’s language is the client’s language (X-Arky-Locale), as for the quote. A changed quote is refused with 409 COMMERCE.PRESENTATION_CHANGED and the new quote, which the SDK raises as CartPresentationChangedError.
  • The answer is { type: "placed", order_id, number, payment_id, payment_action }, or { type: "already_member", customer_group_member_id } when the cart is a free sign-up for a group the buyer already belongs to: no order is made. The payment action says what the buyer does next: nothing, or a Stripe embedded checkout or Monri card form to mount. mountCheckoutAction in arky-sdk/storefront mounts it.
  • Retries: without order_id and order_token, checkout keeps the request across reloads before sending it, the same checkout again recovers the kept one, and a different checkout is refused until recoverCheckout settles it. With both given, use retainCheckout before posting and recoverCheckout on the next page load. The same order_id never makes a second order. A definite refusal (any 4xx except 408 and 429) drops the kept request, so the buyer can change the cart and check out again. eshop.checkoutFlow({ buyer, catalog_id }) runs the whole checkout page on these rules (Checkout).
  • A cart is active, abandoned, converted (with its order), superseded, merged or expired. The browser keeps the cart’s recovery token, which the SDK sends as X-Arky-Cart-Token. A cart holds at most 100 lines of each kind and 100 deliveries, and a cart too large to store is refused with 413 CART.TOO_LARGE.

Company carts and offers. A cart’s buyer is the customer themself, a company, a company_location, or a company_location_selection: a company cart whose location isn’t picked yet, which takes no lines until one is. Company buyers carry an optional purchase order number, and buying for a company or location needs the place_orders company permission there. Staff can review a company or location cart and send it as an offer in a language, with an optional expiry and a note for the customer; an offer locks the cart, holds only lines priced from its catalog (no purchase-access goods and no upgrade), and a changed offer is a new cart that supersedes it. The customer signs in with the email code to open and order it before it expires (409 CART.OFFER_EXPIRED after); an expired offer sends no reminder and stays locked until staff withdraw or revise it. Offers send the offer_sent and offer_reminder emails, whose wording reads the note at cart.offer.status.note and the expiry at cart.offer.status.expires_at.

Orders

interface Order {
	id: string;
	blocks: Block[];
	number: string;
	invoices: OrderInvoice[];
	store_id: string;
	source:
		| { type: "cart"; cart_id: string; placed_by: Actor }
		| { type: "renewal"; order_customer_group_line_item_id: string; recovery: RenewalRecovery | null };
	customer_id: string;
	contact: { email: string | null; first_name: string | null; last_name: string | null; phone: string | null };
	buyer: OrderBuyer;
	market_id: string;
	sales_channel_id: string;
	tax_mode: "exclusive" | "inclusive";
	collection:
		| { type: "free" }
		| { type: "payment_option"; payment_option_id: string }
		| { type: "customer_group_member" }
		| { type: "on_account"; payment_option_id: string; terms: PaymentTerms; due_at: EpochMilliseconds; approval: OnAccountApproval };
	line_items: OrderLineItem[];
	delivery_groups: OrderDeliveryGroup[];
	currency: Currency;
	promotions: OrderPromotion[];
	billing_address: PostalAddress | null;
	language: string;
	created_at: EpochMilliseconds;
	updated_at: EpochMilliseconds;
	status: "pending" | "confirmed" | "partially_cancelled" | "cancelled";
	totals: OrderTotals;
}
GET /v1/stores/{store_id}/orders
SDK: sdk.eshop.order.find()
GET /v1/stores/{store_id}/orders/{id}
SDK: sdk.eshop.order.get()
POST /v1/stores/{store_id}/orders/{id}/cancel
SDK: sdk.eshop.order.cancel()
POST /v1/stores/{store_id}/orders/{id}/product-items/{line_item_id}/cancel
SDK: sdk.eshop.order.cancelProductItem()
POST /v1/stores/{store_id}/orders/{id}/booking-items/{line_item_id}/cancel
SDK: sdk.eshop.order.cancelBookingItem()
POST /v1/stores/{store_id}/orders/{id}/booking-items/{line_item_id}/reschedule
SDK: sdk.eshop.order.rescheduleBookingItem()
POST /v1/stores/{store_id}/orders/{id}/booking-items/{line_item_id}/attend
SDK: sdk.eshop.order.attendBookingItem()
POST /v1/stores/{store_id}/orders/{id}/booking-items/{line_item_id}/no-show
SDK: sdk.eshop.order.markBookingItemNoShow()
POST /v1/stores/{store_id}/orders/{id}/resend-receipt
SDK: sdk.eshop.order.resendReceipt()
POST /v1/stores/{store_id}/orders/{id}/credits
SDK: sdk.eshop.order.credit.create()
  • Lines are product, booking, customer_group, rental_use and purchase_access, each with its frozen snapshot, money and tax. A line is pending (a card checkout waiting 30 minutes for its payment), confirmed or cancelled with a reason: store_cancelled, customer_cancelled, payment_failed or expired. The order’s status is computed from its lines.
  • Arky gives each order a number at placement, unique in its store, which never changes. An order holds at most 500 lines and 100 deliveries.
  • Lines start confirmed when nothing must be paid first: a free order, an on-account order, and cash on delivery or a manual option, whose payment stays open until the money is recorded. Staff can’t confirm a card order’s waiting lines by hand.
  • Booking lines are the bookings: staff mark attendance (attendBookingItem) or a no-show once the booking has started (409 ORDER.BOOKING_NOT_STARTED before), move it to another start of the same offering before it starts (rescheduleBookingItem, at most 20 moves, emailing booking_confirmed again unless notify_customer is false), or cancel it until it ends. Customers move or cancel their own booking on the storefront only as the service’s customer_changes allows. sdk.eshop.order.findBookingItems lists bookings across orders by pending, confirmed, attended, no_show or cancelled.
  • Cancelling a confirmed product or booking line names the app-picked id of the credit it creates; a credit is money owed back to the buyer, refunded through the payment. Cancelling every line of an order paid by cash or a manual option closes its open payment.
  • Staff credits (credit.create, under a credit id the app picks, with its targets and reason) lower what the buyer owes; the same id with other targets or another reason is 409 GENERAL.CONFLICT. A credit that leaves nothing owed settles the order like a payment would, so goods held awaiting_payment can leave.
  • Staff with orders may correct the order’s contact (a normalized email) and, while nothing of a delivery has left, its address; they also edit the order’s blocks and attach up to 20 invoice or credit note links from the store’s accounting software.
  • Resend receipt sends the current order_received wording again to the order’s contact email, as a new outgoing email under the id the app picks, and answers with that email. When that email is queued to go, the order gets a new link and the old link stops working; a resend recorded not_sent, for example to a blocked address, leaves the old link working. It is refused while the store has no order_received email selected (409 STORE_EMAILS.ORDER_RECEIVED_EMPTY).
  • Customers read their own orders, deliveries and payments on the storefront through arky.eshop.order.

Payments

Payment options are how a market’s buyers pay: stripe (cards, after connecting the store’s Stripe account and its webhook), monri, cash_on_delivery and manual (such as a bank transfer, with blocks telling the buyer where to pay). A market lists its options in order. A Stripe or Monri option holds the store’s own saved_card_terms, per language: the wording a person accepts when a card is saved under it.

POST /v1/stores/{store_id}/payment-options
SDK: sdk.store.paymentOption.create()
POST /v1/stores/{store_id}/payment-options/stripe
SDK: sdk.store.paymentOption.stripe.connect()
GET /v1/stores/{store_id}/payments
SDK: sdk.eshop.payment.find()
POST /v1/stores/{store_id}/payments/{id}/refunds
SDK: sdk.eshop.payment.createRefund()
POST /v1/stores/{store_id}/payments/{id}/refunds/{refund_id}/receipts
SDK: sdk.eshop.payment.recordRefundReceipt()
POST /v1/stores/{store_id}/payments/{id}/collections
SDK: sdk.eshop.payment.recordCollection()
POST /v1/stores/{store_id}/payments/{id}/collections/{collection_id}/void
SDK: sdk.eshop.payment.voidCollection()
POST /v1/stores/{store_id}/payments/{id}/resolve-hold
SDK: sdk.eshop.payment.resolveHold()
POST /v1/stores/{store_id}/payments/{id}/resolve-charge
SDK: sdk.eshop.payment.resolveCharge()
POST /v1/stores/{store_id}/payments/{id}/refunds/{refund_id}/resolve
SDK: sdk.eshop.payment.resolveRefund()
GET /v1/stores/{store_id}/incoming-webhooks
SDK: sdk.notification.incomingWebhook.find()
interface Payment {
	id: string;
	store_id: string;
	order_id: string;
	payment_option_id: string;
	money: Money;
	type:
		| ({ type: "stripe_checkout" } & StripeCheckoutPayment)
		| ({ type: "stripe_card" } & StripeCardPayment)
		| ({ type: "monri_checkout" } & MonriCheckoutPayment)
		| ({ type: "monri_card" } & MonriCardPayment)
		| ({ type: "cash_on_delivery" } & RecordedPayment)
		| ({ type: "manual" } & ManualPayment);
	holds: { id: string; message: string; opened_at: EpochMilliseconds; status: { type: "open" } | { type: "resolved"; by: AccountActor; note: string; at: EpochMilliseconds } }[];
	status: "pending" | "open" | "requires_action" | "paid" | "expired" | "cancelled" | "declined" | "failed" | "unknown" | "closing";
	status_revision: string;
	created_at: EpochMilliseconds;
	updated_at: EpochMilliseconds;
}
  • Who may do what: staff with orders read payments and manage saved cards; only payments moves money or records money moving (refunds, collections and their voids, settling unknown results, resolving holds). A pickup clerk with fulfillment reach at the location records the cash of a cash-on-delivery handover there.
  • One record per payment. Its refunds, disputes and recorded money are entries inside it, and refunds never exceed what was paid. Refunds and recorded collections have their own commands on the payment.
  • Cash and manual payments count as paid once their active recorded money reaches what is due. Cancelling the whole order closes an open one. A collection recorded by mistake is voided with a reason: it stays in the history, stops counting, moves no money and sends no email; a wrong amount is fixed by voiding it and recording the right one.
  • Cash and manual refunds are one payout: a requested refund is recorded sent with the reference of the payout, or cancelled; money that comes back makes it returned, and paying it again is a new refund.
  • Holds open when facts disagree, such as money arriving after a payment ended, and stay open until a person resolves them by id with a note; they stay in the history.
  • Unknown Monri results: when Monri’s answer to a card charge or a refund never arrived, staff check Monri and record what happened with resolveCharge or resolveRefund: { type: "made", transaction_id } or { type: "not_made" }, with the payment’s expected_updated_at. The charge or the refund then reads confirmed_made or confirmed_not_made.
  • Saved cards are payment methods: requestSetup, startSetup, completeSetup. A card belongs to its owner, a customer, a company or a company location, and pays only for that buyer. Saving one records the language and the exact terms the buyer accepted, which must be the option’s current saved_card_terms in that language (409 PAYMENT_METHOD.TERMS_CHANGED or TERMS_UNAVAILABLE otherwise). A card can’t be revoked while a customer group member that isn’t cancelled renews with it (409 PAYMENT_METHOD.IN_USE, naming the member).
  • Incoming webhooks are the store’s inbox for posts from its Stripe and Monri options and its own Resend account, unique per provider event id: waiting (new, retried, or unmatched until its record appears), processing, applied, ignored, or in review until staff with payments resolve it with apply or ignore.
  • A refund that succeeds sends the order_refunded email, once per refund, when its request said so. Webhooks post refund.created, refund.succeeded and refund.failed, each with { order, payment_id, refund_id }; refund.succeeded also carries notify_customer.

Returns And Rentals

POST /v1/stores/{store_id}/returns
SDK: sdk.eshop.return.create()
POST /v1/stores/{store_id}/returns/{id}/execute
SDK: sdk.eshop.return.execute()
POST /v1/stores/{store_id}/returns/{id}/credit
SDK: sdk.eshop.return.credit()
GET /v1/stores/{store_id}/rentals
SDK: sdk.eshop.rental.find()
POST /v1/stores/{store_id}/rentals/{id}/commands
SDK: sdk.eshop.rental.execute()

A return is an order return or a rental return. A customer’s return starts requested; staff approve it, opening it at a decided location, or decline it with a reason, and each choice carries notify_customer to say whether the customer gets the return_approved or return_declined email. A return staff create names the location that receives it (409 RETURN.DESTINATION_REQUIRED without one), so creating one needs orders and fulfillment at every location. Staff move a return with one command and the expected_updated_at they read; a receipt, disposal or missing record that closes it posts its own webhook, then return.closed. A return never changes money by itself: once units of an order return are received, staff credit them on the order with credit, under a credit id the app picks and the return’s expected_updated_at, each received unit once, and refund the payment separately. Customers start returns from the storefront with arky.eshop.return.create, using getOrderOptions and findRentalOptions to see what can go back. A rental is one rented item of a customer group member, with the terms in force; staff with orders request a replacement unit for it.

Inventory

POST /v1/stores/{store_id}/inventory-items
SDK: sdk.eshop.inventoryItem.create()
GET /v1/stores/{store_id}/inventory-levels
SDK: sdk.eshop.inventoryLevel.find()
POST /v1/stores/{store_id}/inventory-movements
SDK: sdk.eshop.inventoryMovement.record()
POST /v1/stores/{store_id}/inventory-units
SDK: sdk.eshop.inventoryUnit.receive()

An inventory item is a stocked thing, such as one product model; variants name the items they need. It is tracked by quantity, individual (one unit record per piece) or untracked (no counted stock). An inventory level is its stock at one location (on_hand, reserved, unavailable and the available rest), changed by stock actions that each carry an action_id the app picks. Movements are the stock log with typed reasons; a dispatched row names the fulfillment that took the goods out. Inventory units track one serialized item through receiving, allocation, moves, returns and write-offs.

Companies

POST /v1/stores/{store_id}/companies
SDK: sdk.companies.create()
POST /v1/stores/{store_id}/company-locations
SDK: sdk.companies.location.create()
PUT /v1/stores/{store_id}/companies/{id}/purchasing
SDK: sdk.companies.setPurchasing()
PUT /v1/stores/{store_id}/company-locations/{id}/purchasing
SDK: sdk.companies.location.setPurchasing()
POST /v1/stores/{store_id}/companies/{id}/tax/registrations
SDK: sdk.companies.submitTaxRegistration()
POST /v1/stores/{store_id}/companies/{id}/tax/registrations/review
SDK: sdk.companies.reviewTaxRegistration()
POST /v1/stores/{store_id}/company-roles
SDK: sdk.companies.role.create()
POST /v1/stores/{store_id}/company-memberships
SDK: sdk.companies.membership.create()
await sdk.companies.location.setPurchasing({
	store_id: storeId,
	id: downtown.id,
	expected_updated_at: downtown.updated_at,
	purchasing: {
		payment: { type: "on_account", terms: { type: "net_days", days: 30 }, billing_address: downtownBilling },
		allowed_payment_option_ids: [bankTransfer.id],
		purchase_order_number_required: true,
	},
});

await sdk.companies.membership.create({
	store_id: storeId,
	id: crypto.randomUUID(),
	company_id: northwind.id,
	email: "buyer@northwind.example",
	language: "en",
	grants: [{ scope: { type: "company_location", company_location_id: downtown.id }, role_ids: [buyerRole.id] }],
});
  • A company is a business buyer with a name, a contact email and blocks. A company and each of its locations have their own purchasing policy: standard_checkout, or on_account with payment terms such as net 30 and a billing address; the payment options they may use (an empty list allows none); and whether a purchase order number is required. A location never inherits its company’s policy. Only staff set purchasing, tax, blocks and the location a company location is served from; a new company or location pays at checkout with no payment option allowed until staff list some.
  • A company and each location keep their own tax registrations, which staff submit and then verify or reject. Orders carry the buyer’s verified registrations.
  • Memberships add a person by email, with grants, each for the whole company (which covers all its locations, now and later) or for one location, with company roles for that scope. A verified customer with that email is active at once; anyone else is invited and becomes active the moment they verify that email by signing in, with nothing to accept. Adding someone sends the store’s company_invite email in the language named. Permissions are place_orders, join_customer_groups, access_digital_products, view_own_orders, view_company_orders, view_own_customer_groups, view_company_customer_groups, manage_company, manage_addresses, manage_members, manage_company_customer_groups, manage_payment_methods and admin.
  • A member with manage_company edits the company’s name and contact email. A member with manage_addresses edits a location’s name, shipping address and billing address; for the whole company it also edits the company’s billing address and creates and removes its locations.
  • While other members are active, one active membership must keep manage_members (or admin) for the whole company: removing, disabling or narrowing it, or editing one of its roles, is refused with 409 COMPANY_MEMBERSHIP.LAST_MANAGER until another member holds it. Adding someone and an invite turning active are never refused by this rule, so a company’s buyers can be active before its first manager signs in.
  • Members read their companies, locations and grants with arky.companies on the storefront.
Note

Customer groups are on Customer Groups; promotions on Promotions; shipping and fulfillment on Shipping and Fulfillment.