Skip to content

Powered by Grav CMS + Helios Theme

Products and catalog

This page explains how to create products, organize them into categories, and keep stock and images up to date.

Products are database rows in kahunacart_products, not Grav pages. You edit them in the admin or over the API, and the storefront renders them through Twig templates you can override.

Before you begin

  • Products, variants, options, images and downloadable files require the kahunacart.products.manage permission.
  • Categories and tags require the kahunacart.settings permission.
  • The storefront lives under the route config key, /shop by default. Product URLs are {route}/product/{slug}, category URLs are {route}/category/{slug}.

Product fields

A product carries the descriptive and classification data. The price lives on the variant.

Field What it does
title Required.
slug The public URL segment, /shop/product/{slug}. Derived from the title when empty, and slugified either way. Must be unique; a clash is refused with a 409.
type The product type: physical, digital, grouped, external, or one an add-on registers. It decides what happens when the product is added to a cart. A digital product skips shipping and is eligible for download grants. See Product types.
status draft, published, or archived. Only published products appear on the storefront.
published_at Stamped the first time a product goes published. Drives the storefront's "newest first" ordering.
release_at When the product comes out. Until then it is a pre-order. Empty for something you sell now.
summary Short blurb, shown on listing cards.
description Long copy, rendered through Markdown on the product page. See Markdown, not HTML.
tax_class Defaults to standard. Matched against the rates in the customer's tax zone.
price_includes_tax Whether this product's price already contains tax. Three states. Unset means "follow the region, then the currency, then the store-wide setting", and is every product until somebody opens the field. 0 means "this one is priced net whatever the store says". It is the top of the inclusivity resolution order and decides whether a line has baked-in tax to strip for an out-of-zone customer.
shipping_class Optional. Matched against a shipping method's per-class surcharges.

Markdown, not HTML

Product and category descriptions render as Markdown with raw HTML escaped. Link and image targets that execute rather than navigate (javascript:, vbscript:, data:) become #, and a <script> typed into a description renders as the word <script>. Everything Markdown does still works: headings, lists, tables, emphasis, links and images.

Note

No config key turns HTML back on. For markup Markdown cannot express, override kahunacart-product.html.twig in your theme and render it there.

Create a product

Every product gets one default variant, which holds the price.

  1. In the admin sidebar, click KahunaCart, then Products.
  2. Click New product.
  3. Enter a Title. Leave Slug empty to derive it from the title.
  4. Set Type, and Status to published. Leave Type on Physical unless the product is one of the other product types.
  5. Under Pricing & stock, enter a Price, and a Compare-at price if you want a strike-through "was" price.
  6. Under Classes, set Tax class and Shipping class if the defaults do not apply.
  7. Under Categories and Tags, file the product.
  8. Click Save.

The product is live at /shop/product/{slug}. No cache clear is needed.

Tip

Getting started has a curl example that creates a published product with a priced default variant in one call.

Product types

A product's type decides what happens when a shopper adds it to a cart. Four types ship with KahunaCart, and an installed add-on can register more.

Type Ships Holds stock Can be bought Adding it to a cart adds
physical Yes Yes Yes The product itself, as one line. The default.
digital No Yes Yes The product itself, as one line. Eligible for download grants.
grouped Yes No Yes One line per child product, and no line for the parent.
external No No No Nothing. External products cannot be added to the cart.

A physical or digital product is the ordinary product: one product, one line, priced by its variants. One variant or a dozen makes no difference to its type.

The bookings add-on registers a fifth, booking. A product of that type draws a calendar on its page instead of an Add to cart button, and the rule behind it — how long it takes, who or what it needs, when it can be booked — is written on the Bookings screens rather than on the product. Everything else about it is an ordinary product: the name, the price, the options, the picture, the category, the tax class and who may buy it. It does not ship and it holds no stock, and its line is stamped as a service rather than as a digital good, which is what keeps it out of the digital-goods tax rules and off a digital-goods-only payment provider. See Bookings.

Which fields each type adds. physical, digital and grouped add none. external adds two: Link, where the button sends the shopper, and Button text, which falls back to "View" when empty. A type an add-on registers declares its own fields, shown in their own panel under the type picker.

The type also decides which existing fields are read. Weight and shipping class are read only by a type that ships. Stock quantities are counted and deducted only for a type that holds stock. Attached files grant downloads only for a type that does not ship.

An external product is a listing, not a purchase. It has no buy button; the storefront draws the merchant's link in its place. It still appears in the shop grid, on category pages and in search. Unpublish one to take it out of the listings.

Note

A link typed into Link is checked when the page is rendered, by the same rule product descriptions follow: javascript:, vbscript: and data: become #. See Markdown, not HTML.

Set a product's type

  1. Open the product. Type sits with Title and Slug under Details.
  2. Choose a type. Extra fields the type declares appear in their own panel further down the editor.
  3. Click Save.

Change a product's type

Changing a type is reversible and deletes nothing. A physical product turned digital keeps its weight and shipping class, a grouped product turned physical keeps its children, and a product turned external keeps its variants, stock and price. Only which of those the store reads changes.

The new type What stops or starts applying
Does not ship Weight and shipping class stop being read, and no postage is charged.
Ships Weight and shipping class are read again. Attached files are still delivered, because downloads go to the buyers of any product.
Holds no stock This product's own variants stop being counted and deducted. Their numbers are kept.
Holds stock Its variants are counted and deducted again.
Expands into children Adding the product adds its children instead of itself.
Does not expand The children stop expanding. The rows are kept.
Cannot be bought alone The product can no longer be added to a cart.
Declares extra fields Values already stored for a type's fields are kept, so turning the type back brings them back.

Over the API, a PATCH naming the type answers with a type_change object listing these effects for that product, including the warnings: becoming shippable with no weight on any variant, becoming digital with nothing attached, expanding into children when none are declared, and appearing in a customer's open cart when the change is made.

One change is refused. A product cannot be changed to a type this installation has no code for. Changing away from such a type is always allowed.

What a type sells

Every type says what it sells: goods, digital or service. Tax and payment read that word. Shipping does not, because a service needs no parcel any more than a download does.

A type that says nothing is goods when it ships and digital when it does not, so the four built-in types behave as they always have. Only an add-on selling work, such as Bookings, declares service. That keeps a haircut out of the digital-goods tax rules and off a payment provider that only sells downloads.

A theme can ask the same thing. kahunacart_product_type(product).nature answers the word for the product on the page, so a "Digital download" badge goes on downloads and not on appointments. Do not badge off ships: it is false for both.

A type whose add-on is switched off

Uninstalling the add-on that declares a type does not damage the products using it. They keep their type, their fields and their place in the catalog, and stay listable and editable. They cannot be added to the cart; an error message names the missing type, and the Type picker keeps the type with the reason beside it. Re-enable the add-on and the products work again.

Kits and grouped products

A grouped product names other products as children, each with a quantity. A kit is the same relation rendered as one item rather than as a set. Both put the children in the basket:

  • Adding the parent adds one line per child, and no line for the parent.
  • Each child line is its required quantity multiplied by the quantity of the parent bought.
  • Each child line is priced at the child's own price and holds the child's own stock, through the usual deduction.

A child names one product, and which of its variants it sells: its default variant, one you name, or whichever the shopper picks. See Which variant a part sells. A product may name up to 50 children.

The difference is the page, not the cart. The shipped partial lists the children and draws one buy button that adds the whole set. A grouped product links each child; a kit names each child without a link.

A child that cannot be bought stops the whole add, with a refusal naming the child.

Nesting is one level. A child that is itself a grouped product or a kit is refused when the relation is saved, and the cart refuses to expand one if a row got there another way.

Important

A kit adds its children at their own prices, and the product's own price is never charged. To give the set a price of its own, tick Sell as a set. See Bundle price. A single variant that stands in for several others, with its own SKU and stock, is a different thing: see Bundles.

kit is a rendering an add-on's type can choose. grouped is the type that ships with KahunaCart and names children; both use the same table and the same expansion.

List what a product contains

  1. Open the product, or click New product, and set Type to a type that expands, such as Grouped.
  2. Find What is in it, under the type's own fields. The panel shows on a new product as well as an existing one, and only for a type that expands.
  3. Pick a product in Add a product… and click Add.
  4. Set How many on the row. Use Remove to take a child off.
  5. If a child has more than one variant, choose its Variant: its default, one variant, or Shopper picks. See Which variant a part sells.
  6. To stop shoppers changing one part in the cart, tick Lock the parts in the cart. See In the cart.
  7. To sell the parts for less than they cost one by one, tick Sell as a set and type the Bundle price. See Bundle price.
  8. Click Save. The parts, the choices above and the bundle price are saved with the product, so a new product needs nothing saved first.

The picker lists only products that do not themselves expand, which keeps the one-level rule.

Which variant a part sells

A part that has only one variant sells that variant, and there is nothing to choose. A part whose product has more than one variant gets a Variant choice on its row in What is in it:

  • Default (Medium) sells whatever the part's default variant is, and follows it if you later change the default. This is the choice every part has until you change it.
  • One variant by name always sells that one, say the red easel, whatever the default is.
  • Shopper picks puts a choice on the set's buy button, so the shopper says which colour or size they want.

A part set to Shopper picks shows a dropdown above the buy button, one for each part that picks. It starts on "Choose one", and the shopper can't add the set until they choose. A sold-out variant is in the list but can't be chosen, with the store's sold-out wording next to it. A backordered variant can be chosen and says it's on backorder, the same as on a product page. If the set isn't sold as a set and its variants cost different amounts, each choice shows its price. It works without JavaScript.

One pick covers every unit of the part. A part of "2 × Easel" with Walnut picked is two walnut easels.

Here is a worked example. Priya sells a "Starter easel set": an easel that comes in Birch, Walnut and Black, and two canvases. She makes a grouped product, adds the easel and the two canvases, and sets the easel's Variant to Shopper picks. The canvases stay on Default, because they only come one way. The page shows an Easel dropdown. Black is sold out, so it's greyed. A shopper chooses Walnut and clicks add, and the cart gets a Walnut easel and two canvases under one set row.

If the shopper clicks add without choosing, nothing goes in the cart and they're told Pick which "Easel" you want. The cart doesn't pick the default for them, because they didn't choose it. The other refusals are rarer, because the shipped dropdown only offers what the cart will accept:

  • The pick isn't one of that part's own variants, or isn't a plain variant number: That is not one of the choices for "Easel".
  • The pick is for something that isn't a part the shopper picks in this set: That choice is not part of "Starter easel set".
  • The variant is sold out, or can't be bought at that quantity: a sentence naming the part. Nothing is added.

Every refusal stops the whole add. If the part is down to a single variant by the time the shopper adds the set, that variant is used and nothing is asked.

Two parts that come to the same variant become one line. If the set holds a fixed Birch easel and a part where the shopper picks an easel, and they pick Birch, the cart gets one line of two Birch easels and counts it as one set.

A product can be in a set once as its default, once as a part the shopper picks, and once more for each variant you name.

Shopper picks work everywhere a set can be added except the till. The storefront API, agent checkout, counter sales and orders you enter by hand all take the picks. The Point of Sale till can't pick a part's variant yet, so it can't sell a set that has a part the shopper picks.

Over the API, a part the shopper picks is shopper_picks: true on its entry in kit_items. See Products in the REST API.

In the cart

The cart shows a set as one row with its parts under it. The set's row has its picture, its name linking to the product, what one set costs at the parts' own prices, a quantity box for how many sets, what its lines add up to, and one × that takes out the whole set. The parts follow it, indented, each with its own picture, price and quantity. A bundle price shows up in the totals below as its own line, as it does for parts added one at a time.

  • The set's quantity box adds or takes away whole sets. Each part moves by what one set needs. An easel set with one spare panel, raised to two sets, becomes two easels and one spare panel. A part that would drop to nothing is taken out.
  • Parts can be changed unless you lock them. By default every part keeps its own quantity box and ×. If a shopper turns one part below a full set, the set loses its quantity box and says "Not a full set" under its name. Its × still takes out whatever is left, and a bundle price stops applying, because the cart no longer holds a full set.
  • Lock the parts in the cart makes the set's row the only control. The parts show their quantity, with no box and no ×. Changing one part on its own is refused with "That is part of "Starter easel set". Change or remove the whole set instead." That holds in the cart page, the storefront API and an agent checkout. Use it when the set only makes sense whole, or when a bundle price should never be broken by accident. Over the API it's lock_parts. A kit's parts are always locked.
  • The setting is fixed when the set goes in the cart. Ticking or unticking it affects carts from then on. A set already in a basket keeps the rule it was added under.
  • A part and the same thing bought alone are two lines. A spare panel added from its own page is a line of its own, not a third panel folded into the easel. Adding the set again raises the set's lines together.
  • A set is its product plus the shopper's picks. A Walnut easel set and a Birch easel set are two rows in the cart, each with its own quantity box and ×, and each row names its picks under its title. Adding Walnut again raises the Walnut set. A part every set shares, like the canvas, is a line in each set. A set with no picking parts is just its product, as it always was.

Parts added one at a time aren't drawn as a set, because nobody added them as one. A bundle price still applies to them. The set's name is saved in the language the shopper was reading, so renaming the product later doesn't rename a set already in a basket.

Nothing else changes. Price, stock, bundle price, tax, refunds and returns still work line by line. The checkout, the receipt and the emails list the parts as separate lines, and a cart from before this existed shows its lines on their own.

Theme developers get the cart as cart_rows. See Storefront → The cart.

Bundle price

A grouped product or a kit can sell its parts as a set for less than they cost one by one. Tick Sell as a set in the What is in it panel and type the Bundle price. The product's own price now means one full set. The panel adds up the parts for you ("Parts add up to £38.50 · the set saves £10.00"), so you see the saving before you save. Leave the box unticked and nothing about pricing changes.

On a store that sells in more than one currency there is a Bundle price box for each one, the way a variant has a price for each. A currency with no bundle price has no set price: the cart charges the parts at their own prices there, and the page shows what they add up to.

The price follows what is in the cart, not how it got there. Whenever the cart holds a full set of the parts, each at the quantity one set needs, that set costs the bundle price. A shopper who uses the group's button and one who adds the parts one at a time pay the same. Nothing ties the lines together, so take a part out and the discount goes with it on the next recalculation. Every part is still its own line at its own price, and it takes its own stock.

Here is a worked example. Nigel sells a tie clip at £23.50 and a "wonky" second at £15.00. Bought together, he wants the wonky one to cost £5.00, so the pair comes to £28.50. He makes a grouped product called "Clip and a wonky one", adds both clips, ticks Sell as a set and types 28.50. The page shows £28.50 with £38.50 struck through. In the cart:

The cart holds Sets Total
1 clip, 1 wonky 1 £28.50
1 clip, 2 wonky 1 £28.50 + £15.00 = £43.50
2 clips, 3 wonky 2 £57.00 + £15.00 = £72.00
2 wonky 0 £30.00

Sets are counted whole. The cart takes each part, divides what it holds by what one set needs, rounds down, and the smallest answer is the number of sets. Two wonky clips on their own are no set at all, so they get nothing.

Both clips come off stock for every set. The cart and the receipt show the saving as one row named after the group, "Clip and a wonky one −£10.00", however many lines it covers. Behind that row the saving is shared across the lines of the set in proportion to their prices, so tax, refunds and returns follow it line by line, the way they follow a sale. Refund the wonky clip alone and Nigel's customer gets back £15.00 less its £3.90 share of the saving, which is £11.10, plus the tax on that.

A part is matched by its variant. A part added as "the default variant" is matched by whichever variant is the default. A free gift never counts towards a set, and neither does a price the shopper named.

A part the shopper picks counts any variant. With a bundle price on Priya's easel set, a Walnut easel and a Birch easel each complete a set, and the set costs the bundle price whichever one was picked.

A set with a picking part is counted one set at a time. The variants of a picking part can cost different amounts, so each set takes the units that are worth most to it, and the same cart comes to the same total in whatever order it was filled. A set only forms when it saves more than the sales those units would lose. Say the bundle is £55 for any easel and a £15 canvas, and the cart holds a £30 easel, a £50 easel and two canvases. The £50 easel and a canvas make a set at £55, and the £30 easel and the other canvas cost their own £45, so the cart comes to £100.

A part you name keeps its units for the set that names it. If one set asks for the red easel and another lets the shopper pick any easel, a red easel goes to the set that asked for it, and a blue one goes to the any-easel set. Both sets form. If the first set can't form anyway, because there's no brush in the cart, the red easel is free for the other set to take.

A cheap pick never raises the price. If the picked variants cost so little that the parts come to no more than the bundle price, say a clearance colour, nothing comes off and the parts cost their own prices.

A part in two sets. Each unit counts in one set only. When two set-priced products want the same part, the one that saves most per set is settled first, then the lower product id, so the same cart always comes out the same. The shopper gets the bigger saving whichever product's page they added from, which can be a little more than that page promised, never less.

With a sale running. A unit never gets both a sale and the bundle price. For the units a set would take, the cart compares what the set saves with what those units' sales save, and the customer gets the better of the two. If the set wins, those units lose their sale and any left over keep theirs. If the sales save as much or more, no set is formed. See Sales.

With a coupon. A coupon comes off after the set, as it does after a sale: 10% off the £28.50 set is £2.85. A code that skips items already on sale skips the set's lines too. See Coupons.

With a price list. A price list never changes the bundle price. It changes what the parts cost, so a trade customer whose parts are already cheaper saves less than a retail customer, or nothing at all. A bundle price never raises a total. See Pricing.

When the price does not apply. A bundle price at or above what the parts cost takes nothing off, and a bundle price of zero counts as none, because a free set is never what ticking the box means.

What the shopper sees. A grouped product or a kit never shows its own price. Sold as a set, it shows the bundle price with what the parts add up to struck through. Otherwise it shows what the parts add up to. For a part the shopper picks, that total uses the part's default variant. The product page, the listing card, the hero and compact sections and the product feeds all work this way. See Storefront and Twig functions for themes.

Importing. The catalog file and the CSV importer don't carry a product's parts or Sell as a set yet. Set them in the admin or over the API.

A bundle variant is a different thing. It is one line at its own price that stands in for its parts, and it holds none of their stock. A store can have both. With bundle suggestions on, the cart offers to swap parts for a bundle variant only when that comes out cheaper than the basket as it is, set prices included.

Variants

Every product has at least one variant, created automatically as the default. The price lives on the variant, not the product. A single-price product shows no variant picker; a product with several variants renders a <select> in the buy button.

When a variant editor is open, click Save variant or close it before using the product’s Save changes. The product save asks you to do that first, so it cannot discard the variant prices you typed. A refused amount keeps its message beside the save bar and focuses its box.

Field What it does
sku Optional. Copied onto the order line at add-to-cart time.
title Optional. Appended to the product title on the order line as Product — Variant.
price_minor The price, in minor units, in the store's base currency. Over the API, send price as a decimal string.
compare_price_minor A "was" price for strike-through display. Over the API, send compare_price as a decimal string, or empty to clear it.
currency_amounts Only on a store that sells in more than one currency: the variant's prices in the other currencies, {"EUR": {"price": "25.50", "compare_price": "30.00"}}, each a decimal string in that currency. A priced variant with no entry is not sold in that currency. A variant priced at 0 in the base currency is free in the other currencies too unless a typed amount says otherwise; this applies to its own price only, never its compare-at price. Settable over the API the same way, an empty string removing an amount. See Pricing a product in another currency.
is_default Exactly one variant carries this. It prices the listing and preselects in the buy button.
position Sort order within the product. Defaults to the order the variants were sent in.
stock_managed 0 (default) means infinite stock. 1 turns stock tracking on.
stock_qty The count. Only read when stock_managed is 1.
backorders deny (default) or allow.
backorder_note What the shopper is told while this variant is on backorder, up to 190 characters, such as "Expected to ship late October". Null uses the store's wording. See Telling the shopper it is on backorder.
is_digital 1 makes this variant of a physical product digital: Digital (no shipping) in the variant editor. A line of it needs no parcel, weighs nothing, asks for no address when everything in the bag is digital and is taxed, fulfilled and returned the way a digital product's line is, like a record's Download format beside its pressings. 0 (default) ships with its product. Shown only on products whose type ships. Variants generated from options don't inherit it. See Digital variants.
weight_grams Read by weight-based shipping. Order weight is the sum of qty × variant weight, matched against a method's weight bands. Zero contributes nothing.
option_value_ids Read-only. The values this variant combines, in axis order, or []. See Options.
option_label Read-only and derived: the combination spelled out, "M / Navy", or "".
bundle_of The variants this one stands in for, one of each. Send a list of variant ids to make it a bundle, or [] to make it an ordinary variant again. Leave it out to leave it alone. Every read hands it back as a list of {id, product_id, product_title, title, sku, label}, empty for an ordinary variant. See Bundles.

Note

weight_grams has no field in the admin product editor. Set it over the API or through a catalog import.

A product must always keep at least one variant. Deleting the variant that holds is_default promotes a sibling first.

The product editor's variants table prints each variant's id under its name, for the API routes and MCP tools that name a variant by id.

The variant's price is what every other price mechanism works from: a sale, a priced add-on, and the quantity tiers and price lists in Pricing. Price lists have no storefront or checkout effect in this release.

Bundles

A bundle is an ordinary variant with an ordinary price that stands in for two or more other variants. Say you sell a "Photos" pack at $149 and a "Video" pack whose "Video only" variant is $149: a "Photos + Video" variant at $249 can replace both. You say so with its Bundle of list.

  1. Open the product that sells the bundle and find its variant under Variants.
  2. Click Edit on the bundle variant.
  3. In Bundle of, search for the variants it replaces by product title, variant title or SKU, and pick each one.
  4. Click Save variant.

Nothing else changes. The bundle sells, holds stock, is taxed, refunded and delivered as the one variant it is. Its parts are not added to the basket with it, and their stock is not touched. The list is there for the cart: with bundle suggestions turned on, a shopper who has every part in the basket is offered the bundle instead, when it works out cheaper for them.

The list is checked when you save it. It needs at least two variants, from any products, each once, none of them the bundle itself, and every one has to exist. Deleting a variant takes it out of every list it was in, and deleting a bundle's product forgets its list.

Options

An option is a named choice with labelled values, in two kinds. An axis picks a variant: Size, Colour, Material. An add-on does not: it is an extra a shopper asks for at the buy button, and it can carry a price. This section covers axes; see Priced add-on options for the other kind.

A product may define up to three option axes. A variant becomes the combination of one value from each axis, and keeps its own price, SKU, stock and weight. Stock, holds, tax, shipping and checkout treat a combination as a plain variant.

A product with no axes keeps one variant, or several with free-form titles, and a plain <select> on the storefront.

Axis and value fields

Field What it does
name Free-form: Size, Colour, Length. Two axes on one product cannot share a name.
widget dropdown (default), pills, or swatches. Anything unrecognized falls back to dropdown.
position Axis order. Decides how a combination reads: M / Navy rather than Navy / M.

A value carries a label and an optional color as #rrggbb. Shorthand is expanded, and anything that is not a hex colour is stored as nothing. A value with no colour renders as a pill even on a swatch axis.

Values have stable ids, so reordering S, M, L into L, M, S touches no variant, and renaming Navy to Midnight renames every variant wearing it.

Note

An option value can carry its own picture, chosen from the product's gallery. A variant with no picture of its own shows the first selected value that has one.

Add an option axis

  1. Open the product and scroll to Options & variants.
  2. In New option, enter the axis name, for example Size.
  3. Choose a Storefront widget.
  4. In Values, type the values comma separated, for example S, M, L, XL.
  5. Click Add option.

The axis appears with its values as chips. Repeat for up to three axes.

Generate the variant matrix

POST /kahunacart/products/{id}/options/generate works out the cartesian product of the axes and reports what it would do. It is a dry run unless the body says {"apply": true}, and even then it merges rather than syncs.

Case What happens
Existing combination Left alone. Its price, SKU, stock and weight are preserved.
Missing combination Created, inheriting price, stock policy and weight from the default variant.
Orphan A variant whose combination the axes no longer describe. Listed, never deleted.
Unassigned variant A variant from before the product had axes. Listed separately, not deleted.

In the admin:

  1. Open the product and scroll to Options & variants.
  2. Click Generate variants… to preview the plan.
  3. Review the totals and the combinations listed under To create.
  4. Click Apply to create the missing variants.

Orphans and unassigned variants stay put. Remove one with Delete on its row, or give it a combination with Assign.

Two variants of one product cannot hold the same combination, stored as a sorted key in option_key and unique per product. A variant with no combination stores NULL, which the database treats as distinct, so an axis-less product can keep as many variants as it likes.

Priced add-on options

An add-on is an extra the shopper asks for at the buy button: gift wrap, an engraving, a warranty, a gift note. It never creates a variant, and each choice can carry a price that is added to the line.

Kind What it does Cap per product Can be priced
Axis Picking a value picks a variant 3 No. The variant carries the price.
Add-on An extra asked for at the buy button 10 Yes

Both kinds share the same values, the same editor, the same API and the same translations. An add-on may be marked required, and it may use three widgets an axis cannot.

Widget The shopper sees Where the price hangs
dropdown, pills, swatches A choice between the values On each value: "Ribbon, +£3.00"
text One line to type in, up to 190 characters On the add-on itself, charged once they type something
textarea Several lines On the add-on itself, charged once they type something
checkbox A tick box On the add-on itself, charged when it is ticked

A price is either a fixed amount or a percentage of the price. A fixed amount may be negative, so "collect in store, −£4.00" works. A percentage is worked out from the variant's own price, never from the cart's total, and two percentage add-ons on one line never compound.

A required add-on that has not been answered refuses the add, naming the option.

Add a priced add-on

  1. Open the product and scroll to Add-ons.
  2. Type a name in the box at the foot of the panel, for example Gift wrap, and click Add an add-on.
  3. Choose a Widget and tick Required if the shopper must answer.
  4. For a choice widget, type each choice in Add a choice… and click Add.
  5. Set the price on each row: choose Fixed amount or Percentage of the price, and fill in Amount or Percent. Leave both empty for a free choice. On a store that sells in more than one currency a fixed amount has a box per currency, and an empty one means the choice can't be picked in that currency.
  6. Click Save.

Names, choices and the required flag save as you press the buttons. Prices save with the product. A product already carrying ten add-ons says so instead of offering the box.

How the price reaches the line

The delta is added to the line's unit price when the line is created, rather than sitting beside it as an order-level adjustment. Four consequences follow:

  • The add-on is taxed at the line's own tax class.
  • It is refunded with the line.
  • It counts as the line's revenue in reports.
  • A percentage sale takes its percentage off the add-on too.

The delta is frozen onto the line at add-to-cart. Editing an add-on's price afterwards changes what the next shopper pays and leaves lines already in a cart or on an order as they were. Two shoppers who typed different engravings are two separate lines: the answers are part of what identifies a line.

Every calculation re-emits the breakdown from the line's own snapshot as an adjustment of type option, marked included. Included means the money is already inside the line's price: the row moves no total, is taxed by nothing and refunded by nothing.

Note

A percentage sale discounts a priced add-on: the surcharge lives in the line's unit price, so "20% off everything" takes 20% off the engraving too. There is no way to exempt an add-on from a sale in this release.

Important

Add-ons do not travel in the catalog file. bin/plugin kahunacart export and import round-trip variant axes and their values, but not add-on options or their prices, so importing a product into a fresh store gives you the product without them. An import never deletes, so re-importing over a store that already has the add-ons leaves them alone.

See Pricing for how add-ons stand beside quantity tiers and price lists.

Pay what you want

A product can let the shopper name the price, the way Bandcamp sells an album and itch.io sells a game. The variant's price is the least they can pay, and anything above it is up to them. A variant at 0.00 is "free, or pay what you want", and a basket that comes to nothing checks out the way any free basket does.

Column Default What it does
pay_what_you_want 0 Shoppers name their price, and each variant's price is the minimum.
pwyw_suggested_minor none What the price box starts at. Blank starts it at the minimum. The editor refuses a suggestion below the lowest priced variant’s minimum in that currency. An older saved suggestion below a chosen variant’s minimum is read as that minimum.
pwyw_max_minor none The most a shopper can pay. It is there to catch 5000 typed for 50.00, not to cap anybody. Blank is no maximum, and a maximum below the minimum is ignored.

Sell a product at the shopper's price

  1. Open the product and go to Price & stock.
  2. Set the variant's price to the least you'll take. On a product with several variants, each variant's own price is its minimum.
  3. In the Pay what you want fieldset, tick Let shoppers name their price.
  4. Optionally enter a Suggested price for the box to start at, and a Maximum price. Leave either blank to go without.
  5. Click Save.

What the shopper sees

The buy button gets a Name your price box, filled in with the suggested price, and a line under it: "Minimum $12.00", or "Free, or pay what you want" for a variant at nothing. Picking a variant with a different minimum updates the line, and only raises the amount in the box when what's there would now be refused.

The product carries no sale badge and no struck-through price in the catalog, even when a sale covers its category, because there's nothing to mark down.

The rules

The cart checks the amount, whatever the page says. An amount below the minimum or above the maximum is refused with the product and the number in the sentence: The least you can pay for "Offshore Ambient Album" is $12.00. Something that isn't an amount at all gets Enter the amount you would like to pay for "Offshore Ambient Album". A cleared box counts as the minimum.

An add with no amount pays the suggested price. A product grid's button, an API call or a wishlist sends no price box, so the line is priced at what the product page would have offered.

One line per variant. Adding the same variant again at the same amount bumps the quantity, like any identical add. At a different amount it replaces the line, because a shopper who types a new price has changed their mind, not bought a second copy at each price.

No discount reaches a price the shopper chose. Sales, coupons and price lists all leave the line alone. A coupon whose only lines are named prices is refused with "This coupon does not apply to anything in your cart". The amount is frozen onto the line as its price, so tax, refunds, reports, receipts and invoices treat it like any other line.

The amount is in the store's base currency, the one the variant's price is in. The catalog and seed files write the two amounts as decimals, pwyw_suggested and pwyw_max, the way they write a price. Over the REST API and the MCP tools the fields are pay_what_you_want, pwyw_suggested_minor and pwyw_max_minor, in minor units. On a store that sells in more than one currency each has a box per currency in the editor, and the catalog file writes pwyw_suggested_prices and pwyw_max_prices; an empty suggestion starts the box at the minimum, and an empty maximum is no maximum. See Currencies.

A theme drawing its own buy box reads the same rules through kahunacart_pay_what_you_want(). See Storefront → The price box.

Buy now

A product can skip the cart. Its buy button says Buy now, and pressing it puts the product in the cart and opens checkout, instead of going back to the basket with "Added to cart". It suits a store that sells one thing at a time: a storage unit, an hour of consulting, a mobile top-up.

Column Default What it does
buy_now null 1 goes straight to checkout, 0 goes to the cart, and null follows the store's cart.buy_now setting.
button_label null The buy button's own words, like "Rent now", "Reserve" or "Move in". Null keeps "Add to cart" or "Buy now", whichever way the button goes. Translatable, like the title.

Send a product straight to checkout

  1. Open the product and find the Buy button fieldset.
  2. Set When the shopper presses it to Buy now, or leave it following the store setting.
  3. Optionally enter a Button label.
  4. Click Save.

To do it for every product that follows the store, turn on Buy now goes straight to checkout on Settings → Commerce (cart.buy_now).

It is an ordinary add. Stock, quantity limits, members-only products, coupon links and an open payment all work exactly as they do for Add to cart, and a refusal lands on the cart page. Only the page after the add is different.

Anything already in the cart stays there. A shopper who presses Buy now with something in the basket gets a checkout with both on it, and can take either off from the cart. A product that should only ever be bought alone is sold individually.

A kit or grouped product's one button follows the parent product's setting. The catalog file and the seed file write buy_now as true or false, and leave it out for a product that follows the store. A theme drawing its own button reads kahunacart_buy_now(product), and a form can post buy_now=1 to send any add to checkout. See Twig functions.

Badges on the picture

A product's pictures can carry up to five badges, in this order: Sold out, Pre-order (while the product's release date is ahead), the sale running on it, your own badge, and Digital download. Sold out, Pre-order, the sale and the download badge come from the product itself. A sale's badge uses the sale's name, so a sale called "Clearance" puts CLEARANCE on every product in it. Your own badge is for anything else, like BOGO, Last one or New.

Column Default What it does
badge_text null The badge's words, up to 40 characters. Null for no badge. Translatable, like the title.
badge_tone null red, green, amber, blue or dark. Null is the theme's accent colour.

Add a badge to a product

  1. Open the product and find the Badge fieldset.
  2. Type the Badge text, such as Last one.
  3. Pick a Colour, or leave it on Accent.
  4. Click Save.

The badge shows over the product's pictures in the shop and on its page. Clear the text to take it off. The theme decides the exact shade of each colour, so the badge matches the rest of the shop. The catalog file writes badge_text and badge_tone the same way.

For theme authors. Every badge is drawn by one partial, partials/kahunacart-badges.html.twig, with the same classes in every theme: kahunacart-badges on the stack, kahunacart-badge--sold-out, --preorder, --sale, --custom and --digital on each badge, and kahunacart-badge--tone-red (and so on) on yours. Include the partial over your pictures and style those classes. See Theming.

Pre-orders

A product can be sold before it comes out. Give it an Out on date in the product editor (beside Status, release_at) and until then it is a pre-order. At that moment it becomes an ordinary product by itself, with nothing to switch. Leave the date empty for something you sell now.

The date and time are in the store's timezone (reports.timezone, then Grav's system.timezone), so midnight on release day is midnight where the store is.

Make a product a pre-order

  1. Open the product.
  2. Set Out on to the release date and time.
  3. Click Save.

The buy button now says Pre-order, and the product page prints the date beside it. Clear Out on to release the product at once.

What a pre-order does

  • Selling. Checkout takes payment in full now, the way it always does. There is no authorize-now, capture-later. Stock works as it does for anything else, so a limited pressing of 300 sells out at 300. Every variant of the product is a pre-order: something you can ship today goes in a separate product.
  • What the shopper sees. The buy button says Pre-order instead of Add to cart or Buy now (a button label you wrote yourself still wins), and the product page prints "Out Friday, November 13" beside it. Cards and the product page carry a Pre-order badge. The cart's cross-sells say Pre-order on their button. Each cart and checkout line carries "Pre-order · out Nov 13" through kahunacart_cart_line_notes(). Dates are written in the store's language and timezone, with the year when it is not this year's.
  • The order keeps what it was sold as. As the order completes, each pre-order line freezes its release date and its words (snapshot_json.preorder, and the line's release_at). The receipt, the pay page, the order confirmation email and the admin keep saying "Pre-order · out Nov 13" even if you move the date later.
  • The order list shows it. The order list and the order page show a Pre-order pill on an order with a line still waiting, until the product is out. The customer's account order list shows "Pre-order · out Nov 13" beside that order's statuses. The order page shows the product's date now beside the one the line was sold with when they differ.
  • Downloads wait for the day. Download grants are minted at purchase as usual, but a download is refused before the product's release date (see Digital products). The receipt, the account's Downloads and the order emails show "Soon · Nov 13" in place of the link. When the date passes, the worker sends one It's out: your download is ready email to each order that waited, with its links.
  • Moving the date. Changing Out on changes what the shop says straight away, and the downloads follow the product's date, not the line's. Orders keep the date they were sold with on their receipt. Clearing it releases the product at once, and the worker's next run sends the emails.

A catalog page is cached no longer than the next release, the way it is capped at the next sale boundary, so a shared cache never keeps saying Pre-order after the day. Structured data says availability: PreOrder with availabilityStarts. The Google and OpenAI feeds say preorder with the release date as availability_date. A pre-order that has sold out says it is out of stock.

Where release_at goes

release_at goes through these routes:

  • The products API and the create_product / update_product MCP tools. They take epoch seconds, ISO 8601 with an offset, or a date and time with none, such as 2026-11-13 00:00, read in the store's timezone. Null or empty clears it, and a date it cannot read is refused with a 422.
  • The catalog file (release_at: as ISO 8601 with the store's offset).
  • The CSV importer (a Release date or Out on column) and the products CSV export.
  • Store export and restore, Duplicate and the product's revisions.

Orders completed outside checkout

Code that builds and completes an order itself (a demo seed, an import, an add-on) freezes its pre-order lines the way checkout does with KahunaCart::instance()->checkout()->freezePreOrderLines($orderId). Each line whose product is still on pre-order gets its release_at and the words the shopper read, in the order's language. The answer is how many lines it froze.

Pre-orders in a theme

kahunacart_preorder(product) answers {active, release_at, date_label, short_label, date, short_date} for a pre-order and an empty array for anything else. The shipped buy button does the wording itself, so a theme that includes it gets Pre-order for free. See Theming.

Manage stock

Stock is only tracked when stock_managed is 1. This section applies to those variants only.

  1. Open the product and scroll to Pricing & stock, or open a variant row and click Edit.
  2. Tick Track stock.
  3. Enter a Stock quantity.
  4. Set Backorders to Not allowed or Allowed.
  5. With backorders allowed, fill in the optional Backorder note under it if you want to tell shoppers when to expect it.
  6. Click Save, or Save variant on a variant row.

Deduction is atomic: completing an order runs a conditional UPDATE per line, stock_qty >= qty + held, so two customers racing for the last unit cannot both win. The loser gets an insufficient-stock error and their order rolls back to a cart.

With deny, the decrement refuses when there is not enough, and isPurchasable() blocks add-to-cart before that. With allow, stock_qty goes negative, so the admin sees the deficit as a number.

Important

The API, the catalog file and the CSV importer store allow or deny, so WooCommerce's notify arrives as allow. A hand-edited or imported row is read the way the cart reads it: allow, allowed, notify, yes, true, on and 1 allow overselling, and anything else reads as deny. The catalog index, the admin's Backorders select, structured data and the agentic feeds all read it the same way.

On the admin's product list, a tracked variant at zero with backorders allowed shows On backorder instead of Out of stock, because the store is still selling it. Once the count goes below zero it says how many are owed, such as "2 on backorder", and the product row's out-of-stock count leaves those variants out.

Telling the shopper it is on backorder

A tracked variant with nothing left and backorders allowed is on backorder. The cart still takes it, the button still says Add to cart, and the shop tells the shopper why it will take a while. The words come from three places, and the first one set wins:

  1. The variant's Backorder note, under the Backorders select in the product editor (backorder_note, up to 190 characters), such as "Expected to ship late October". The box is live while stock is tracked and backorders are allowed.
  2. The store's wording, Backorder Wording under Stock on the Commerce tab of KahunaCart → Settings (stock.backorder_text), for every variant without a note of its own.
  3. "On backorder", translated, when neither is set.

That label appears next to the buy button of a product with one variant, on the option picker's status line when the chosen variant is on backorder, and in kahunacart_stock().label for a theme's own stock line and product card badge.

A cart line counts. Three wanted with one on the shelf reads "2 of 3 on backorder", followed by the note or the store's wording when there is one: "2 of 3 on backorder · Expected to ship late October". When the whole line is past the shelf it is just the label, and a line the shelf covers says nothing. Stock held for another shopper who is paying right now counts as gone, and so do the earlier lines of the same cart. Two lines of one variant, or a kit's part and the same thing bought on its own, are counted in the order checkout takes the stock, so the cart says what the receipt will. The cart page prints it under the line and the checkout lists it above Place order.

The order keeps what the shopper was told. When the order is placed, the backordered quantity and the wording are written into the line's snapshot (snapshot_json.backorder: qty, of, label and text). The admin order view, the order confirmation email and the receipt print it from there, so it still says "2 of 3 on backorder · Expected to ship late October" after the stock arrives and the note comes off. It is worded in the order's language, or in English when the store has no strings for it. If a line's quantity is changed after the order is placed, it shows just the label, because "2 of 3" no longer describes it.

The note travels with the variant. The products API and the update_variant MCP tool take backorder_note (null or an empty string clears it), the catalog file writes it when there is one, the CSV importer maps a Backorder note column, Duplicate copies it, and variants generated from options inherit the default variant's.

Stock holds

A hold reserves stock while a customer is away at a hosted payment provider.

When payment is initiated with a redirect provider, the order's stock-managed lines get hold rows valid for stock.hold_minutes, default 15. Other carts see the held quantity subtracted from what they can buy, and the order's own hold never counts against itself. Holds are advisory: the decrement at completion is still the check that can fail.

Behaviour Detail
Re-initiating payment Replaces the hold rather than stacking a second one.
Order completes Released inside the same transaction, so a rollback puts them back.
Payment fails or is cancelled Released.
Expiry Swept by the worker on every tick and by bin/plugin kahunacart work.

Set stock.hold_minutes to 0 to disable holds entirely.

When a download runs out

"Out of stock" is right for a t-shirt and odd for a download. A file never really runs out. A digital product sits at zero because you sold a limited run, or because you are holding it back until it goes on sale and zero is how you keep the cart from taking it. So a product that needs no shipping gets its own wording, set under Settings → Stock → When a Digital Product Runs Out (stock.digital_sold_out):

Value The shop says When it fits
not_available (default) Not available Always true, and says nothing about whether it comes back
coming_soon Coming soon You keep something at zero until its release

Your Own Wording (stock.digital_sold_out_text) replaces the label with anything you type, such as "Back in October", on product cards, the product page and the buy button. Leave it empty to use the choice. The sentence somebody reads when the cart refuses the product still follows the choice.

"Needs no shipping" means the product's type does not ship: a download, and also a service or a booking, since every type that does not ship reads the same way here. A product that ships always says Out of stock.

The wording applies wherever the shop names that state: the badge on a product card, the stock line and the disabled buy button on the product page, the option picker's status line, and the sentence somebody reads when the cart refuses the product. With Coming soon that sentence is "Field Guide" is coming soon and cannot be bought yet. A shared or abandoned cart brought back later says "Field Guide" is not available. for a download, whichever you picked. Asking for more than is left is a different sentence for every product, There are not enough of "Field Guide" left for that quantity., because something with three left is neither sold out nor unavailable.

A product with one variant that has run out gets a greyed buy button carrying that label in place of Add to cart, and no quantity box, so the shopper sees why before they click. A product with a picker or a variant select keeps its normal button, because the shopper can switch to a variant that is in stock.

Themes read all of this through kahunacart_stock(), so the rule lives in one place.

Quantity rules

Stock is how many there are. Quantity rules are how many one basket may take. Six columns on the product carry them.

Column Default What it does
min_qty cart.quantity_min_default (1) The smallest quantity one order may hold. A trade-only pack of twenty-four, a minimum print run.
max_qty cart.quantity_max_default (999) The largest.
qty_step none The quantity has to be a multiple of this. Beer by the six, tiles by the box of ten, business cards by the hundred.
sold_individually 0 One per order, whatever the other three say.
combine_variants 1 Whether the limits are counted over every line of the product together, or over each line on its own.
exclude_category_rules 0 This product ignores the rules its categories carry. See Quantity rules on a category.

A maximum is not a sales cap. It is what stops one basket holding the whole shelf while its owner thinks about it, so keep it low. sold_individually wins outright over the three numbers, and the refusal names the product rather than quoting a number nobody typed.

A minimum is not a step. A minimum of six accepts seven, and seven is a case the warehouse cannot pick. qty_step is the setting that says "in sixes". The two can never disagree, because the minimum is rounded up to the next multiple of the step and the maximum down: a step of six under the default minimum of one makes the smallest order six, and under the default maximum of 999 the largest is 996. Every number the storefront prints is a quantity the store will actually sell.

Quantities are summed per product, not per line. Two lines of the same product — two sizes, two engraving choices — are still that many of the shelf in one basket. That is combine_variants, and it is on by default. Turn it off and each line is judged alone against the limits its own variant resolves to, which is a merchant saying the variants are separate shelves.

The rules are enforced in three places: at add-to-cart, at the cart's quantity box, and again at checkout. The first two ask about the total the basket would end up holding rather than about the quantity arriving, so pressing the button twice cannot get past a limit. The third is the one that makes the rule true, because a cart built before the merchant set the limit has never been through the other two.

Note

Setting a limit never changes a basket somebody is already holding. It refuses the next thing they do with it.

Limits on one variant

min_qty, max_qty and qty_step exist on a variant too, and all three are nullable. Blank means inherit, not "no limit". sold_individually is a product-level decision and is not asked of a variant.

A variant's own limits are only read when combine_variants is off. With combining on, the sum is over the product and the product's own numbers are what it is judged against.

Quantity rules on a category

Three of the same columns live on a category — min_qty, max_qty and qty_step — and everything filed in that category inherits them. Setting three numbers on six hundred products one at a time is an errand; putting them on the category is how the errand ends.

Column Default What it does
min_qty none The smallest order for everything in this category.
max_qty none The largest.
qty_step none The multiple every quantity in this category has to be.

These are inherited defaults, not counts across the category. A minimum of six on Beer means every beer has a minimum of six. It does not mean a basket has to hold six beers between them.

A category's rules reach everything under it. A product filed in Clothing → Mens → Tops is in Clothing as far as a rule is concerned.

Four layers decide any one line, and every field is decided on its own.

  1. The variant's value, where the variant sets one.
  2. The product's.
  3. The category layer: every category the product is in, and every ancestor of those, merged.
  4. The store default, cart.quantity_min_default and cart.quantity_max_default. There is no store default for a step.

Because each field is decided on its own, a product's own rule overrides only the field it sets. A product with a minimum of two, in a category with a minimum of six and a maximum of twenty-four, is sold in twos to twenty-fours. sold_individually still wins outright, and a category cannot set it.

Where two categories disagree, the most restrictive value wins, field by field: the highest minimum, the lowest maximum, the largest step. A bottle in Beer (sold in sixes, at most twenty-four) and in Clearance (at most two) is sold in sixes, at most two. The largest step rather than a lowest common multiple, because sixes and tens would otherwise come out as thirty, a pack size neither merchant typed.

Tick Exclude from category rules on a product and it ignores every rule its categories carry, falling straight from its own values to the store defaults. The single-bottle sampler filed under a category sold in sixes is what it is for. It has no effect on the product's own limits, and none on the order rules.

Set a product's quantity limits

  1. Open the product and go to Price & stock.
  2. Open the Quantity limits fieldset.
  3. Enter Minimum quantity, Maximum quantity and Sold in multiples of. Leave a box blank to inherit.
  4. Tick Sold individually, Combine variants toward the limit, Exclude from order rules or Exclude from category rules as needed.
  5. Click Save.

Whole numbers only. A maximum below the minimum is refused on the field. 0 is the same as blank, which is the same reading the API and a bulk edit give it. Ticking Sold individually disables the three numbers above it rather than hiding them, so you can still see what you typed.

To set a limit on one variant, open the variant's row editor in the Variants table. The same three numbers are there, and a blank means inherit from the product rather than the store default. Combine variants toward the limit is only drawn on a product that has more than one variant.

Set a category's quantity rules

  1. Go to KahunaCart → Catalog → Categories and open a category.
  2. Open the Quantity rules group.
  3. Enter Minimum quantity, Maximum quantity and Sold in multiples of.
  4. Click Save.

Everything in the category and in every category under it inherits the rules, unless the product sets its own or is excluded.

Change limits across many products

Merchandising → Bulk edit sets min_qty, max_qty, qty_step, sold_individually, exclude_order_rules and exclude_category_rules across a selection, beside price, stock, status, visibility and featured.

A limit can only be changed to a value: "increase every minimum by ten percent" is not a sentence anybody means. A bulk edit cannot send a blank, so 0 is how a limit comes off.

The same fields travel in a CSV under WooCommerce's own column headings. See Importing.

Order rules

The quantity rules above are about one product. The order rules are about the basket as a whole: cart.order_min_qty, cart.order_max_qty, cart.order_min_total and cart.order_max_total. All four are off as the plugin ships. See Configuration for the keys.

A basket outside the rules prints the reason at the top of the cart page, and the checkout button stays where it was. A shopper who walks past the sentence is refused at checkout with the same words.

Column Default What it does
exclude_order_rules 0 This product's lines count for nothing in the order rules, neither their units nor their money.

Set it on a gift card, a donation, a deposit, shipping protection, a printed catalogue: anything that is money or units in a basket that nobody meant to count towards a minimum order. A basket holding nothing but excluded lines is refused nothing at all, so a store with a £50 minimum can still sell a £25 gift card on its own.

The flag has no effect on the per-product quantity limits, which apply whatever it says.

Member-only products

A product can name one or more customer groups, and then only a shopper resolved into one of them may see or buy it.

The case it was built for is a concert. A Member ticket at one price that only the Members group may buy, and a General admission ticket at another that anybody may. They are two ordinary products at two prices, each with its own quantity limits, and the member one names the Members group.

To a shopper outside those groups the product is absent: from the shop page, from every category and tag page, from the counts beside them, from the facet counts, from search, from the related-products blocks, from the catalog index and from the sitemap. Not greyed out and not badged — absent.

Its own URL still resolves. Whoever follows that link is usually a member who has not signed in yet, and a 404 gives them a dead end. The page renders with a "This is for members only" notice and a sign-in link in place of the buy button. Add-to-cart refuses, and checkout asks again over every line before it starts a payment, because a group can change in between.

An empty list is the default and means anybody may buy. The admin lists every product whatever its restriction, for the same reason it lists unlisted ones: a product you cannot see in the admin is a product you cannot fix.

Restrict a product to a group

  1. Open the product.
  2. In the aside, find the Who can buy picker.
  3. Tick one or more customer groups. Tick none and everybody can buy it.
  4. Click Save.

The product is now missing from every listing for anybody outside those groups.

Note

The picker reads the store's group list, which needs the kahunacart.settings permission. An account that manages the catalog but not the store's settings gets no picker rather than an error.

Over the API, send customer_group_ids on the product. Deleting a group takes its restrictions with it in the same transaction, so a product is never left naming a row that is not there. See Member-only products for the group side and Storefront for what a theme sees.

Product data for search engines and agents

Seven fields describe a product the way Google Merchant Center, schema.org and agent platforms want it described. None is required, and a product with none of them sells exactly as before. They feed the product page's structured data, the product feeds, and the storefront MCP and UCP answers.

Column On Default What it does
brand product null The brand it is sold under, up to 190 characters. Not translated. Empty falls back to seller.default_brand.
item_condition product null new, refurbished or used. Null reads as new everywhere.
final_sale product 0 No returns. The structured data says returns are not permitted, the OpenAI feed says it accepts none, and an agent may never check it out.
agent_checkout product null Whether an agent platform may sell it through its own checkout: null is Automatic, 1 Allow, 0 Never. See Agent checkout.
google_category product null A Google product taxonomy id or path, such as 1604 or Apparel & Accessories > Clothing, up to 190 characters. Empty falls back to feeds.google_category.
gtin variant null The barcode: 8, 12, 13 or 14 digits with a valid GS1 check digit. Spaces and dashes are removed, and the digits are stored as given, never padded.
mpn variant null The manufacturer part number, up to 70 characters.

Describe a product for search engines and agents

  1. Open the product.
  2. In the Product Data fieldset, fill in Brand, Condition and Google product category where you have them. Tick Final sale for a product you take no returns on.
  3. On each variant row, enter the GTIN and MPN beside the SKU.
  4. Click Save.

A value that cannot be stored is refused with a sentence naming the field, never cut short or guessed at. A GTIN whose check digit does not match is a 422 over the API. In a YAML catalog or seed file, quote a GTIN so a leading zero survives.

Agent checkout

Some products can be bought through an agent platform's own checkout, such as the Google Pay sheet in AI Mode, and some cannot. One rule decides three things: the Google feed's native_commerce column, whether a UCP checkout accepts the line, and the hint under the Agent checkout control. The control only appears while agentic.enabled is on.

Never, whatever the setting says, when the buyer would have to give something UCP cannot carry or Google does not allow:

  • a required add-on option, or a buy-button field an add-on marks required;
  • pay what you want;
  • a deposit;
  • a prerequisite product;
  • a members-only product, or one limited to customer groups;
  • a subscription;
  • a service;
  • a variant on backorder;
  • a final sale.

Automatic also says no to a digital product and to a used or refurbished one, because Google excludes software, virtual goods and rentals. Allow overrides only those two automatic rules. Never always says no.

The product editor shows the automatic answer and every reason behind it under the control, per variant, so you can see why a product is not offered before choosing Allow. The products API answers the same thing as agent_eligibility on a product read and update.

Note

A product an agent cannot check out can still be sold to one. Agent carts accept any variant a guest could put in the storefront cart, and the buyer finishes on your own checkout through the continue_url. See Agentic commerce.

Over the API and in files

brand, item_condition, final_sale, agent_checkout and google_category are accepted on POST /kahunacart/products and PATCH /kahunacart/products/{id} and their MCP tools, and gtin and mpn on each variant there and on the variant routes. agent_checkout takes null, true or false over the API; in a file it also reads automatic, allow and never. The catalog file exports and imports every field, and the seed file may carry them. The CSV importer knows them by the headers WooCommerce and Shopify write; see Importing.

Product safety (GPSR)

A shop selling into the EU has to show certain product safety details on every product listing, under the General Product Safety Regulation (EU 2023/988, Article 19):

  • the manufacturer's name (or registered trade name or trademark), postal address and an electronic contact, an email or a website;
  • when the manufacturer is outside the EU, the same details for the responsible person in the EU;
  • what identifies the product: a picture, its type and any other identifier;
  • warnings and safety information, in the shopper's language.

The picture, the title, the SKU and the GTIN are already on every product page. The rest is set in three places, and the product page shows it in a Product safety section.

Manufacturers

Catalogue → Manufacturers in the admin is the list of who makes what you sell. Each manufacturer has a Name (required, and unique ignoring case), a Trade name for when it trades under another one, a multi-line Postal address, an Email and a Website or contact form. The EU responsible person fields (name, address, email, website) sit in a collapsed group, because they are only needed when the manufacturer is outside the EU. An email must look like one and a website must be an http or https address, or the save is refused naming the field. A website typed without https:// gets it added. The list shows how many products name each manufacturer and which one is the store default.

Under the address fields is Default safety information: warnings and safety instructions in Markdown, shown on every product of this manufacturer that has no safety information of its own. Most products from one maker carry the same two lines, such as "Age restrictions: For adults" and "EU Warranty: 2 years". This is the place to type them once, instead of on every product. It holds up to 20,000 characters, the same as a product's own. On a store with languages it has a tab per language, the same as the description, and a shopper reads the text in their language. A language you haven't translated falls back to the base text. See Multilanguage. Deleting the manufacturer deletes its translations with it.

A manufacturer is its own record rather than the product's brand. The brand is marketing text that feeds and agents read, and the manufacturer is a legal name and address. They are often the same company, and nothing stops you typing the same name into both.

Deleting a manufacturer takes it off every product that named it, in the same step, and those products fall back to the store default. The products are otherwise untouched.

The store default

Settings → General → Default Manufacturer (catalog.default_manufacturer) names the manufacturer every product shows when it doesn't name one of its own. A shop that makes its own goods sets this once and never opens a product for it. Empty means no default. A default naming a manufacturer that has since been deleted reads as none.

On a product

The product editor's Product safety panel has a Manufacturer select, whose first option is "Store default (name)", and Safety information: warnings and safety instructions shoppers need before buying, in Markdown. Safety information is translated the same way the description is, on the product's language tabs. When Safety information is empty and the manufacturer the product shows has a default text, a line under the field says what the page will show instead.

A product's own text wins. The page shows the product's own safety information when it has any, and only that. When it has none, the page shows the default text of the manufacturer the product shows: its own manufacturer, or the store default manufacturer when it names none. A manufacturer with no default text adds nothing. This is decided in one place, ManufacturerService::safetyFor(), so the product page, kahunacart_product_safety() and any theme that calls it agree.

The two fields are the product columns manufacturer_id (null follows the store default) and safety_info. On MySQL and MariaDB the safety text is a long text column, because a plain TEXT column stops at 64 KB, and 20,000 characters of some scripts are more than that. A manufacturer's default text is the manufacturer column safety_info.

On the product page

The section is a <details> element titled Product safety, closed to start with, under the specification table and the custom fields. Inside it are the manufacturer (name, trade name when it differs, the address a line at a time, and the email and website as links), the EU responsible person when there is one, and the safety information rendered as Markdown through the same sanitising renderer the description uses. A product with no manufacturer (its own or the default) and no safety information shows nothing at all. A product with no manufacturer of its own, on a store with no default, costs no database query.

It is the core partial partials/kahunacart-product-safety.html.twig, handed product_safety on the product route. See Storefront → Product safety. The product's structured data carries the manufacturer as a schema.org Organization (manufacturer, with the address as text), whether or not agentic commerce is on. brand is unchanged.

Safety documents (a product information sheet, instructions for use) go on the product as Product media of kind document in the sample role, which the product page already lists under Preview. There is no separate safety documents list.

Over the API and in files

GET and POST /kahunacart/manufacturers, and PATCH and DELETE /kahunacart/manufacturers/{id}, under kahunacart.products.manage, with the MCP tools list_manufacturers, create_manufacturer, update_manufacturer and delete_manufacturer. The list answers each manufacturer's product_count and the store's default_manufacturer_id. A delete answers products_affected and was_default. A field that can't be stored is a 422 naming it, and a name another manufacturer has is a 409.

A manufacturer's safety_info is its default Product safety text, written and read like its other fields. A blank value clears it, and a body that doesn't name it leaves it alone. Its translations go in a translations map, {"de": {"safety_info": "..."}}, which replaces every language when sent and comes back unresolved on every manufacturer the API answers.

manufacturer_id and safety_info are accepted on POST /kahunacart/products and PATCH /kahunacart/products/{id} (and create_product and update_product). An id that names no manufacturer is a 422, and null puts the product back on the store default. safety_info goes in a product's translations like the description.

The catalog file (bin/plugin kahunacart export and import) carries every manufacturer as a manufacturers: block, and each product names its own by name:

YAML
manufacturers:
  - name: North Coast Textiles Inc.
    address: "1200 Harbor Road\nSanta Cruz, CA 95060\nUnited States"
    email: s[email protected]
    rp_name: Atlantic Compliance B.V.
    rp_address: "Kade 14\n1013 AA Amsterdam\nNetherlands"
    safety_info: "Age restrictions: For adults\nEU Warranty: 2 years"
    translations:
      de:
        safety_info: "Altersbeschränkung: Für Erwachsene\nEU-Garantie: 2 Jahre"
products:
  - slug: reef-runner-tee
    manufacturer: North Coast Textiles Inc.
    safety_info: Keep away from open flames and heat sources.

An import matches manufacturers by name, ignoring case. One the store has is updated with the fields the file sets, and one it hasn't is created. A manufacturer's safety_info and translations follow the same rule as every other field: a key the file leaves out leaves the stored value alone, and a blank safety_info: clears it. A product naming a manufacturer that neither the store nor the block has gets one created with just that name, for you to fill in later. A blank manufacturer: puts the product back on the store default, and a product without the key is left alone. The admin's CSV importer reads a manufacturer column and a safety_info column the same way, one product per row. See Importing → Product safety in a CSV. The seed file uses the same two keys and the same block, and a seeded manufacturer takes safety_info too.

Order and feature products

Every listing is newest first until the store says otherwise: the shop page, every category page and search all run published_at DESC, id DESC. Two columns on the product change that.

Column What it does
position The merchant's own order. One flat sequence over the whole catalog, set by moving products up and down, and read only while catalog.sort is manual.
featured A lead rather than a position. A featured product comes first in every listing whatever the sort, while catalog.featured_first is on.

Both columns arrive with migration 0019_product_ordering.

The sort vocabulary

The same six words are used everywhere an order can be asked for: the catalog.sort setting, the sort= parameter on the products endpoint, the sort: key of a kahunacart_products() options map, and the sort: of a block in a sectioned listing.

Word Admin label Order
newest Newest first Most recently published first. The default.
oldest Oldest first The reverse.
manual Manual order The merchant's own order, lowest position first.
title Title, A to Z Alphabetical.
price_asc Price, low to high Cheapest first, on the default variant's price, which is the price the card prints.
price_desc Price, high to low Dearest first, on the same price.

Anything unrecognized reads as newest.

Every sort is total, which is what makes paging work. Each clause ends in the product id, so two products sharing a position, a price or a publication second cannot swap places between pages. See Storefront.

catalog.featured_first (default true) puts featured DESC in front of whichever of the six is chosen.

Search and catalog.sort. Search results are ordered by relevance first, with catalog.sort as the tie-break. A store whose search is answered by the portable substring scan has no relevance score, so its results come back in the listing's order. See Catalog index.

Note

Switching an existing store to manual changes nothing until somebody moves a product: the migration numbers every existing row in the order the storefront is already showing. New products join the end of the manual order.

Set the catalog order

  1. In the admin sidebar, click KahunaCart, then Settings.
  2. Open the Storefront tab and find Catalog Order.
  3. Set Listing Order to the order you want.
  4. Leave Featured Products Lead enabled, or disable it to make the featured flag mean nothing on the storefront.
  5. Click Save.

The shop page, every category page and search now list products in that order.

Reorder products by hand

The Order control above the product list offers Newest, Manual order and Title. The choice is remembered per browser rather than stored as a shop setting.

  1. In the admin sidebar, click KahunaCart, then Products.
  2. Set Order to Manual order.
  3. On a row's ⋯ menu, click Move up or Move down.

The row moves and the whole list is posted back in its new order.

Three rules follow from that:

  • The list drops its pager while manual order is on and asks for the first 200 products in one request. The reorder route gives the ids it is sent positions 0 to n, so sending one page of a paged list would drag that page in front of everything else.
  • Take a category, tag or stock filter off before moving anything. The screen says so, and Move up and Move down are unavailable while a filter is on.
  • The admin list never pulls featured products to the front, whatever catalog.featured_first says.

Important

The storefront follows this order only while Listing Order in the plugin settings is set to Manual order.

Feature a product

  1. Open the product and find Featured under Status.
  2. Tick it.
  3. Click Save.

The product leads every storefront listing whatever the sort. Featured rows carry a Featured badge beside the title in the product list.

Ordering in export and import

position and featured are written on export every time, unlike the optional fields around them. An absent key means "leave it alone" on the way back in, so omitting a position of 0 would import a shop in a different order, and omitting featured: false could never take the star off a product.

YAML
products:
  - slug: reef-runner-tee
    title: Reef Runner Tee
    position: 0
    featured: true

The seed file takes featured: true but no position: products join the end of the manual order as they are created, so catalog.sort: manual on a freshly seeded store lists them in the seed file's order.

Categories and tags

A category is where a product lives. One place in a tree, with a landing page of its own, a picture, and a position among its siblings. "Clothing → Mens → Tops" is three categories.

A tag is what a product is. "Organic", "sale", "last few": flat, unordered, many per product, and with no page of its own. See Tags.

Migration 0013_taxonomy finishes the tree that 0005 started and moves tagging to its own table.

The category tree

Nesting is parent_id on the category row, NULL for a root, with no depth limit. Siblings are ordered by position, and a new category lands at the end of its row of siblings.

Operation Behaviour
Slugs Slugified and de-duplicated on write; a second "Shirts" becomes shirts-2. Unique tree-wide, not per parent.
Reparenting Refused with a 422 when it would close a cycle. A category cannot be its own parent or move under its own descendant.
Reordering Swaps two positions. Answers moved: false at the end of a row.
Orphans A row whose parent vanished is shown at the root rather than dropped.
Deleting Reparents the children into the deleted category's own parent. Its assignments, translations and picture go with it.

Category assignments are diff-based: a save that changes nothing writes nothing. Over the API, category_ids replaces the whole set when present and is left alone when absent.

The whole tree is read in one unfiltered SELECT and nested in PHP by Catalog\CategoryTree.

Category product counts

A category carries two counts.

Count What it counts
product_count Products filed directly on that category.
subtree_count Distinct products filed anywhere in that category's subtree.

subtree_count is a distinct count of product ids, not a sum, so a product filed in both Clothing and Clothing → Mens counts once. The storefront sidebar shows subtree_count and counts published products only. The admin list shows both counts and includes drafts.

Create a category

  1. In the admin sidebar, click KahunaCart, then Categories.
  2. Enter a Title, and a Slug if you do not want it derived from the title.
  3. Choose a Parent, or leave it empty for a root category.
  4. Set Page layout. See What a category's page shows.
  5. Click Create category.

To add a child directly under an existing category, click New child on its row. To move a category within its row of siblings, use the up and down buttons on the row.

What a category's page shows

A category page is either a department that shows subcategory cards, or a shelf that shows a product grid. The display column on the category decides which.

Value Admin label The page shows
auto Automatic Cards when it has children, a product grid when it does not. The default.
products Products only Always the product grid, children or no children.
subcategories Subcategories only Cards only; the product grid is suppressed.
both Subcategories, then products Cards first, then the product grid underneath.

Two guards override the column:

  • subcategories on a category with no children still shows the product grid, so the page is never empty.
  • A filtered listing always shows products, whatever the column says.

Anything unrecognized in the column reads as auto.

The product grid on a category page paginates at catalog.per_page like every other listing, and the pager carries whatever filter is on. A paginated category page's canonical points at the page being read rather than at page 1. See Storefront.

Upload a category image

A category carries one optional picture, used as the face on its card. Uploading replaces the existing picture, and the previous file is removed from disk once the new row is written.

  1. In the admin sidebar, click KahunaCart, then Categories.
  2. Expand the category and find the Image panel.
  3. Choose a file under Image file, or Replace image if one is already set.
  4. Enter Alt text describing what the picture shows.
  5. Click Save.

The bytes live in user/images/kahunacart/categories/{categoryId}/, and image_path is stored relative to that root. image_alt is NULL when nobody fills it in, which renders as alt="".

Accepted formats, format sniffing and images.max_upload_mb match product images. A file over the ceiling is refused with a 413.

Why category URLs stay flat

A category's URL is {base}/category/{slug} however deep it sits: /shop/category/mens, never /shop/category/clothing/mens. Nesting the path is not supported.

What this costs: two categories under different parents cannot both be slugged tops. The second becomes tops-2. Where that reads badly, name them mens-tops and womens-tops.

The hierarchy is carried in the visible breadcrumb and in the JSON-LD BreadcrumbList, both built from the real ancestor chain.

Why nesting the path was rejected

Frontend\StorefrontRoutes matches a URL without touching a database or a session, and a nested path is a variable number of segments. Slugs are globally unique, so a lookup is one indexed hit; a path URL would cost a walk down the tree, and reparenting would break every URL beneath the moved category.

One canonical slug per category in every language means /en/shop/category/mens and /de/shop/category/mens are the same category. A path URL turns that one decision into one per level of the tree. See Multilanguage.

Filters ride in the query string, so /shop?tag[]=organic and /shop/category/mens?min=10 match the same two routes. See Storefront.

Tags

A tag narrows the catalog down. Tags are flat: no parent, no position, no landing page, no place in the navigation. On a product page they render as links into the filtered listing, {base}?tag[]=organic, with rel="nofollow".

Two tables, added by 0013_taxonomy:

TXT
kahunacart_tags(id, slug UNIQUE, label, created_at, updated_at)
kahunacart_product_tags(id, product_id, tag_id, UNIQUE (product_id, tag_id))

slug is the identity the filter URL, the exporter and an import use; label is what a merchant types. The slug is derived from the label on write, so "Organic", "organic" and " ORGANIC " all derive organic and land on one tag. There is no UNIQUE constraint on label.

Create and assign tags

Tags are created on first use: typing "Organic" on the first product creates the tag, and typing it again lands on the existing one.

  1. Open the product and scroll to Tags.
  2. Type a label and press Enter.
  3. Click Save.

To manage tags on their own, click KahunaCart, then Tags, and use Create tag.

Over the API, tags is a plain list of labels. Ids naming no real tag are ignored rather than failing the product save.

Renaming a tag leaves its slug alone: the slug is in circulation in bookmarked filter URLs and exported catalog files. Ask for a new slug and it is slugified and de-duplicated, sale then sale-2.

Labels are translatable the same way category titles are: a de override on the tag, resolved per field, with a missing row meaning fallback. See Multilanguage.

Deleting a tag removes the assignments, the label's translations and the row. Products that wore it keep every other tag they had.

Attributes

An attribute is a typed field the store defines once and every product may answer: material, fabric weight, whether the thing is machine washable, care instructions. Answers print as a specification table on the product page. An attribute marked filterable also becomes a catalog filter.

Three things describe a product, each doing a different job.

Kind What it does
Option axis Decides what is bought and what it costs. Choosing Medium selects a different variant with its own SKU, price and stock.
Tag A flat label a shopper narrows the catalog by. No type, no value.
Attribute A typed fact about the product. It never changes what is in the cart or what it costs.

The definition is store-wide and the value is per product. That is what makes a filter work: every product answering "Material" answers from the same list, so ?attr[material]=cotton finds all of them.

Two tables, added by 0018_product_attributes:

TXT
kahunacart_attributes(id, slug UNIQUE, label, type, options_json, unit, group_label,
                      position, filterable, filter_panel, show_on_product,
                      created_at, updated_at)
kahunacart_product_attributes(id, product_id, variant_id, attribute_id, value_json,
                              value_text, value_num, position,
                              UNIQUE (product_id, variant_id, attribute_id, value_text))

A single-valued answer is one row. A multiselect answer is one row per member, which keeps every member filterable and countable.

variant_id is 0 for the product's own answer and a variant id for one variant's own answer. See Per-variant attribute values.

Orders are untouched. An attribute is never snapshotted onto an order line: it describes the product rather than what was bought.

Attribute types

Nine types. The Admin control column names what the product editor draws; the bold names are the admin's labels.

Type Value Admin control On the product page Filterable
text (Text) A single line, up to 1000 characters Text box As typed Yes, exact match
textarea (Paragraph) Markdown Text area Rendered as Markdown, with raw HTML escaped No
boolean (Yes / no) true or false Not answered / Yes / No picker Yes or No, translated Yes
number (Number) A decimal string, with an optional unit on the definition Text box, with the unit beside it 185 gsm Yes, exact value or range
select (One choice) One option slug from the definition's list Select, with a Not answered entry The option label Yes
multiselect (Several choices) Several option slugs from the same list Tick boxes The labels, comma separated, in definition order Yes
date (Date) YYYY-MM-DD Date input Formatted for the language being browsed Yes
url (Link) http://, https://, mailto:, or a path starting / URL box A link, with rel="nofollow noopener" No
color (Colour) #rrggbb Colour picker paired with a hex box A swatch beside the hex code Yes

Four rules decide how a value is typed and stored:

  • A number is stored as a decimal string, never as a JSON number: type 0.1 and you read 0.1 back. A comma is normalized, so 12,5 is stored as 12.5. Set unit on the definition, not per product.
  • Choice options are a list of {slug, label} pairs. Type a label and the slug is derived from it, so "Organic Cotton" becomes organic-cotton. Two options whose slugs would collide are refused rather than suffixed.
  • A value outside the option list is refused with a 422. Nothing is created by typing it. For free text, use text.
  • A textarea value renders as Markdown through the same hardened path a product description takes. See Markdown, not HTML.

A select value accepts either the option slug or the option label on the way in. A multiselect accepts a list or a comma-separated string. A boolean accepts true, false, 1, 0, yes, no, on and off. A date accepts a full timestamp and keeps the date half. A three-digit hex colour is expanded to six.

Define an attribute

Definitions are store-wide: creating one offers it on every product editor.

  1. In the admin sidebar, click KahunaCart, then Attributes.
  2. Click New attribute.
  3. Enter a Label. Slug fills in as you type; leave it to derive the slug from the label.
  4. Choose a Type.
  5. For a Number, enter a Unit such as gsm. Optional.
  6. Enter a Group heading such as Materials to file the attribute under that heading on the product page. Optional.
  7. For One choice or Several choices, click Add an option for each value and enter its Label. Slug is optional and is derived from the label.
  8. Leave Show on the product page ticked, or untick it for a field you store but never print.
  9. Tick Offer as a catalogue filter to make the attribute a facet.
  10. Leave Show in the filter panel ticked, or untick it to keep the links and the URLs without drawing a facet. The control is enabled only while Offer as a catalogue filter is ticked.
  11. Click Create attribute.

The attribute appears in the list, and every product editor now offers it. The order of this list is the order the specification table prints and the filter panel offers; use Move up and Move down on a row's ⋯ menu to change it.

Untick Show in the filter panel for an attribute with too many values to list. See Filterable and show in the filter panel. Such an attribute reads links only under its Filterable pill in the attribute list.

To edit a definition, click its label. To remove one, use Delete on its ⋯ menu.

Set attribute values on a product

  1. Open the product and scroll to Attributes, under Pricing & stock.
  2. Answer the controls that apply to this product.
  3. Click Save.

Every attribute the store defines is listed, answered or not, grouped the same way the product page groups them. Leave a control empty for an attribute that does not apply: an unanswered attribute prints no row. Emptying a control that had an answer removes that answer when you save.

A boolean is a Not answered / Yes / No picker, so an unanswered attribute is never mistaken for a No. A colour is a picker paired with an editable hex box, so it can be cleared.

A store that has defined no attributes gets one line and a link to the Attributes screen instead of the panel.

Important

Attributes need the kahunacart.products.manage permission, the same one option axes need. An account without it sees no Attributes panel on the product editor, and an explanation instead of the list on the Attributes screen.

Per-variant attribute values

A variant can answer an attribute for itself. When it says nothing, the product's answer is used.

One resolver answers for every field, most specific first.

Order What is read When it applies
1 The variant's own column — sku, weight_grams, image_id The variant states it directly.
2 The variant's own attribute value The variant answers the attribute for itself.
3 The product's own column — tax_class, shipping_class The variant says nothing and the product has a column for it.
4 The product's own attribute value The variant says nothing and the product answers the attribute.
5 The product type's default The type declares a field of that name with a default.
6 Nothing Nobody answered.

A step with nothing to say is skipped rather than treated as an answer. An empty string, a null, a zero weight and an empty list all mean "this level does not answer", so a variant with a blank SKU falls through to the product's.

Every answer says where it came from. Each variant in an API response carries a resolved map holding the value and the step that produced it.

Facet counts stay a question about products: a product whose four variants each state a weight is counted once. See Catalog index.

Note

The product editor has no control for a variant's own attribute answers in this release. Set them over the API, as variant_attributes: a map of variant id to the same {slug: value} map the product-level attributes key takes. It replaces per variant, so a variant named with an empty map has its own answers cleared and goes back to inheriting. Deleting a variant deletes its own answers and leaves the product's alone.

Rules that protect existing answers

  • Renaming the label leaves the slug alone. The slug is what filter URLs and export files are made of, so changing it retires the links already made of the old one. Renaming the label changes it on every product at once.
  • The type is locked while products answer the attribute. The control is disabled with the count beside it. Clear the values first, or create a second attribute. An unanswered attribute can be retyped freely.
  • Deleting an attribute that products answer asks to clear those answers too. The confirmation names the count, and confirming removes the definition and every answer to it, but not the products. An attribute nothing answers is deleted without the extra question.
  • Removing an option is allowed and is not undone for you. Products still answering it keep their value, the specification table prints the option's slug in place of the label, and the filter panel stops offering it.
  • Filterable is refused on textarea and url. The tick box is disabled on those two types, and the screen names the types that do accept it.
  • Show on the product page, filterable and show in the filter panel are independent. A wholesale field can be filterable and never printed, and a care instruction can be printed and never filtered.
  • Show in the filter panel is never cleared for you. Retyping to a type that refuses filtering clears filterable; filter_panel is kept, so turning filtering back on gives the panel the attribute had.

Filterable and show in the filter panel

Two flags decide what a filterable attribute does. filterable decides whether ?attr[...] URLs are honoured, whether values link from the product page, and whether counts are computed. filter_panel decides one thing only: whether the facet is drawn in the filter panel.

filterable filter_panel ?attr[...] URLs Value links on the product page Counted Facet drawn
On On Honoured Yes Yes Yes
On Off Honoured Yes Yes No
Off On Ignored No No No
Off Off Ignored No No No

The second row is what the pair exists for: an attribute with hundreds of values is worth linking and not worth hundreds of tick boxes. A shopper arriving from such a link still gets a chip naming the filter with a cross that takes it off. See Storefront.

filter_panel means nothing while filterable is off, and is stored anyway.

The column is added by migration 0022_attribute_filter_panel and defaults to on.

Tip

A facet that is merely long does not need this flag. catalog.facets.max_values caps how many values a facet draws and puts the rest behind a Show all toggle. See Storefront.

Groups and the specification table

Give an attribute a Group heading and the product page files it under that heading. One table per group, in the order the groups first appear in the attribute list. Attributes with no group form the first block and render with no heading over it.

A group is a heading and nothing else. There is no groups screen, and a group has no order of its own.

Only attributes marked Show on the product page that this product answers are printed. The table renders nothing at all on a product that answers none, and on a store that has defined none. See Theming for the markup and the classes.

Translate an attribute

Attribute labels and group headings are translatable, the same way tag labels are: an override per language, resolved per field, with a missing row meaning fallback.

  1. In the admin sidebar, click KahunaCart, then Attributes.
  2. Click the attribute's label to open it.
  3. Choose the language tab.
  4. Fill in Label and Group heading. An empty field falls back to the base text.
  5. Click Save.

Note

Option labels are not translatable. The slug, the type and the option labels are shared across every language. If you need translated option labels, use one text attribute per language.

See Multilanguage.

Filter the catalog by an attribute

An attribute marked filterable joins the tag, price and stock facets on the listing, on category pages and on search. Values inside one attribute are ORed, and attributes are ANDed with each other, the same rule tags follow.

Parameter Means
attr[material]=cotton One value.
attr[material][]=cotton&attr[material][]=linen Two values on one attribute. This is what the filter panel's tick boxes post.
attr[material]=cotton,linen The same two values, as a hand-written link.
attr[machine-washable]=1 A boolean. 1 is yes, 0 is no.
attr[fabric-weight]=185 An exact number. Matched as a number, so 185, 185.0 and 185.00 all find the product stored as 185.
attr[fabric-weight]=100-200 A number range, as a short link.
attr[fabric-weight][min]=100&attr[fabric-weight][max]=200 The same range, as the panel's two boxes post it. Either bound may be left out.

Which reading a value gets is decided by the attribute's type, never by the text.

A number is asked for an exact value first, so -40 is minus forty rather than an open-ended range. Write an open bottom as 0-150 and an open top as 185-. For a negative bound, use the two-box form.

The panel draws tick boxes with counts for One choice, Several choices and Yes / no, and a min and max pair for Number. Text, date and colour attributes are filterable by URL and get no control. An attribute with Show in the filter panel unticked gets no control either. Everything else about it is unchanged: the URL narrows, the counts are computed, and the chip above the results still names it and takes it off again.

Counts follow the current filters. Each value is counted over the listing being looked at with that attribute's own ticks left out, so ticking a second material widens the result while the certifications beside it narrow. A value that would give an empty grid reads 0 and is greyed out rather than removed, and a value nothing in the catalog answers is never offered. See Storefront.

Every block is presence-driven. A store with no attributes renders no attribute markup at all, and a definition nothing in the catalog answers gets no block.

A value naming nothing is dropped rather than emptying the listing, so a bookmark from before an option was renamed shows the shop. An attribute that is not filterable is ignored when a URL names it. One request may narrow on at most 20 attributes, with at most 20 values each.

See Storefront for the other facets, and Catalog index for how a large catalog answers a filtered listing and its counts.

A filterable value on the product page links to the rest of the catalog answering the same thing. The specification table draws it as <a class="kahunacart-attribute-link" rel="nofollow">.

Each entry in product.attributes carries two extra keys:

Key Holds
filter_url The store's main listing narrowed to this attribute's whole answer, or null when there is no link to give.
values On a multiselect, one entry per member as {slug, label, filter_url}.

The URL is one attr[...] parameter, built from the same stored value an equality filter matches on:

TXT
?attr[material]=recycled-polyester    a select, by option slug
?attr[wireless]=1                     a yes/no, 1 or 0
?attr[weight]=185                     a number, its exact value
?attr[released]=2026-08-01            a date, ISO
?attr[shell-colour]=0b57d0            a colour, hex with no leading hash

A multiselect links each member on its own inside the comma-separated cell, so "Cotton, Linen" is two links. The attribute's own filter_url there is the listing narrowed to any of its members, which is what kahunacart_attribute_url() answers for a multi-valued attribute.

filter_url is null in three cases, and a template that asks once prints plain text for all of them:

  • The attribute is not marked filterable.
  • The product does not answer the attribute.
  • The value cannot be named in this grammar. A text attribute holding "Porto, Portugal" prints as text, because a comma separates two values here.

Every one of these URLs is built by the same code as the facet tick boxes and the chips. filter_panel has no say here: an attribute kept out of the panel still links every value.

kahunacart_attribute_url(product, 'material') gives a theme the same link outside the specification table. See Storefront and Theming.

Attributes in structured data

The printed attributes also become additionalProperty entries in the product's JSON-LD, one PropertyValue each, carrying the slug as propertyID, the label as name and the printed string as value.

textarea attributes are left out. See Storefront.

Product images

A product image is a picture attached to a product, ordered by position. The lowest one is the primary image: the card thumbnail, the gallery's large image, and the one social previews and structured data lead with.

Field What it does
path Relative to the store's images root. Derived server-side on upload.
alt Alternative text. An empty one declares the image decorative.
position Gallery order. A new upload lands at the end.

Upload product images

  1. Open the product and scroll to Media.
  2. Drop pictures anywhere on the panel, or click Drop pictures here, or choose files and pick as many as you like.
  3. Click Edit on a picture's card to add a description for anything that carries information about the product.

The pictures upload one after another and join the end of the gallery in filename order, so IMG_2 comes before IMG_10. Files dropped while a batch is still uploading join the end of it, and a file that's refused (too big, not a picture) is named in a message while the rest carry on. Drag a card onto another to reorder the gallery. The first picture is the main image, so drag one to the front to change it, or choose Make main image in its ⋯ menu. The same menu has Move earlier, Move later and Delete.

Images live in user/images/kahunacart/products/{productId}/, not in the deny-all downloads root, because a browser fetches them directly.

Format is decided by reading the file, never by its extension. JPEG, PNG, WebP, AVIF and GIF are accepted, matched on their magic numbers, and payload.php renamed to payload.png is refused with a 415. images.max_upload_mb (default 8) caps the size, and PHP's upload_max_filesize and post_max_size apply on top.

The stored filename is derived from the product directory, a sanitized basename, a random suffix and the sniffed extension, so two uploads of hero.jpg do not collide and a crafted filename cannot escape the images root.

Note

The storefront always renders the alt attribute, empty when nobody has filled it in, which declares the image decorative. A missing alt is different: a screen reader falls back to reading the filename.

Deleting an image deletes the bytes. Deleting a product deletes all of its images, rows and bytes both, and the product's directory with them.

See Storefront for how images are rendered and how resized copies are cached.

Reviews on the product page

Reviews and their star rating are part of the core plugin and render on every product page by default, below the description. Set reviews.enabled to false and the block is absent entirely rather than empty.

kahunacart-product.html.twig includes partials/kahunacart-reviews.html.twig, so a theme that overrides the product template keeps reviews by keeping the include. Both are overridable. See Storefront → Templates and theme overrides.

The partial is given six variables.

Variable What it is
reviews The approved reviews for this product, newest first, capped for the page.
review_aggregate {count, average, distribution} over the approved reviews, or null when there are none.
can_review Whether this visitor may write one: always for a signed-in customer, and for a stranger only when reviews.allow_anonymous is on.
has_reviewed Whether this signed-in customer has already written one for this product.
review_moderated Whether reviews.moderated is on, so the form can say a review will be read before it appears.
review_max_rating The top of the scale, which is 5.

A review is written by posting to the product page. The rating is a whole number from 1 to 5. The verified purchase badge is computed from the writer's own paid orders and cannot be set by a form or an API. See Customers → Reviews and ratings.

The aggregate rating in structured data

The product's JSON-LD gains a schema.org AggregateRating carrying the average and the review count, alongside the Product block and its offer.

It is emitted only when at least one approved review exists: an aggregateRating with reviewCount: 0 behind it is a rich-result violation. The numbers come from the same aggregate the page shows, so the block and the stars beside it cannot disagree.

The aggregate is cached for five minutes behind a version stamp that moves on every approval, rejection and deletion, so a newly approved review is in the structured data on the next request.

Product files (digital goods)

A product file is a downloadable attached to a product. Leave variant_id null to give it to every buyer of any variant, or pin it to one variant.

Field What it does
name The display name, and the filename the browser saves. Independent of the name on disk.
path Relative to the store's files root. Derived server-side on upload.
size_bytes Measured at upload.
download_limit Downloads allowed per grant. Null falls back to downloads.default_limit.
expiry_days Grant lifetime. Null falls back to downloads.default_expiry_days.
position Admin display order.

Attach a downloadable file

  1. Open the product and scroll to Downloadable files.
  2. Choose a file under File to upload.
  3. Click Upload.
  4. Set Download limit and Lifetime on the new row if you want to override the store defaults.
  5. Click Save file.

downloads.max_upload_mb (default 512) caps a single upload.

The bytes are immutable: grants already issued promised a specific file to a specific buyer. Editing an attachment changes its name, limit, expiry and position, never its content. Replacing a file means uploading a new one.

Deleting a file removes the bytes from disk but leaves existing grants in place, and they deny with "not found". See Digital products for the full delivery story.

A product can carry three hand-picked lists of other products. Each answers a different question and renders in a different place on the page.

Kind Says Renders
related "You might also like" At the foot of the product page
cross_sell "Goes well with this" Under the buy button, and again under the cart as Goes well with your order
up_sell "Consider this instead" Above the buy button

Use related for browsing on, cross_sell for the things that go with what the shopper is buying, like a strap for a camera, and up_sell for the better version of the same thing. Nothing is derived. A block is exactly the products the merchant picked, in the order they picked them, and that is the order the shopper sees.

A theme draws the blocks its own way. The Kahuna theme shows Consider this instead and Goes well with this as compact rows with a picture, the name and the price, and You might also like as product cards at the foot of the page.

Curate a product's lists

  1. In the admin sidebar, click KahunaCart, then Merchandising, and open the Related products tab.
  2. Search for the product you are curating and click Curate on its row.
  3. Click Add a product on the block you want, then Add on the product to add.
  4. Use Move up and Move down on a row to set the order the storefront renders.
  5. Click Save.

The blocks appear on the product page immediately. A row that a shopper would not see is labelled not shown on the storefront in the list.

What a block does not show

A curated entry is filtered to what a shopper may see, in SQL rather than in the template. An unpublished product, or one a shopper may not see for any other reason, is not rendered. The merchant's order survives the filter: a block whose second entry is unpublished renders one, three, four — not one, four, three.

A deleted product drops out of every list at once.

A relation is not symmetric. Adding the strap to the camera's list does not add the camera to the strap's, so curating both means curating both.

Cross-sells in the cart

A shopper who has just put a camera in the basket is the one most likely to want its strap, so the cart offers the cross-sells again. Under the basket, Goes well with your order lists the cross-sells of the products in it. The product added most recently comes first, and each product's cross-sells keep the order you picked. A grouped product counts for itself and for each of its parts.

The list leaves out:

  • anything already in the basket, so the strap disappears once it is added
  • a product two basket items both name, after the first time it comes up
  • a product with one variant that is sold out. The next product in line takes its place
  • anything a shopper isn't allowed to see, like an unpublished product
  • anything past the fourth

A product gets an Add button when buying it takes no decisions. That means one variant, no add-on options, a fixed price rather than pay what you want, nothing an add-on asks for beside the button (a booking's dates, a subscription's plan), a price in the shopper's currency, a shopper who is allowed to buy it, and no minimum quantity above one. Add puts one in the basket and comes back to the cart. Everything else gets View, which opens the product's page where the choices are. Every grouped product and kit gets View, and so does a product with several variants, even if it's sold out in one of them.

It's on to start with. To turn it off, untick Cross-sells in the Cart on Settings → Commerce (cart.cross_sells, see Configuration). A store that has never picked a cross-sell shows nothing either way. The block is also left out while a payment page is open against the cart, because an add would be refused. A theme that overrides it reads cart_cross_sells. See Theming → Cross-sells under the cart.

Relation caching and limits

A block's list of product ids is cached for catalog.relations.cache seconds. Prices, sale badges and stock are read live on every request, so a cached block can never show a stale price.

Two things clear the cache. Saving a list clears it immediately. Rebuild cache, on the same screen, does the same for the whole store and sweeps rows pointing at deleted products. bin/grav clear empties it like anything else in Grav's cache.

catalog.relations.limit caps how many entries one block renders, as a page-weight control. Both keys are in the plugin settings; see Configuration.

The three partials are kahunacart-related-products.html.twig, kahunacart-cross-sells.html.twig and kahunacart-up-sells.html.twig, and the cart's block is kahunacart-cart-cross-sells.html.twig. Each renders nothing at all on a product with no curated list. See Theming.

Duplicate a product

Twenty similar products should not mean twenty blank forms. Duplicate is in each row's ⋯ menu on the Products list and in the header of the product editor, and over the API it is POST /kahunacart/products/{id}/duplicate (MCP tool duplicate_product), which takes the same permission as creating a product and answers 201 with the copy.

The copy is titled Copy of whatever the original is called with a slug derived from that, so duplicating the same product twice gives you copy-of-kettle and then copy-of-kettle-2. It is a draft whatever the original is, and its publish_at and unpublish_at dates are cleared — duplicating a product that goes live on Tuesday must not put a half-edited second one on the shop front on Tuesday. It lands at the end of the manual order.

What comes across: the description, summary, type, visibility, featured flag, tax and shipping settings, weight, quantity rules, custom fields, every variant with its option combination, prices, compare prices and stock settings, the option axes and their values, option prices, categories, tags, attribute values, customer-group restrictions, related products and prerequisites, kit items, price-list prices and quantity tiers, and the translations. Media comes across as rows and files: the pictures are copied into the new product's own folder rather than shared, because deleting a product deletes its folder.

Two things change on the way across, and both are deliberate:

  • Every copied variant comes out with a blank SKU. A SKU names one thing a merchant can pick off a shelf and two products cannot share one, so the store's own SKU template mints a fresh one, exactly as it does for a variant created by hand. With no template set the copies simply have no SKU until you type one.
  • Stock starts at zero on variants that track it. The copy has no units of its own; the ones on the shelf were counted against the product they belong to.

What does not come across: downloadable files and releases, because a digital deliverable belongs to one product and its buyers; and reviews, sales figures, history, revisions, wishlist entries and download grants, because each of those is a record of something that happened to the original and did not happen to the copy.

The copy's first history entry says which product it was duplicated from.

Product history

Every product carries an append-only record of what changed, when, and who did it.

Recorded Written by
product.bulk_edit A bulk edit changed this product
product.import_created An import created this product
product.import_updated An import updated this product

Each entry holds the change field by field, with its before and its after, the instruction that caused it, and the account that pressed the button. There is one entry per product per run, not one per variant: a 10% rise across a product with twelve variants is one entry carrying twelve changes.

Nothing here is edited or recomputed. Writing an entry never fails the change it describes.

Note

The product editor has no history panel in this release. Read a product's history with GET /kahunacart/products/{id}/history, or with the get_product_history tool over MCP. See Importing for the runs that write most of it.

Revisions, scheduling and prerequisites

KahunaCart saves a snapshot of a product every time you save it, so you can put back an edit that went wrong. The same table carries scheduled publishing and scheduled price changes: a scheduled change is a revision with a time on it, and applying it means restoring it.

A product can also require another product. Set a prerequisite and the storefront will not add this one to a cart until the shopper has a completed order for the product you name.

All three are in Product lifecycle.

Order-line snapshots

When a variant is added to a cart, the line item stores a JSON snapshot of the product and variant rows, alongside the copied title, sku, unit_price_minor and is_digital flag.

  • The tax class and shipping class applied to a completed order come from the snapshot, never from the live product row.
  • Renaming or deleting a product does not rewrite history. Reports show the name a product sold under.
  • Deleting a product deletes its variants. Orders keep their snapshots and their line items.
  • The line title is snapshotted in the language the customer was shopping in, and frozen there. Nothing translates an order after the fact. See Multilanguage.

Catalog export and import

bin/plugin kahunacart export and bin/plugin kahunacart import carry the whole taxonomy by slug rather than by id, so a file written on staging applies to production.

The Export… menu on Products downloads the same catalog file, or a products CSV. To move the catalog together with its pictures, customers and orders, use a store bundle.

YAML
categories:
  - slug: mens
    title: Mens
    parent: clothing
    position: 0
    display: both
    image:
      path: 4/mens-8c21f0a91b3e.jpg
      alt: Two men on a jetty
products:
  - slug: reef-runner-tee
    tags: [organic, sale]
Key Behaviour
display Written only when it is not auto.
parent Names a slug. Categories import in two passes, rows first and parents second, so a child may be listed before its parent.
image Records the path, not the bytes. Import copies no files, so a category whose picture is missing renders a card without one.
position The manual order. Written every time, never omitted. See Ordering in export and import.
featured The featured flag. Written every time, so a file can take the flag off as well as put it on.
pay_what_you_want, pwyw_suggested, pwyw_max Pay what you want. The flag is written every time; the two amounts are decimals in the base currency, written only when the product sets them. Amounts in other currencies travel as pwyw_suggested_prices and pwyw_max_prices.
brand, item_condition, final_sale, agent_checkout, google_category, and gtin and mpn on each variant Product data for search engines and agents. Quote a GTIN so a leading zero survives.
prices, compare_prices On each variant, only on a store that sells in more than one currency and only where the variant has some: a map of currency to decimal string, prices: { EUR: '25.50' }. A file with an amount in a currency the store does not sell in is refused whole. See Currencies.
tags Plain labels. An unknown label becomes a new tag on the receiving store.
type The product's type, written as its code, so grouped and external round-trip. A code the receiving store does not know is refused. Add-on options and their prices are not carried; see Priced add-on options.
attributes Definitions at the top of the file, and a {slug: value} map on each product. A product naming an attribute the file never declared creates it as a text attribute. See CLI.
filterable, filter_panel, show_on_product The three attribute flags, on each definition. Written every time, at their defaults included, because an import reads an absent key as "leave it alone". Adding filter_panel: false to a filterable definition is how a file asks for the links without the facet.

A product declares its axes under options:, and each variant names its combination as a list of value labels in the same order the axes are declared. Every label has to be one its option lists (case and the spaces around it don't matter). A file that gets a label wrong, or lists the values in a different order than the options, is refused before anything is written, and the message names the product, the variant and the label.

Important

Import is a merge and never deletes. A file's tags are added to whatever a product already carries. To remove a tag, remove it in the admin, where you can see how many products it is on.

Seed demo data

bin/plugin kahunacart seed fills the catalog from seed/sample-products.yaml: a nine-category tree and eleven products, physical and digital, single- and multi-variant, with one item down to its last few units and one (the Sandbar Boardshort) built on two option axes.

It also shows a backorder both ways. The Tide Line Beanie's Kelp colour sits at zero with backorders allowed and the note "Knitted to order. Ships in about three weeks.", and the Saltwater Socks keep one pair on the shelf with backorders allowed, so two in the cart read "1 of 2 on backorder" in the store's own wording.

TXT
Clothing
  Mens
    Tops
    Bottoms
    Accessories
  Womens
    Tops
    Accessories
Digital Goods

Products are filed on the leaves, so "Clothing" has nothing assigned directly to it and still shows the whole wardrobe. The two categories titled "Tops" are slugged mens-tops and womens-tops. See Why category URLs stay flat.

The seeder is idempotent: a slug that already exists is skipped and never updated, parent included, so re-seeding cannot undo a reparent made in the admin.

bin/plugin kahunacart seed --purge removes exactly the slugs the file names, after listing them and asking. It deletes seeded categories deepest first and removes their pictures. Seeded tags go too, but only the ones nothing else is wearing.

Demo products carry the tags organic, new and sale, created on first use.

No photographs ship with the seed. Drop a picture named after a product's slug into seed/images/ (reef-runner-tee.jpg), and one named after a category's slug into seed/images/categories/ (mens.jpg), and the seeded rows pick them up. See seed/README.md for the naming rule.

Twig helpers

Five functions give a theme what this page adds. Each answers empty for a store that has never used the feature.

Function Answers
kahunacart_product_type(product) {code, label, registered, nature, ships, holds_stock, buyable_alone, expansion, fields}, fields being the type's own field values on this product
kahunacart_addons(product) The product's add-on options with their prices, each carrying id, name, widget, required, valueless, field (the input name to post it under) and values
kahunacart_kit_items(product) What a grouped product or a kit contains, each row with required_quantity, the child's title, slug, status and resolved variant id, and shopper_picks. A part the shopper picks also carries choices and choice_prices_differ. See Twig functions
kahunacart_set_price(product) What a grouped product or a kit costs in the visitor's currency: {parts_minor, price_minor, saving_minor, sells_as_set}, or null for any other product. See Bundle price
kahunacart_related(product, kind) One curated block. kind is related (the default), cross_sell or up_sell

partials/kahunacart-buy-button.html.twig branches on the first of them: an unregistered type draws an explanation, an external product draws a link out of the site, a grouped product or a kit includes partials/kahunacart-kit.html.twig, and everything else draws the standard form plus one control per add-on.

See Twig reference for the full list, and Theming for overriding the partials.

API reference

Product, variant, option, image, file and attribute endpoints require kahunacart.products.manage. Category and tag endpoints require kahunacart.settings.

TXT
GET    /kahunacart/products                     # ?q= ?sort= ?featured= ?page= ?per_page=
POST   /kahunacart/products
POST   /kahunacart/products/reorder             # {ids: [...]} — those ids take positions 0..n
GET    /kahunacart/products/{id}
PATCH  /kahunacart/products/{id}
DELETE /kahunacart/products/{id}

GET    /kahunacart/product-types                # every registered type, plus any whose add-on is switched off

GET    /kahunacart/products/{id}/relations      # all three curated lists
PUT    /kahunacart/products/{id}/relations      # {related: [...], cross_sell: [...], up_sell: [...]}
POST   /kahunacart/products/relations/rebuild   # rebuild every list and sweep deleted products
GET    /kahunacart/products/{id}/history        # the product's change history, newest first

GET    /kahunacart/products/{id}/files
POST   /kahunacart/products/{id}/files          # multipart, bytes arrive as `file`
PATCH  /kahunacart/products/{id}/files/{fileId}
DELETE /kahunacart/products/{id}/files/{fileId}

GET    /kahunacart/products/{id}/options
POST   /kahunacart/products/{id}/options                             # {name, widget, values: [...], kind, required}
POST   /kahunacart/products/{id}/options/generate                    # {apply: false} by default
POST   /kahunacart/products/{id}/options/assign/{variantId}          # {option_value_ids: [...]}
PATCH  /kahunacart/products/{id}/options/{optionId}                  # name, widget, position
DELETE /kahunacart/products/{id}/options/{optionId}
POST   /kahunacart/products/{id}/options/{optionId}/values           # {label, color}
PATCH  /kahunacart/products/{id}/options/{optionId}/values/{valueId} # label, color, position
DELETE /kahunacart/products/{id}/options/{optionId}/values/{valueId}

DELETE /kahunacart/products/{id}/variants/{variantId}
GET    /kahunacart/variants/search              # ?q= ?limit=, or ?ids=24,25, for the variant pickers

GET    /kahunacart/products/{id}/images
POST   /kahunacart/products/{id}/images         # multipart: `file`, optional `alt`
PATCH  /kahunacart/products/{id}/images/{imageId}   # alt, position
DELETE /kahunacart/products/{id}/images/{imageId}

GET    /kahunacart/categories
POST   /kahunacart/categories
PATCH  /kahunacart/categories/{id}
DELETE /kahunacart/categories/{id}
POST   /kahunacart/categories/{id}/move        # {parent_id, position} — refuses a cycle with a 422
POST   /kahunacart/categories/{id}/reorder     # {direction: "up" | "down"}
POST   /kahunacart/categories/{id}/image       # multipart: `file`, optional `alt`
DELETE /kahunacart/categories/{id}/image

GET    /kahunacart/tags
POST   /kahunacart/tags                        # {label, slug?}
PATCH  /kahunacart/tags/{id}                   # label, slug
DELETE /kahunacart/tags/{id}

GET    /kahunacart/attributes
POST   /kahunacart/attributes                  # {label, type, slug?, options?, unit?, group_label?, position?, filterable?, filter_panel?, show_on_product?}
POST   /kahunacart/attributes/reorder          # {ids: [...]} — the whole list, in that order
GET    /kahunacart/attributes/{id}
PATCH  /kahunacart/attributes/{id}             # the same fields, only what is sent is touched
DELETE /kahunacart/attributes/{id}             # {force: true} to take the values with it

Products

On create, the settable fields are slug, type, status, title, summary, description, tax_class, shipping_class, price_includes_tax, plus the variants. price_includes_tax takes true, false or null. Sending null clears an override back to inherited, and omitting the key leaves the column alone. category_ids, tags and translations are accepted on create and update alike: absent leaves them alone, present replaces the set.

attributes is accepted on create and update too, and is the one exception to that rule: it merges. See Attribute values over the API.

type_code and type are the same field under two spellings, and type_code wins when a body sends both. A PATCH that changes it answers with a type_change object listing the effects. type_fields carries the values of the fields a type declares, kit_items the children of a type that expands, sell_as_set (true or false) whether the product's own price is a bundle price for a full set of them, option_prices the add-on prices, and variant_attributes a variant's own attribute answers. Every product read carries them back, and every variant carries a resolved map naming which step of the cascade produced each value. A grouped product or a kit also carries set_price: parts_minor (one set of the children at their own prices), price_minor (the bundle price when it is sold as a set), saving_minor and sells_as_set, in the store's own currency. It is null for any other product.

position and featured are on every product read and are accepted on create and update. featured goes in as a boolean and comes back as 1 or 0. Absent means "leave it alone". Omit position on create and the product joins the end of the manual order. See Order and feature products.

Parameter Takes Means
sort One of the six sort words Omit it for newest added first, by id rather than publication date, so drafts sort with everything else. A word outside the vocabulary is dropped rather than read as newest.
featured 1 or 0 1 for the featured products alone, 0 for the rest. Omit for both.

Featured products are never pulled to the front of the admin list, whatever catalog.featured_first says, so sort=manual there means the positions.

POST /kahunacart/products/reorder takes {"ids": [12, 4, 9]}, gives those ids positions 0 to n, and renumbers everything else behind them in the order it already had, so a page of a long catalog can be sent without disturbing the rest. Ids naming nothing are ignored. It answers {"moved": n, "ids": [...]} rather than the whole catalog, and it does not touch updated_at.

A variant object takes sku, title, stock_managed, stock_qty, backorders, backorder_note, is_digital, weight_grams, position, is_default, bundle_of (see Bundles), and the money fields price and compare_price as decimal strings in the base currency. Anything that is not a decimal amount is refused with a 422 before the write starts.

Create takes either a single variant object or a variants list. Update takes variants as a map keyed by variant id and writes only the keys each entry names, so a PATCH that mentions price will not clear a compare_price.

is_default applies to the whole set. On create the flagged variant becomes the default, and the first one does when nothing is flagged. On update it promotes that variant and demotes its siblings in the same transaction. The flag cannot be cleared; move the default by naming its replacement.

DELETE /kahunacart/products/{id}/variants/{variantId} refuses to take the last variant with a 422, and promotes a sibling before removing one that holds is_default.

GET /kahunacart/variants/search finds variants matching ?q= on product title, variant title or SKU, across every product whatever its status. It is what the Bundle of picker asks, and the picker in a condition on a sale, a price list or a payment method. Each row is id, product_id, product_title, title, sku and a ready-made label. ?limit= defaults to 20, at most 50. ?ids=24,25 names those variants instead of searching, in that order, for an editor showing a saved condition. An id with no variant is left out.

Note

Every product write queues a catalog push to any provider configured as local-master. Sync carries the default variant only. See Sync.

Images

The image endpoints all speak one object:

JSON
{"id": 3, "path": "12/hero-5af24baa2cec.png", "url": "/user/images/kahunacart/products/12/hero-5af24baa2cec.png", "alt": "Blue boots", "position": 0}

url is the site-relative URL a browser loads. GET answers {"images": [...]} in position order, POST answers 201 with {"image": {...}}, PATCH answers 200 with the same, and DELETE answers {"deleted": true}. An image id belonging to a different product is a 404.

GET /kahunacart/products/{id} returns the whole gallery. The listing returns only the primary image, as a one-element images array plus an image_count of the real total.

The image bytes are immutable. PATCH changes alt and position.

Options

Options are carried in one form everywhere:

JSON
{"id": 4, "name": "Colour", "widget": "swatches", "values": [{"id": 12, "label": "Navy", "color": "#1f3a5f"}]}

color is always present and is null when the value has none. GET /kahunacart/products/{id} and the listing both carry an options array, and every variant carries option_value_ids and option_label.

generate answers with the axes, the variants, and a plan:

JSON
{"plan": {"total": 8, "existing": 3, "missing": 5, "created": 0,
          "missing_labels": ["30 / Slate", "32 / Kelp"],
          "orphans": [{"id": 91, "sku": "OLD-1", "label": "M", "is_default": false, "stock_qty": 4}],
          "unassigned": []}}

Orphans and unassigned variants are named rather than counted.

Categories and tags

The category list is flat, with a parent_id on every row. Each row carries product_count, subtree_count, image (the same {path, url, alt} object a product image uses, or null) and its unresolved translations. The storefront gets the nested form from kahunacart_categories().

move answers the updated category, or a 422 when the new parent is the category itself or one of its own descendants. reorder answers {"moved": false} at the end of a row. The image routes replace whatever picture the category had and delete the file they replace.

Tags are assigned through the product endpoints, as tags on a product body. Every tag carries product_count, and DELETE answers {"deleted": true, "products_affected": 80}.

Attribute definitions

The definition endpoints edit the store-wide vocabulary. They do not edit values; a product answers an attribute through the product endpoints, the same way tags are assigned.

Every definition carries its decoded options list, its product_count, and its unresolved translations. The list response also carries types, filterable_types and option_types, so a create form does not have to keep its own copy of the vocabulary.

JSON
{"id": 1, "slug": "material", "label": "Material", "type": "select",
 "options": [{"slug": "cotton", "label": "Cotton"}, {"slug": "linen", "label": "Linen"}],
 "unit": null, "group_label": "Materials", "position": 0,
 "filterable": 1, "filter_panel": 1, "show_on_product": 1, "product_count": 12,
 "translations": {}}

POST requires label and type. The slug is derived from the label when none is sent, and is de-duplicated either way: material, then material-2. On PATCH, only what the body names is touched, and a blank slug reads as "leave it alone" rather than as a rename.

filter_panel defaults to true and only matters while filterable is on. Send it as false for an attribute with too many values to list: it stays linked from every product page and honoured in ?attr[...] URLs without adding a block of tick boxes to the panel. Retyping to a type that refuses filtering clears filterable and leaves filter_panel alone, so read filterable back off the response.

reorder takes the whole list of ids in the order they should be stored and answers {"moved": n, "attributes": [...]}. Ids naming nothing are ignored, and anything not listed keeps its place behind what was.

DELETE answers {"deleted": true, "products_affected": 12}. While products still answer the attribute it is refused with a 409 until the body says {"force": true}.

Refusal Status Message
Unknown type 422 Unknown attribute type '{type}'; it must be one of {list}
Retyping an answered attribute 422 Attribute '{slug}' is answered by {n} product(s), so its type cannot change; clear the values first or make a new attribute
Filterable on a type that cannot be 422 Attribute '{slug}' is a '{type}' and cannot be filtered on; filterable types are {list}
A choice type with no options 422 Attribute '{slug}': a '{type}' attribute needs at least one option
Two options with the same slug 422 Attribute '{slug}': duplicate option slug '{option}'
Deleting an answered attribute 409 Attribute '{slug}' is answered by {n} product(s); delete it with force to remove those values too

Attribute values over the API

Every product read carries an attributes object: a {slug: value} map in definition order, holding the canonical value of each attribute the product answers.

JSON
{"attributes": {"material": "cotton", "fabric-weight": "185", "machine-washable": true,
                "certifications": ["gots-organic", "oeko-tex"]}}

Sending attributes on a product body merges rather than replacing, which is the one place it differs from tags and category_ids.

The body What happens
A key with a value That attribute is written.
A key that is absent That attribute is left exactly as it was.
A key set to null, "" or [] That attribute's answer is removed.

Attributes are a set of independent fields rather than one list, so a client editing one attribute does not have to resend the others.

Everything is validated before anything is written, so a body with a good material and a bad fabric-weight leaves the product as it was. On create the check runs before the product row exists, so a bad body leaves no half-made product.

Refusal Status Message
attributes sent as a JSON list 422 'attributes' must be a map of attribute slug to value
A slug the store does not define 422 Unknown attribute '{slug}'; this store defines {list}
A value the type will not take 422 Attribute '{slug}' expects a number — and the equivalent for each type
A choice value that is not an option 422 Attribute '{slug}' has no option '{value}'; it accepts {slugs}