Core Concepts
The rules every Arky record and request follows, before you wire a custom website.
Store
A store is the tenant boundary: products, content, customers, orders and members all belong to one store. A store has a name, an owner, a timezone and its languages. There is no default language: every request that needs a language names it, and it must be one of the store’s.
A new store sells from day one. It comes with its first market, its first sales channel, that
market’s catalog public-{market key} and a publishable key on that channel. It sends no email until
it connects its own Resend account and adds a sending address; see Store Email.
Modules
| Module | Purpose |
|---|---|
| Content | Collections, entries and blocks for editable text |
| Media | Images, videos and PDFs |
| Commerce | Products, bookings, catalogs, carts, orders, payments, shipping |
| Customer groups | Free or paid memberships, joined by buying or assigned by the team, such as newsletters |
| Customers | Customers, sessions, sign-in codes, email changes |
| Forms | Questions, submissions and stages |
| The store’s own Resend account, sending and receiving addresses, the template of each moment, broadcasts | |
| Support | Fixed-step chat flows, conversations by chat and email, one inbox |
| Actions | First-party facts for timelines and experiment goals |
| Page views | Traffic per page, country and referrer, and unique visitors, counted without records |
| Experiments | Sticky variant assignments and their results |
The App Picks Every Id
Every create names the new record’s id: a canonical UUID v4 that your code picks, never Arky. On a
storefront your createId factory picks it: a call that creates something takes every id you leave
out from createId, and a checkout takes its order token from createOrderToken.
const cart = arky.eshop.cart({ buyer: { type: "customer" }, catalog_id: catalog.id });
const accepted = await cart.checkout({ contact_email: email, payment });
- A retry that is the same request gets the existing record back, so a lost answer never makes a second order, cart line, form submission, chat message or sign-in email.
- The storefront flows keep the pending id with a fingerprint of the request in
localStorage, so the same request sent again, after a reload too, reuses it. A changed request gets a new id, and a success or a definite refusal clears it; see Error Handling. - A different request under an id that is already used is refused with
409. - An id you pass yourself is picked once per user action and sent again on every retry of that action. Code that makes a new id on every retry gets duplicates.
Arky checks the format and never makes ids for you: the SDK refuses a malformed id before sending, and takes a missing one from your factory.
Changes Carry What They Read
An update or an action on an existing record carries expected_updated_at, the updated_at you
read. A delete carries it in the query.
const draft = await sdk.broadcast.get({ store_id: storeId, id: broadcastId });
await sdk.broadcast.send({
store_id: storeId,
id: draft.id,
expected_updated_at: draft.updated_at,
});
If someone changed the record in between, or the same action already applied, the record’s
updated_at moved and the request is refused with 409 GENERAL.CONFLICT. Reload, look, and decide
again. Every other refusal names its reason in its own code, such as 409 PRICE.OVERLAP; see
Error Handling.
Storefront Context
A storefront request names its context in headers, which the SDK sets from initialize or
setContext:
X-Arky-Publishable-Key: the publishable key. It belongs to exactly one sales channel, and every cart, price and checkout it makes uses that channel, which decides prices, catalog access and routing. There is no channel header.X-Arky-Locale: the language. One of the store’s languages; there is no fallback.X-Arky-Market: the market key, implied when the key’s channel sells in exactly one market.
import { initialize } from "arky-sdk/storefront";
import { createOrderToken } from "arky-sdk/utils";
const arky = initialize("arky_pk_...", {
locale: "de",
market: "de",
createVisitorIdentity: () => ({
customer_id: crypto.randomUUID(),
session_id: crypto.randomUUID(),
}),
createSessionId: () => crypto.randomUUID(),
createId: () => crypto.randomUUID(),
createOrderToken,
});
For one request in another language, pass { locale } as its request options; it runs on the same
session:
const english = await arky.content.load({ collection: { key: "website" } }, { locale: "en" });
Deleting
Records that other records can point at can’t be deleted while something points at them: the
delete is refused with 409 REFERENCE.STILL_REFERENCED and a message that names who points. Remove
or change the pointer first, or archive the record instead.
A delete that has work to do, such as a collection with entries, a product in carts or a whole
store, first marks the record deleting. From then on it can’t be edited, nothing new can point at
it (409 REFERENCE.DELETING), and buyers no longer see it; a cleanup removes what depends on it,
and then the record goes.
Action Keys And Experiments
An action key names something meaningful that happened on the site: lead.submitted,
hero.cta.clicked, checkout.started. Actions join the customer’s timeline and experiment goals.
Page views are not actions: the SDK counts them with pageViews.send, which stores nothing. Arky
records its own moments itself, such as cart items added, orders placed and forms submitted.
await arky.actions.track({ key: "lead.submitted", data: { source: "homepage" } });
An experiment gives each customer one sticky variant; see Action Keys and Experiments.
Pagination
Lists return { items, cursor }. Follow the cursor until it is null, even after an empty page:
let cursor: string | null = null;
do {
const page = await arky.eshop.product.find({ catalog_id: catalog.id, limit: 50, cursor });
render(page.items);
cursor = page.cursor;
} while (cursor);
readAllPages and paginate in arky-sdk/utils follow the cursor for you, and refuse a cursor
that repeats or doesn’t advance:
import { readAllPages } from "arky-sdk/utils";
const products = await readAllPages((cursor) =>
arky.eshop.product.find({ catalog_id: catalog.id, limit: 50, cursor }),
);
Time, Days And Money
- Moments are signed UTC epoch milliseconds,
EpochMillisecondsin the SDK. Pass them toDateas they are. - Days without a time, such as a booking day or a form’s date answer, are
"YYYY-MM-DD"strings. - Money is an integer in the currency’s minor units: cents for EUR, whole yen for JPY, thousandths for KWD.
import { formatMinor } from "arky-sdk/utils";
formatMinor(1999, "eur", "en");
Errors
SDK calls resolve to their response type and throw on failure. A refused request throws an
ArkyApiError with statusCode, code, message and validationErrors:
import { ArkyApiError } from "arky-sdk/storefront";
try {
await arky.forms.submit(request);
} catch (error) {
if (error instanceof ArkyApiError) console.error(error.code, error.message);
else throw error;
}
See Error Handling.
Permissions
Operator access comes from store memberships. Each membership holds roles, and each role holds
permissions: admin, catalog, orders, payments, customers, fulfillment and inventory
(each limited to locations if needed), marketing, content, support, email and analytics.
Integrations act through a store’s secret key, which holds its own permissions. The store’s
owner alone manages Arky billing and store settings, closes the store and transfers ownership.
Platform administrators run Arky itself. Customer sessions never grant store permissions.
Webhooks
A webhook picks its own events and gets each one as a signed post, retried for about 45 hours:
await sdk.store.webhook.create({
store_id: storeId,
id: crypto.randomUUID(),
url: "https://example.com/arky",
events: [{ type: "order.confirmed" }, { type: "order_booking_item.reminder" }],
headers: {},
secret: process.env.ARKY_WEBHOOK_SECRET!,
status: { type: "active" },
});
See the Webhooks guide.