Store
The store, its Arky plan, roles and members, and webhooks
A store is the tenant boundary: products, content, customers, orders and members all belong to one store. It holds only nine fields; everything else is its own record.
import type { EpochMilliseconds } from "arky-sdk";
interface Store {
id: string;
name: string;
owner_account_id: string;
timezone: string;
languages: string[];
status: { type: "active" } | { type: "deleting" };
email_sending: { type: "allowed" } | { type: "paused"; reason: string };
created_at: EpochMilliseconds;
updated_at: EpochMilliseconds;
}
languagesare the store’s languages, at least one. There is no default language: every request that needs a language names it, and it must be one of these.timezoneis the store’s business calendar: payment-term due dates, scheduled deliveries, and the calendar each order records. Locations, booking resources and customer group member schedules have their own.owner_account_id: exactly one owner. Only the owner manages Arky billing and store settings, closes the store, removes members and takesadminaway.
Create
/v1/stores sdk.store.create() import { createAdmin } from "arky-sdk/admin";
const sdk = createAdmin({ apiUrl: "https://api.arky.io" });
const store = await sdk.store.create({
id: crypto.randomUUID(),
name: "Northwind",
timezone: "Europe/Berlin",
languages: ["de", "en"],
market: { key: "de", currency: "eur", tax_mode: "inclusive" },
sales_channel: { key: "web" },
location_address: { country: "DE", street1: "Hauptstraße 1", city: "Berlin", postal_code: "10115" },
});Parameters
| Name | Type | Description |
|---|---|---|
id required | string | The store id the app picks (UUID v4) |
name required | string | 1 to 255 bytes |
timezone required | string | An IANA timezone |
languages required | string[] | The store's languages, no duplicates |
market required | { key, currency, tax_mode } | The first market |
sales_channel required | { key } | The first sales channel, which lists that market |
location_address required | PostalAddress | The default location's address |
Store creation sells from day one. In one transaction it creates the store, its Arky plan record
(none), the default location and the routing record, the admin role and the owner’s membership,
the first market, the first sales channel listing it, the first
publishable key on that channel, that market’s catalog
public-{market key} with buy access for everyone and all companies in every channel, and empty
email settings. It creates no company roles, customer groups, forms, tax
configuration, categories, email addresses or templates: the app creates those it needs. A
content-only store simply leaves its selling setup unused.
Read And Update
/v1/stores sdk.store.find() /v1/stores/{id} sdk.store.get() /v1/stores/{id} sdk.store.update() await sdk.store.update({
id: store.id,
expected_updated_at: store.updated_at,
languages: ["de", "en", "fr"],
});
Settings edits carry the expected_updated_at they read; a stale edit is refused with 409.
Pause Store Email
/v1/stores/{store_id}/pause-email-sending sdk.store.pauseEmailSending() /v1/stores/{store_id}/allow-email-sending sdk.store.allowEmailSending() const paused = await sdk.store.pauseEmailSending({
store_id: store.id,
expected_updated_at: store.updated_at,
reason: "Broadcast went out with the wrong link",
});
await sdk.store.allowEmailSending({ store_id: store.id, expected_updated_at: paused.updated_at });
The owner or a platform administrator can hold every store email at once, such as to catch a
mistaken broadcast. While email_sending is paused, store emails wait instead of going out;
nothing is dropped or stopped, and an email with a deadline, such as a sign-in code, still ends
when its code expires. Allowing sending lets the waiting emails go. Receiving email and Resend’s
delivery reports go on while paused, and Arky’s own emails aren’t held. The reason is 1 to 500
bytes.
Close The Store
/v1/stores/{id}/deletion sdk.store.requestDeletion() await sdk.store.requestDeletion({
id: store.id,
expected_updated_at: store.updated_at,
confirmation: store.name,
});
Closing deletes the store, and nothing that is still open holds it back. Only the owner (or a
platform administrator) closes a store, with its name as confirmation and the updated_at it read,
signed in within the last 15 minutes (403 ACCOUNT.RECENT_SIGN_IN_REQUIRED otherwise); a store that
changed since it was read answers 409 GENERAL.CONFLICT.
So show the owner what is still open before they confirm. Arky’s Admin does this on its close screen, from the store counts:
/v1/stores/{store_id}/counts/statuses sdk.counts.getStatuses() /v1/stores/{store_id}/counts/report sdk.counts.getReport() const open = await sdk.counts.getStatuses({
store_id: store.id,
types: [
"open_recurring_members_by_status",
"open_rentals_by_status",
"open_payments_by_type",
"open_shipments_by_type",
],
});
const upcoming = await sdk.counts.getReport({
store_id: store.id,
from: storeToday,
to: storeTodayPlus364Days,
totals: [{ type: "booked_slots_day" }],
});
open_recurring_members_by_status: customer group members that renew and aren’t cancelled (awaiting_activation,active,blocked,paused).open_rentals_by_status: rentals still out (active,ending).open_payments_by_type: payments with a charge, refund or dispute in progress, by payment type.open_shipments_by_type: fulfillment jobs with units left to send (fulfillment_job) and fulfillments being prepared or waiting for pickup (fulfillment).booked_slots_day: each booking that holds capacity counts on the day its slot starts, so a total from the store’s today (instore.timezone) onward is the upcoming bookings. Admin reads today through today + 364 days.
Every segment comes back, with 0 where nothing is open. The owner holds every permission these
reads need.
Closing sends those customers no email and refunds nothing; bookings, rentals and members go with the store. A Monri payment that completes after closing must be refunded in Monri.
The store becomes deleting at once. From then on staff can still read it, but every change answers
409 STORE.DELETING; nobody signs in to it, not even with a code requested before; its storefront
keys answer 401; its waiting emails end NotSent and its webhook posts unacknowledged. Arky then
cancels the store’s Arky subscription at Stripe, expires the store’s open Stripe Checkout Sessions,
cancels its card payments still waiting for the customer, and removes the webhooks it created in the
store’s own Stripe and Resend accounts. The accounts themselves, their domains, customers and saved
cards stay the store’s. A provider that fails is logged and never holds the deletion up. Each module
then removes its own data in batches, and the last step removes the store record. Payments, emails
and provider posts that arrive later for the store are acknowledged and ignored.
Arky Plan
/v1/stores/plans sdk.platform.findStorePlans() /v1/stores/{store_id}/subscription sdk.store.subscription.get() /v1/stores/{store_id}/subscription sdk.store.subscription.select() /v1/stores/{store_id}/subscription/portal sdk.store.subscription.createPortalSession() /v1/stores/{store_id}/subscription/end-grant sdk.store.subscription.endGrant() /v1/stores/{store_id}/usage sdk.store.usage.get() interface StoreSubscription {
id: string;
store_id: string;
plan:
| { type: "none" }
| { type: "granted"; plan_id: string }
| {
type: "billed";
plan_id: string;
status: "trialing" | "active" | "past_due" | "unpaid" | "cancellation_scheduled" | "cancelled";
paid_until: EpochMilliseconds;
provider: { type: "stripe"; customer_id: string; subscription_id: string };
};
trial_used: boolean;
created_at: EpochMilliseconds;
updated_at: EpochMilliseconds;
}
const plans = await sdk.platform.findStorePlans();
const standard = plans.items.find((plan) => plan.tier === 2 && plan.interval === "month");
const selection = await sdk.store.subscription.select({
store_id: store.id,
plan_id: standard!.id,
return_url: "https://app.example.com/billing",
});
if (selection.payment_action.type === "stripe_embedded_checkout") {
mountStripeCheckout(selection.payment_action.client_secret);
}
- Picking a plan opens a Stripe payment page with the plan’s price, the trial when it wasn’t
used yet, and the owner’s account email. Nothing is saved until Stripe reports a paid or trialing
subscription; then the plan is
billed. - Access lasts until
paid_untilplus two days, in case a renewal webhook is late. Agrantedplan has no end;nonehas no access, and the public storefront answers402. - Cancelling, reactivating, and changing the card or the plan happen in Stripe’s billing
portal, which
createPortalSessionopens. - Granted plans are given by a platform administrator: picking a plan without a Stripe price
grants it.
endGrant, with the subscription’sexpected_updated_at, takes a granted plan back tonone. - Plan limits are running totals per store: products, booking services and resources, collections,
entries, media, customer groups (those that aren’t archived), forms, email templates, categories,
support flows, webhooks, broadcasts (those not yet sent) and members.
sdk.store.usage.get({ store_id })lists every counter beside its allowance:{ type: "limited", limit },{ type: "unlimited" }, or{ type: "no_plan" }when the store has no plan.
Roles And Members
/v1/stores/{store_id}/roles sdk.store.role.create() /v1/stores/{store_id}/roles sdk.store.role.find() /v1/stores/{store_id}/roles/{id} sdk.store.role.update() /v1/stores/{store_id}/roles/{id}?expected_updated_at= sdk.store.role.delete() const packer = await sdk.store.role.create({
store_id: store.id,
id: crypto.randomUUID(),
key: "packer",
permissions: [
{ type: "fulfillment", locations: { type: "only", store_location_ids: [warehouse.id] } },
{ type: "inventory", locations: { type: "only", store_location_ids: [warehouse.id] } },
],
});
The permissions are admin (alone, implies every other), catalog, orders, payments,
customers, fulfillment and inventory (each with a location reach), marketing, content,
support, email and analytics. orders reads payments but moves no money; only payments
refunds, records or voids collections, settles unknown provider results and resolves holds, except
that fulfillment at a location records the cash of a cash-on-delivery pickup there. A role has at
least one permission, each once, and a unique key. A role can’t be deleted while a membership or an
email alert names it.
/v1/stores/{store_id}/invitation sdk.store.member.invite() /v1/stores/{store_id}/members/{id}/invitation sdk.store.member.resendInvite() /v1/stores/{store_id}/membership sdk.store.member.getOwn() /v1/stores/{store_id}/membership/accept sdk.store.member.acceptInvite() /v1/stores/{store_id}/members sdk.store.member.find() /v1/stores/{store_id}/members/{id}/roles sdk.store.member.updateRoles() /v1/stores/{store_id}/members/{id}/status sdk.store.member.changeStatus() /v1/stores/{store_id}/members/{id} sdk.store.member.remove() /v1/stores/{store_id}/ownership/transfer sdk.store.member.transferOwnership() /v1/stores/memberships sdk.store.member.findOwn() await sdk.store.member.invite({
store_id: store.id,
id: crypto.randomUUID(),
email: "lena@example.com",
role_ids: [packer.id],
});
const invited = await sdk.store.member.getOwn({ store_id: store.id });
if (invited?.status.type === "invited") {
await sdk.store.member.acceptInvite({ store_id: store.id, expected_updated_at: invited.updated_at });
}
const own = await sdk.store.member.findOwn();
A membership is invited (holding the invited email), active or disabled, with one to twenty
roles. Members are addressed by the membership’s id.
- An invite has no expiry. It stays until it is accepted or removed. Inviting queues one of
Arky’s own emails, from Arky’s address, with a link to the invite in Admin;
resendInvitesends it again. A repeated invite with the same id returns it without sending another. An email holds at most one invite per store: inviting it again under another id is refused with409 STORE_MEMBERSHIP.EMAIL_TAKEN. Inviting someone who is already a member is refused too. - Accepting is its own step. The signed-in account whose email is the invited one accepts with
the membership’s
expected_updated_at, while the plan has a free members seat. Signing in never accepts an invite. - Removing deletes the membership, an invite included; a later invite gets a new id. The owner
can’t be disabled or removed; only the owner removes members or disables a member holding
admin. Disabled members keep their roles and their seat. - Ownership moves only to another active member holding
admin, and both keep their roles. - Role changes and status changes carry the membership’s
expected_updated_at.
Webhooks
/v1/stores/{store_id}/webhooks sdk.store.webhook.create() /v1/stores/{store_id}/webhooks sdk.store.webhook.find() /v1/stores/{store_id}/webhooks/{id} sdk.store.webhook.update() /v1/stores/{store_id}/webhooks/{id}?expected_updated_at= sdk.store.webhook.delete() /v1/stores/{store_id}/outgoing-webhooks/test sdk.store.webhook.test() const webhook = await sdk.store.webhook.create({
store_id: store.id,
id: crypto.randomUUID(),
url: "https://example.com/arky/webhooks",
events: [
{ type: "order.confirmed" },
{ type: "fulfillment.dispatched" },
{ type: "form_submission.created", forms: { form_ids: [contactForm.id] } },
],
headers: {},
secret: process.env.ARKY_WEBHOOK_SECRET!,
status: { type: "active" },
});
await sdk.store.webhook.test({ store_id: store.id, id: crypto.randomUUID(), webhook_id: webhook.id });
- A webhook picks its own events, each once. Collection and entry events take a typed filter,
{ type: "all" }or{ type: "only", ... }; form submission events name their forms in{ form_ids }, 1 to 100 of them. - The URL, the secret and the header values are stored encrypted and masked in responses; leave them out of an update to keep them.
- Each post is an outgoing webhook, signed fresh on every try
and retried for about 45 hours.
testtakes the post’s id, picked by the app. sdk.platform.findWebhookEvents()lists all 113 events, each with the scope fields its filter takes ({ field }).
See the Webhooks guide for the body, the signature and the event list.
Markets, sales channels, API keys, zones and tax categories are on Markets and Channels.