Back to Arky

Customer Groups

Groups and offerings, members who join by buying or are assigned by the team, revisions, renewals, and member changes

A customer group is a set of members, and being a member gets them things: member prices, a catalog, products delivered every period, items on loan, or nothing more than a place on a mailing list. Whether a group is free or paid isn’t stored on the group: it is the group’s price in each catalog that lists it, so one group can cost €0 in one catalog and €9 a month in another. A group that no catalog lists is one the team assigns people to.

  • A customer group offering puts groups side by side, such as Basic and Premium, so a member can move between them.
  • A customer group member is one customer, company or company location in one group. Members join by buying the group through a catalog, or the team assigns them.
  • A member revision holds the terms a member accepted when it ordered, and each later change.

Catalog access, promotions, shipping rates and broadcasts name a group or a whole offering, so one rule covers every member of it.

A shop might have three groups:

GroupPriceWhat it givesMembers
wholesalelisted by no catalogthe Wholesale catalog, whose access names the groupa company the team assigned
weekly-box€40 a montha box delivered every periodone company location, paying with its own card
club€9 a monthmember prices on chosen products and a gift every montha person, who bought it for themselves

When that person orders for their company’s location, the Wholesale prices apply. When they order for themselves, the club prices do.

Groups

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

interface CustomerGroup {
	id: string;
	store_id: string;
	key: string;
	blocks: Block[];
	term:
		| { type: "permanent" }
		| {
				type: "recurring";
				cadence: { interval: "day" | "week" | "month" | "year"; interval_count: number };
				renewal_reminder_days: number | null;
				commitment: { occurrences: number; end_action: CustomerGroupCommitmentEndAction } | null;
				on_exhaustion: { type: "pause" } | { type: "cancel" };
				unpaid_order: { type: "retain_debt" } | { type: "cancel_unfulfilled" };
				retry_offsets_seconds: number[];
				recovery_window_seconds: number;
		  };
	entitlements: CustomerGroupEntitlement[];
	purchase_requirement: PurchaseRequirement | null;
	tax_policies: CustomerGroupTaxPolicy[];
	status: { type: "draft" } | { type: "active" } | { type: "closed" } | { type: "archived" };
	starts_at: EpochMilliseconds | null;
	ends_at: EpochMilliseconds | null;
	created_at: EpochMilliseconds;
	updated_at: EpochMilliseconds;
}
POST /v1/stores/{store_id}/customer-groups
SDK: sdk.eshop.customerGroup.create()
GET /v1/stores/{store_id}/customer-groups
SDK: sdk.eshop.customerGroup.find()
GET /v1/stores/{store_id}/customer-groups/by-key/{key}
SDK: sdk.eshop.customerGroup.getByKey()
PUT /v1/stores/{store_id}/customer-groups/{id}
SDK: sdk.eshop.customerGroup.update()
DELETE /v1/stores/{store_id}/customer-groups/{id}?expected_updated_at=
SDK: sdk.eshop.customerGroup.delete()
const club = await sdk.eshop.customerGroup.create({
	store_id: storeId,
	id: crypto.randomUUID(),
	key: "club",
	blocks: [],
	term: {
		type: "recurring",
		cadence: { interval: "month", interval_count: 1 },
		renewal_reminder_days: 3,
		commitment: null,
		on_exhaustion: { type: "pause" },
		unpaid_order: { type: "retain_debt" },
		retry_offsets_seconds: [3600, 86400],
		recovery_window_seconds: 604800,
	},
	entitlements: [
		{
			id: crypto.randomUUID(),
			type: {
				type: "product",
				variant_ids: [giftVariantId],
				delivery: { type: "once", quantity: 1, offset_days: 0, window_days: 3 },
			},
			allocation_weight: 1,
		},
		{
			id: crypto.randomUUID(),
			type: {
				type: "purchase_access",
				variant_ids: memberVariantIds,
				catalog_ids: [clubPrices.id],
				limits: [],
				tax_category_id: standardTax.id,
			},
			allocation_weight: 1,
		},
	],
	purchase_requirement: null,
	tax_policies: [],
	status: { type: "active" },
	starts_at: null,
	ends_at: null,
});
  • Term: permanent, or recurring with a cadence. cadence, commitment, on_exhaustion and unpaid_order are agreement terms: each member keeps them as accepted. retry_offsets_seconds, recovery_window_seconds and renewal_reminder_days are renewal timing: every renewal reads them from the live group, so an edit reaches existing members. A group’s term can’t switch kind while it has members who aren’t cancelled.
  • Commitment: a number of periods counted in blocks from the first. When a block ends, renew commits the next block, renew_once commits one more block and then stops, continue_without_term renews without a commitment and stop renews no more. Inside a started block the customer’s cancel ends renewals at the block’s end, the customer can’t pause, and nobody skips a committed period. A commitment charges no fee of its own.
  • Entitlements live inside the group and are written with it, each with an id and an allocation_weight that splits a paid group’s price across them:
    • product: 1 to 100 variants of one fulfillment kind. With several, the buyer picks one at checkout and the member’s terms keep it. delivery is none (services and digital variants), once per period in one window, or repeating on its own cadence inside a recurring period, per_period (spread over the windows) or per_delivery. Repeating windows count from the period’s start, except that month and year steps in a month- or year-based group follow the member’s billing day, so an N-month plan with monthly deliveries always gets N windows. Saving a group is refused when a schedule would open no window in a period, let a window cross the period’s end or give per_period fewer units than windows (409 CUSTOMER_GROUP.DELIVERY_WINDOW), or hold more than 100 deliveries in its longest period (409 CUSTOMER_GROUP.DELIVERY_LIMIT).
    • rental: a variant on loan, with its quantity and tax category.
    • purchase_access: buy chosen variants at the prices of a member catalog (at most one per market), within optional limits per billing period or calendar month, with a tax category. The catalog is only a price source; the buyer still shops in their own catalog.
  • A purchase names a quantity. It multiplies the group’s price, every product and rental quantity and every purchase limit. A group without entitlements, or with a digital one, takes quantity 1.
  • A group without entitlements is free and permanent: its price is €0 in every catalog that lists it. A group priced above €0 needs at least one entitlement, which carries its tax. A newsletter is a free group; see Newsletters.
  • Purchase requirement: a minimum, counted each calendar month per location or company-wide, that binds members who ordered, never assigned ones. A shortfall is only reported: it never charges, blocks or ships anything.
  • Tax policies cover areas like tax rules, and either tax each entitlement on its own or put entitlements under one tax category (grouped). Each renewal reads the live policy.
  • Status: draft has no members. active can be bought and assigned. closed takes no new members; existing members keep counting and renewing. archived is like closed and is hidden from pickers. A purchase or move enters a group only while it is active, inside its optional starts_at/ends_at, and, when an offering lists it, while that offering is active. The plan’s customer_groups limit counts groups that aren’t archived.
  • Keys are unique per store (409 CUSTOMER_GROUP.KEY_TAKEN). A new store has no groups.
  • Deleting is one write, refused while anything names the group, such as a member (cancelled ones too), an offering, a price, a catalog item, a cart, a promotion, a shipping rate, catalog access or a broadcast. A group that had members is closed or archived instead.

Offerings

POST /v1/stores/{store_id}/customer-group-offerings
SDK: sdk.eshop.customerGroupOffering.create()
GET /v1/stores/{store_id}/customer-group-offerings
SDK: sdk.eshop.customerGroupOffering.find()
GET /v1/stores/{store_id}/customer-group-offerings/by-key/{key}
SDK: sdk.eshop.customerGroupOffering.getByKey()
PUT /v1/stores/{store_id}/customer-group-offerings/{id}
SDK: sdk.eshop.customerGroupOffering.update()
DELETE /v1/stores/{store_id}/customer-group-offerings/{id}?expected_updated_at=
SDK: sdk.eshop.customerGroupOffering.delete()
const memberships = await sdk.eshop.customerGroupOffering.create({
	store_id: storeId,
	id: crypto.randomUUID(),
	key: "memberships",
	blocks: [],
	customer_group_ids: [basic.id, premium.id],
	status: { type: "active" },
});

An offering is { id, store_id, key, blocks, customer_group_ids, status, created_at, updated_at }, with status draft, active, closed or archived. Its key is unique in the store (409 CUSTOMER_GROUP_OFFERING.KEY_TAKEN).

  • A group is listed by at most one offering (409 CUSTOMER_GROUP_OFFERING.GROUP_TAKEN). Groups that need no neighbours, such as a VIP list, need no offering.
  • One group per offering at a time. A customer, company or location holds at most one member that isn’t cancelled per group and per offering. Adding a group to an offering is refused while someone already holds a member in it and in another group of the offering; Arky never cancels one of them on its own.
  • A member can move to any other group of its offering (see Moving).
  • active sells its groups, each still under its own status and dates. closed sells nothing and accepts no move into its groups, while existing members keep counting, renewing and leaving and the team can still assign. archived is closed and hidden from pickers.
  • Deleting an offering leaves its groups, and is refused while anything else names it.

Joining By Buying

A buyer can buy a group only through a catalog they may buy from that lists the group or its offering: a catalog item { type: "customer_group", customer_group_id } or { type: "customer_group_offering", customer_group_offering_id }, priced with a customer_group sellable.

const catalog = await sdk.eshop.catalog.getByKey({ store_id: storeId, key: "public-eu" });

await sdk.eshop.catalogItem.create({
	store_id: storeId,
	id: crypto.randomUUID(),
	catalog_id: catalog.id,
	item: { type: "customer_group", customer_group_id: club.id },
});

await sdk.eshop.price.create({
	store_id: storeId,
	id: crypto.randomUUID(),
	catalog_id: catalog.id,
	sellable: { type: "customer_group", customer_group_id: club.id },
	amount: 900,
	compare_at: null,
	min_quantity: 1,
	max_quantity: null,
	schedule: { type: "always" },
	status: { type: "active" },
});

Storefronts read groups like products, with the catalog’s prices, and add one to the cart as a customer_group line. Its line id comes from the app’s createId unless you pass one:

const offering = await arky.eshop.customerGroupOffering.getByKey({ key: "memberships" });
const groups = await arky.eshop.customerGroup.find({
	catalog_id: catalog.id,
	customer_group_offering_id: offering.id,
	include_price: true,
});

const cart = arky.eshop.cart({ buyer: { type: "customer" }, catalog_id: catalog.id });
await cart.addCustomerGroup({
	customer_group_id: groups.items[0].id,
	quantity: 1,
	choices: [],
	start: { type: "on_acceptance" },
});
  • get reads a group or an offering by { id }, and getByKey by { key }. The storefront offering read answers only active offerings.
  • choices names one variant for each product entitlement that lists several; arky.eshop.customerGroup.getProductChoices lists them with their option labels in a language.
  • start is on_acceptance, scheduled with a starts_at, or switch for an upgrade (below).
  • A group with deliveries asks for them with setFutureDeliveries, quoted first with quoteFutureDeliveries.
  • Checkout makes one order. Its group line names the new member and the revision of the terms the buyer accepted.
  • When the terms charge again later, as a paid recurring group or a delivery fee does, checkout saves the buyer’s card for the renewals: save_payment_method: true, with the option’s saved_card_terms text the buyer accepted in payment_method_terms. Without it checkout is refused with 409 CART.CARD_REQUIRED. When the first order has nothing to pay, such as under a first-period promotion, a Stripe card is saved through a setup without a payment, which checkout answers in payment_method_setup and mountPaymentMethodSetup mounts; Monri saves cards only from a paid checkout.
  • The member is awaiting_activation until that line is confirmed, then active. Its subject is the order’s buyer: the customer, the company or the company location.
  • Buying a group the subject already holds is refused. A free sign-up for a group the buyer already holds makes no order: checkout answers { type: "already_member", customer_group_member_id } and lifts the signed-in customer’s storefront unsubscribe.
  • arky.eshop.customerGroup.joinFree({ key, catalog_id }) (or { id, catalog_id }) signs a signed-in customer with a verified email up to a free group in one call, through its own cart, and answers { type: "joined", order_id } or { type: "already_member", customer_group_member_id }. arky.newsletter.join does the same after the email code; see Newsletters.
  • For a company or location, buying a group needs the place_orders and join_customer_groups company permissions there.

Assigning A Member

POST /v1/stores/{store_id}/customer-group-members
SDK: sdk.eshop.customerGroupMember.assign()
import { epochMilliseconds } from "arky-sdk/utils";

const member = await sdk.eshop.customerGroupMember.assign({
	store_id: storeId,
	id: crypto.randomUUID(),
	customer_group_id: wholesale.id,
	subject: { type: "company", company_id: company.id },
	access_end_at: epochMilliseconds(Date.UTC(2026, 11, 31)),
});
  • The team assigns a verified customer, an active company or an active company location to an active group with no order and no price. The member starts active, is permanent, has no revision, card or renewal, and gets the live group’s entitlements.
  • access_end_at ends the membership at that moment, such as “VIP until 31 December”; null keeps it until someone ends it.
  • A group that ships, lends or gives downloads, offers a product choice, or limits purchases per billing period needs an order and is refused (409 CUSTOMER_GROUP_MEMBER.NEEDS_ORDER). The team gives it through a €0 order with a manual price and free delivery instead.
  • An assigned member with purchase access buys at the member prices through a product line whose purchase is { type: "assigned_purchase_access", customer_group_member_id, entitlement_id }, and each limit allows its max_quantity. Purchase access follows whose groups count: a company location that holds its own group of the same offering can’t buy through its company’s member (409 CART.PURCHASE_ACCESS).
  • Assigning someone who holds another group of the same offering moves them: the new member is active and the old one cancelled in the same save.
  • A retry with the same id and the same request returns the member.

Members

interface CustomerGroupMember {
	id: string;
	store_id: string;
	customer_group_id: string;
	subject:
		| { type: "customer"; customer_id: string }
		| { type: "company"; company_id: string }
		| { type: "company_location"; company_location_id: string };
	type:
		| { type: "permanent" }
		| {
				type: "recurring";
				next_occurrence_index: number;
				payment_method: { payment_method_id: string; authorization: { by: Actor; accepted_at: EpochMilliseconds } } | null;
				renewal_end_at: EpochMilliseconds | null;
		  };
	status:
		| { type: "awaiting_activation" }
		| { type: "active" }
		| { type: "blocked"; cause: CustomerGroupMemberBlockCause; blocked_at: EpochMilliseconds }
		| { type: "paused"; cause: CustomerGroupMemberPauseCause; paused_at: EpochMilliseconds }
		| { type: "cancelled"; ended_at: EpochMilliseconds };
	access_end_at: EpochMilliseconds | null;
	created_at: EpochMilliseconds;
	updated_at: EpochMilliseconds;
}
GET /v1/stores/{store_id}/customer-group-members
SDK: sdk.eshop.customerGroupMember.find()
GET /v1/stores/{store_id}/customer-group-members/{id}
SDK: sdk.eshop.customerGroupMember.get()
GET /v1/stores/{store_id}/customer-group-members/{id}/current
SDK: sdk.eshop.customerGroupMember.getCurrent()
GET /v1/stores/{store_id}/customer-group-members/{id}/revisions
SDK: sdk.eshop.customerGroupMember.findRevisions()
GET /v1/stores/{store_id}/customer-group-members/{id}/orders
SDK: sdk.eshop.customerGroupMember.findOrders()
GET /v1/stores/{store_id}/customer-group-members/{id}/calendar
SDK: sdk.eshop.customerGroupMember.getCalendar()
GET /v1/stores/{store_id}/customer-group-members/{id}/purchase-access
SDK: sdk.eshop.customerGroupMember.getPurchaseAccess()
GET /v1/stores/{store_id}/customer-group-members/{id}/purchase-limits
SDK: sdk.eshop.customerGroupMember.findPurchaseLimits()
  • Only a recurring member renews: it holds the next period, the card renewals charge, and renewal_end_at, the moment renewals stop. A member’s type never changes.
  • Only a recurring member can be blocked or paused. A renewal that can’t be made blocks the member with its cause: period_missed, order_unpaid, payment_method_not_ready, company_not_active, company_location_not_active, purchase_order_number_required, entitlement_unavailable, no_tax_rule or tax_policy_mismatch. A pause is requested, with a reason and an optional resumes_at, or renewal_unpaid when a renewal’s retries ran out.
  • find filters by customer_id, company_id (the company and its locations) or company_location_id, and by group, offering and status. getCurrent answers the revision in force with its terms; findOrders the orders whose group line names the member; getPurchaseAccess what the member may buy at member prices in a catalog and channel, with each grant’s source (order or assigned) and the limits left.
  • When a member counts for prices, catalog access, promotions and shipping rates: a member who ordered counts while a confirmed group line covers now and before its access_end_at; an assigned member counts while it is active and before its access_end_at. So while a renewal’s payment is retried, rules naming the group stop applying until it is paid, and a member who cancelled keeps the period it paid for.
  • Whose groups count: a person’s own when they buy for themselves; a location’s own and its company’s on orders for that location (within one offering, the location’s own group wins); a company’s own on orders for the company.

Member Changes

Every change carries the member’s expected_updated_at, so a repeated change is refused once the first one applied. Changes that make a revision are reviewed first with the same request.

ChangeCallWho
end renewals (a customer inside a committed block ends at the block’s end)cancelteam and member
end at a given momentscheduleEndteam
pause renewals, with a reason and an optional resumes_atpauseteam and member
resume a paused or blocked member at its next safe periodresumeteam and member
skip the next periodskipNextteam and member
the card renewals chargeselectPaymentMethodteam and member
another group of the offeringreviewSwitch, then switchteam and member
quantity, choices, deliveries or billing addressreviewTerms, then changeTermsteam and member
take back a future changewithdrawRevisionteam and member
a purchase requirementreviewPurchaseRequirement, then changePurchaseRequirementteam
end at once and revoke what it bought from now onrevoketeam
move a requirement between two members of one company or locationtransferPurchaseRequirementteam
await sdk.eshop.customerGroupMember.reviewSwitch({
	store_id: storeId,
	id: member.id,
	expected_updated_at: member.updated_at,
	to_customer_group_id: basic.id,
	choices: [],
	reason: "Customer asked for the smaller box",
});

await sdk.eshop.customerGroupMember.switch({
	store_id: storeId,
	id: member.id,
	expected_updated_at: member.updated_at,
	to_customer_group_id: basic.id,
	choices: [],
	reason: "Customer asked for the smaller box",
});
  • The team’s cancel and scheduleEnd carry notify_customer and the email’s language. A cancellation of a recurring member with an order sends the store’s membership_cancelled email to the address its renewals use.
  • skipNext names the period it skips. It skips only a next period that has no order yet, outside a committed block and before renewal_end_at, and waives no debt.
  • A card belongs to the member’s subject; a card of another customer, company or location is refused. A team card selection that charges an owing renewal at once needs the payments permission as well as orders.
  • changeTerms applies from the next safe period. On the storefront, arky.eshop.customerGroupMember.getDeliveryOptions prices a changed delivery with the method’s rates first, and getProductChoices lists the variants a choice may take.
  • At most one accepted future change waits at a time; withdraw it before making another. Only a change that hasn’t started can be withdrawn.

Moving Between Groups

A member moves only to another group of its offering, and only at a price: the new group needs a price in a catalog the member may buy from. switch may name a catalog_id; it must sell in the market of the member’s first order, and a different tax mode refuses the move. A move keeps the member’s quantity and names the choices the new group needs.

  • An upgrade happens now. Between two recurring groups with the same cadence, to a higher price, when the new group gives something at once (a service, a digital variant or purchase access), the buyer checks out a cart line whose start is { type: "switch", customer_group_member_id }. The order charges the price difference, times the quantity, for the rest of the current period, the member moves at once, and what ships or is lent changes at the next renewal. An upgrade that is never paid is undone. switch refuses an upgrade with CUSTOMER_GROUP_MEMBER.UPGRADE_IS_BOUGHT.
  • Any other move between two recurring groups (down, at the same price, to a group that only ships or lends, or to another cadence) starts at the next renewal and charges nothing before it.
  • Between two permanent groups the team’s switch amends a member who ordered, from the start of the next calendar month in its schedule’s timezone.
  • Any other move, such as the buyer’s own move between permanent groups or a move between a permanent and a recurring group, is buying the other group (or the team assigning it). The save that activates the new member cancels the old one.
  • Rented items stay on the member that rented them until they are returned.

Storefront Members

GET /v1/storefront/customer-group-members
SDK: arky.eshop.customerGroupMember.find()
GET /v1/storefront/customer-group-members/{id}
SDK: arky.eshop.customerGroupMember.get()
POST /v1/storefront/customer-group-members/{id}/cancel
SDK: arky.eshop.customerGroupMember.cancel()
const mine = await arky.eshop.customerGroupMember.find({ status: "active" });
const member = mine.items[0];

const calendar = await arky.eshop.customerGroupMember.getCalendar({ id: member.id });
console.log(calendar.next, calendar.after_skip);

if (member.type.type === "recurring") {
	await arky.eshop.customerGroupMember.skipNext({
		id: member.id,
		expected_updated_at: member.updated_at,
		occurrence_index: member.type.next_occurrence_index,
	});
}
  • Without filters, find lists the signed-in customer’s own members. With company_id or company_location_id it lists that party’s members, for a customer with the view_company_customer_groups permission there. view_own_customer_groups shows the members whose first order the customer placed.
  • A customer changes their own members, and those of a company or location where they have manage_company_customer_groups, with cancel, pause, resume, skipNext, selectPaymentMethod, reviewSwitch, switch, reviewTerms, changeTerms and withdrawRevision, the same bodies as Admin without store_id.
  • The storefront gets the customer’s own copies (CustomerGroupMemberSelf): no staff names or internal reasons.

Renewals

Each period of a recurring member is an order Arky makes at the start of the period, under the revision for that period. It copies the first order’s buyer, purchase order number, market, channel, currency, tax mode and language, uses the accepted billing address, and charges the member’s card.

  • At most one renewal order is made per period. A renewal that costs money without a ready card blocks the member with payment_method_not_ready; a €0 renewal is free.
  • A failed charge is retried at retry_offsets_seconds within recovery_window_seconds. When the retries run out, the order follows unpaid_order (retain_debt keeps it unpaid and unfulfilled until paid; cancel_unfulfilled cancels its unfulfilled lines) and the member follows on_exhaustion: paused with renewal_unpaid, or cancelled.
  • The store’s renewal_upcoming email goes renewal_reminder_days before a renewal, at most once per period and never at or after the renewal itself; an edit to the setting reaches upcoming renewals. renewal_payment_failed follows the retry schedule.
  • A person’s renewal email goes to the member’s current verified email; a company or location member’s to the company’s contact email.
Note

Webhooks: customer_group.created, customer_group.updated and customer_group.deleted, and customer_group_member.activated, paused, blocked, resumed, cancelled, renewed, payment_failed, switched, renewal_skipped and payment_method_changed. A person member’s timeline records customer_group_member_activated, customer_group_member_cancelled and customer_group_member_switched.