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:
| Group | Price | What it gives | Members |
|---|---|---|---|
wholesale | listed by no catalog | the Wholesale catalog, whose access names the group | a company the team assigned |
weekly-box | €40 a month | a box delivered every period | one company location, paying with its own card |
club | €9 a month | member prices on chosen products and a gift every month | a 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;
}/v1/stores/{store_id}/customer-groups sdk.eshop.customerGroup.create() /v1/stores/{store_id}/customer-groups sdk.eshop.customerGroup.find() /v1/stores/{store_id}/customer-groups/by-key/{key} sdk.eshop.customerGroup.getByKey() /v1/stores/{store_id}/customer-groups/{id} sdk.eshop.customerGroup.update() /v1/stores/{store_id}/customer-groups/{id}?expected_updated_at= 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, orrecurringwith a cadence.cadence,commitment,on_exhaustionandunpaid_orderare agreement terms: each member keeps them as accepted.retry_offsets_seconds,recovery_window_secondsandrenewal_reminder_daysare 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,
renewcommits the next block,renew_oncecommits one more block and then stops,continue_without_termrenews without a commitment andstoprenews 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_weightthat 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.deliveryisnone(services and digital variants),onceper period in one window, orrepeatingon its own cadence inside a recurring period,per_period(spread over the windows) orper_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 giveper_periodfewer 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:
drafthas no members.activecan be bought and assigned.closedtakes no new members; existing members keep counting and renewing.archivedis likeclosedand is hidden from pickers. A purchase or move enters a group only while it isactive, inside its optionalstarts_at/ends_at, and, when an offering lists it, while that offering isactive. The plan’scustomer_groupslimit 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
/v1/stores/{store_id}/customer-group-offerings sdk.eshop.customerGroupOffering.create() /v1/stores/{store_id}/customer-group-offerings sdk.eshop.customerGroupOffering.find() /v1/stores/{store_id}/customer-group-offerings/by-key/{key} sdk.eshop.customerGroupOffering.getByKey() /v1/stores/{store_id}/customer-group-offerings/{id} sdk.eshop.customerGroupOffering.update() /v1/stores/{store_id}/customer-group-offerings/{id}?expected_updated_at= 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).
activesells its groups, each still under its own status and dates.closedsells nothing and accepts no move into its groups, while existing members keep counting, renewing and leaving and the team can still assign.archivedisclosedand 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" },
});
getreads a group or an offering by{ id }, andgetByKeyby{ key }. The storefront offering read answers onlyactiveofferings.choicesnames one variant for each product entitlement that lists several;arky.eshop.customerGroup.getProductChoiceslists them with their option labels in a language.startison_acceptance,scheduledwith astarts_at, orswitchfor an upgrade (below).- A group with deliveries asks for them with
setFutureDeliveries, quoted first withquoteFutureDeliveries. - 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’ssaved_card_termstext the buyer accepted inpayment_method_terms. Without it checkout is refused with409 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 inpayment_method_setupandmountPaymentMethodSetupmounts; Monri saves cards only from a paid checkout. - The member is
awaiting_activationuntil that line is confirmed, thenactive. 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.joindoes the same after the email code; see Newsletters.- For a company or location, buying a group needs the
place_ordersandjoin_customer_groupscompany permissions there.
Assigning A Member
/v1/stores/{store_id}/customer-group-members 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
activegroup with no order and no price. The member startsactive, ispermanent, has no revision, card or renewal, and gets the live group’s entitlements. access_end_atends the membership at that moment, such as “VIP until 31 December”;nullkeeps 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
purchaseis{ type: "assigned_purchase_access", customer_group_member_id, entitlement_id }, and each limit allows itsmax_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
activeand the old onecancelledin 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;
}/v1/stores/{store_id}/customer-group-members sdk.eshop.customerGroupMember.find() /v1/stores/{store_id}/customer-group-members/{id} sdk.eshop.customerGroupMember.get() /v1/stores/{store_id}/customer-group-members/{id}/current sdk.eshop.customerGroupMember.getCurrent() /v1/stores/{store_id}/customer-group-members/{id}/revisions sdk.eshop.customerGroupMember.findRevisions() /v1/stores/{store_id}/customer-group-members/{id}/orders sdk.eshop.customerGroupMember.findOrders() /v1/stores/{store_id}/customer-group-members/{id}/calendar sdk.eshop.customerGroupMember.getCalendar() /v1/stores/{store_id}/customer-group-members/{id}/purchase-access sdk.eshop.customerGroupMember.getPurchaseAccess() /v1/stores/{store_id}/customer-group-members/{id}/purchase-limits sdk.eshop.customerGroupMember.findPurchaseLimits() - Only a
recurringmember renews: it holds the next period, the card renewals charge, andrenewal_end_at, the moment renewals stop. A member’s type never changes. - Only a
recurringmember can beblockedorpaused. 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_ruleortax_policy_mismatch. A pause isrequested, with a reason and an optionalresumes_at, orrenewal_unpaidwhen a renewal’s retries ran out. findfilters bycustomer_id,company_id(the company and its locations) orcompany_location_id, and by group, offering and status.getCurrentanswers the revision in force with its terms;findOrdersthe orders whose group line names the member;getPurchaseAccesswhat the member may buy at member prices in a catalog and channel, with each grant’ssource(orderorassigned) 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 isactiveand before itsaccess_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.
| Change | Call | Who |
|---|---|---|
| end renewals (a customer inside a committed block ends at the block’s end) | cancel | team and member |
| end at a given moment | scheduleEnd | team |
pause renewals, with a reason and an optional resumes_at | pause | team and member |
| resume a paused or blocked member at its next safe period | resume | team and member |
| skip the next period | skipNext | team and member |
| the card renewals charge | selectPaymentMethod | team and member |
| another group of the offering | reviewSwitch, then switch | team and member |
| quantity, choices, deliveries or billing address | reviewTerms, then changeTerms | team and member |
| take back a future change | withdrawRevision | team and member |
| a purchase requirement | reviewPurchaseRequirement, then changePurchaseRequirement | team |
| end at once and revoke what it bought from now on | revoke | team |
| move a requirement between two members of one company or location | transferPurchaseRequirement | team |
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
cancelandscheduleEndcarrynotify_customerand the email’slanguage. A cancellation of a recurring member with an order sends the store’smembership_cancelledemail to the address its renewals use. skipNextnames the period it skips. It skips only a next period that has no order yet, outside a committed block and beforerenewal_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
paymentspermission as well asorders. changeTermsapplies from the next safe period. On the storefront,arky.eshop.customerGroupMember.getDeliveryOptionsprices a changed delivery with the method’s rates first, andgetProductChoiceslists 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
startis{ 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.switchrefuses an upgrade withCUSTOMER_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
switchamends 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
/v1/storefront/customer-group-members arky.eshop.customerGroupMember.find() /v1/storefront/customer-group-members/{id} arky.eshop.customerGroupMember.get() /v1/storefront/customer-group-members/{id}/cancel 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,
findlists the signed-in customer’s own members. Withcompany_idorcompany_location_idit lists that party’s members, for a customer with theview_company_customer_groupspermission there.view_own_customer_groupsshows 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, withcancel,pause,resume,skipNext,selectPaymentMethod,reviewSwitch,switch,reviewTerms,changeTermsandwithdrawRevision, the same bodies as Admin withoutstore_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_secondswithinrecovery_window_seconds. When the retries run out, the order followsunpaid_order(retain_debtkeeps it unpaid and unfulfilled until paid;cancel_unfulfilledcancels its unfulfilled lines) and the member followson_exhaustion: paused withrenewal_unpaid, or cancelled. - The store’s
renewal_upcomingemail goesrenewal_reminder_daysbefore 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_failedfollows 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.
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.