Back to Arky

Promotions

Code-based discounts, their conditions and effects, promotion codes, and cart usage

A promotion is a discount rule: who gets it (conditions), what it does (effects), how it combines with others (stacking, priority) and how often it can be used. It applies only through its promotion codes: a buyer enters a code, and there are no automatic promotions. A promotion is a discount, not stored value.

Effects

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

const effects: PromotionEffect[] = [
	{
		type: "item_percentage",
		id: crypto.randomUUID(),
		target: { type: "categories", category_ids: [accessoriesCategoryId] },
		basis_points: 1_000,
	},
	{
		type: "delivery_fixed",
		id: crypto.randomUUID(),
		amounts: [
			{ amount: 500, currency: "eur" },
			{ amount: 1_000, currency: "bam" },
		],
	},
];
  • Effects are item_percentage, item_fixed, order_percentage, order_fixed, delivery_percentage, delivery_fixed and buy_x_get_y. Each has an id the app picks, unique within its promotion.
  • Item effects name a target: products, product variants, booking services, customer group offerings, customer groups, catalogs (the lines priced from them), categories, or all eligible items.
  • item_fixed takes its amount off each matching unit (scope: { type: "per_unit" }) or once, split across the matching units (scope: { type: "once_per_order" }). Order effects work once across all lines that take promotions, and delivery effects once across the cart’s deliveries.
  • buy_x_get_y repeats as often as the cart allows: each time buy.quantity matching units and get.quantity reward units are there, the cheapest reward units get its percentage off.
  • Percentages are basis points: 100 is 1%, 10_000 is 100%. Fixed amounts list one amount per currency, each currency once, in integer minor units.
  • Effects apply in three rounds: every applied promotion’s item effects, then their order effects, then their delivery effects. Within a round, promotions go from the highest priority down, and each works on what earlier discounts left, so a line never goes below zero.
  • Only lines priced from the cart’s catalog take promotions, and a manual price only when it allows them. Offer lines, purchase-access lines and a group line that switches a member take none. A promotion on a group or offering reduces the group line, never again the lines it generates.

Conditions

All conditions must hold together: signed_in, customer (verified customers only), customer_group, customer_group_offering, company, company_location, market, sales_channel and minimum_subtotal. A customer group condition holds for the buyer’s groups that count; a company condition holds for the company’s own carts and its locations’. minimum_subtotal lists one amount per currency and holds when the lines that take promotions add up to it, at their prices before any discount and without separately added tax.

All the fixed amounts and the minimum of one promotion list the same currencies. A promotion with fixed amounts or a minimum applies only in carts whose currency they list; a promotion with only percentages applies in any currency.

Create, Read, Update, Delete

POST /v1/stores/{store_id}/promotions
SDK: sdk.eshop.promotion.create()
GET /v1/stores/{store_id}/promotions
SDK: sdk.eshop.promotion.find()
PUT /v1/stores/{store_id}/promotions/{id}
SDK: sdk.eshop.promotion.update()
DELETE /v1/stores/{store_id}/promotions/{id}?expected_updated_at=
SDK: sdk.eshop.promotion.delete()
POST /v1/stores/{store_id}/promotion-codes
SDK: sdk.eshop.promotionCode.create()
GET /v1/stores/{store_id}/promotion-codes
SDK: sdk.eshop.promotionCode.find()
GET /v1/stores/{store_id}/promotion-codes/by-code/{code}
SDK: sdk.eshop.promotionCode.getByCode()
const promotion = await sdk.eshop.promotion.create({
	store_id: storeId,
	id: crypto.randomUUID(),
	key: "summer-20",
	blocks: [{ id: crypto.randomUUID(), key: "name", type: "localized_text", value: { en: "Summer sale" } }],
	conditions: [{ type: "minimum_subtotal", amounts: [{ amount: 5_000, currency: "eur" }] }],
	effects: [{ type: "order_percentage", id: crypto.randomUUID(), basis_points: 2_000 }],
	stacking: { type: "exclusive" },
	priority: 10,
	max_uses: 1_000,
	max_uses_per_customer: 1,
	schedule: { type: "scheduled", starts_at: Date.UTC(2026, 5, 1), ends_at: Date.UTC(2026, 8, 1) },
	status: { type: "active" },
});

const code = await sdk.eshop.promotionCode.create({
	store_id: storeId,
	id: crypto.randomUUID(),
	promotion_id: promotion.id,
	code: "SUMMER20",
	max_uses: null,
	status: { type: "active" },
});
  • The key is unique in the store (409 PROMOTION.KEY_TAKEN), and so is the priority (409 PROMOTION.PRIORITY_TAKEN). A code is unique in the store (409 PROMOTION_CODE.CODE_TAKEN).
  • blocks hold what a storefront or an email shows for the promotion, such as its name and terms. Every email that carries an order lists each promotion the order applied with its current blocks at order.promotions, read when the email is made and never stored on the order.
  • schedule is { type: "always" } or { type: "scheduled", starts_at, ends_at }, where ends_at may be null for “from then on”. Codes share their promotion’s dates.
  • A promotion is eligible while it is active, inside its schedule and under max_uses, its conditions hold, and the cart carries one of its active codes under that code’s own max_uses.
  • max_uses_per_customer counts per email: a signed-in customer by their verified email, a guest by the checkout’s contact email, so a guest checkout using such a promotion needs one.
  • Uses are counted when checkout is accepted. An order the buyer never pays gives its uses back once all its lines end expired or payment failed; any other cancellation gives nothing back.
  • An update sends the expected_updated_at it read and the complete conditions and effects.
  • A code can be deleted while only carts name it; deleting takes it off those carts. A promotion can be deleted while only its own codes name it; a promotion an order names is archived instead.
  • Webhooks post promotion.created, promotion.updated, promotion.deleted, promotion_code.created and promotion_code.deleted. Uses taken or given back don’t post promotion.updated.

In The Cart

The storefront sets the codes on the cart and reads the discount from the quote:

const cart = arky.eshop.cart({ buyer: { type: "customer" }, catalog_id: catalog.id });
await cart.setPromotionCodes({ promotion_codes: ["SUMMER20"] });

const quote = await cart.quote({ contact_email: "ana@example.com" });
console.log(quote.totals.discount, quote.promotions);
  • A cart holds either one exclusive promotion’s code or any number of combinable promotions’ codes. Adding a code that breaks this is refused with 409 PROMOTION.STACKING_CONFLICT, and so is a quote or checkout whose codes break it, for example after a stacking edit, until the buyer removes one.
  • A code limited to one use per customer counts a guest by the contact email, so a guest’s quote sends contact_email once it is known; without it such a code’s quote is refused with 409 CART.PROMOTION_EMAIL_REQUIRED. The checkout flow sends it after setContact.
  • Storefront code never computes its own discounted total. The quote and checkout check each promotion against the cart, and the order keeps each promotion it applied with its key and code.

To show a promotion’s name or terms on the storefront, read its blocks by id:

GET /v1/storefront/promotions/{id}
SDK: arky.eshop.promotion.get()
const shown = await arky.eshop.promotion.get({ id: quote.promotions[0].promotion_id });

The storefront reads an active or archived promotion’s id, key and blocks only, never its conditions or counters.