Back to Arky

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;
}
  • languages are 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.
  • timezone is 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 takes admin away.

Create

POST /v1/stores
SDK: 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

GET /v1/stores
SDK: sdk.store.find()
GET /v1/stores/{id}
SDK: sdk.store.get()
PUT /v1/stores/{id}
SDK: 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

POST /v1/stores/{store_id}/pause-email-sending
SDK: sdk.store.pauseEmailSending()
POST /v1/stores/{store_id}/allow-email-sending
SDK: 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

POST /v1/stores/{id}/deletion
SDK: 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:

GET /v1/stores/{store_id}/counts/statuses
SDK: sdk.counts.getStatuses()
GET /v1/stores/{store_id}/counts/report
SDK: 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 (in store.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

GET /v1/stores/plans
SDK: sdk.platform.findStorePlans()
GET /v1/stores/{store_id}/subscription
SDK: sdk.store.subscription.get()
POST /v1/stores/{store_id}/subscription
SDK: sdk.store.subscription.select()
POST /v1/stores/{store_id}/subscription/portal
SDK: sdk.store.subscription.createPortalSession()
POST /v1/stores/{store_id}/subscription/end-grant
SDK: sdk.store.subscription.endGrant()
GET /v1/stores/{store_id}/usage
SDK: 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_until plus two days, in case a renewal webhook is late. A granted plan has no end; none has no access, and the public storefront answers 402.
  • Cancelling, reactivating, and changing the card or the plan happen in Stripe’s billing portal, which createPortalSession opens.
  • Granted plans are given by a platform administrator: picking a plan without a Stripe price grants it. endGrant, with the subscription’s expected_updated_at, takes a granted plan back to none.
  • 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

POST /v1/stores/{store_id}/roles
SDK: sdk.store.role.create()
GET /v1/stores/{store_id}/roles
SDK: sdk.store.role.find()
PUT /v1/stores/{store_id}/roles/{id}
SDK: sdk.store.role.update()
DELETE /v1/stores/{store_id}/roles/{id}?expected_updated_at=
SDK: 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.

POST /v1/stores/{store_id}/invitation
SDK: sdk.store.member.invite()
POST /v1/stores/{store_id}/members/{id}/invitation
SDK: sdk.store.member.resendInvite()
GET /v1/stores/{store_id}/membership
SDK: sdk.store.member.getOwn()
POST /v1/stores/{store_id}/membership/accept
SDK: sdk.store.member.acceptInvite()
GET /v1/stores/{store_id}/members
SDK: sdk.store.member.find()
PUT /v1/stores/{store_id}/members/{id}/roles
SDK: sdk.store.member.updateRoles()
PUT /v1/stores/{store_id}/members/{id}/status
SDK: sdk.store.member.changeStatus()
DELETE /v1/stores/{store_id}/members/{id}
SDK: sdk.store.member.remove()
POST /v1/stores/{store_id}/ownership/transfer
SDK: sdk.store.member.transferOwnership()
GET /v1/stores/memberships
SDK: 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; resendInvite sends 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 with 409 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

POST /v1/stores/{store_id}/webhooks
SDK: sdk.store.webhook.create()
GET /v1/stores/{store_id}/webhooks
SDK: sdk.store.webhook.find()
PUT /v1/stores/{store_id}/webhooks/{id}
SDK: sdk.store.webhook.update()
DELETE /v1/stores/{store_id}/webhooks/{id}?expected_updated_at=
SDK: sdk.store.webhook.delete()
POST /v1/stores/{store_id}/outgoing-webhooks/test
SDK: 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. test takes 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.

Note

Markets, sales channels, API keys, zones and tax categories are on Markets and Channels.