Skip to content

Powered by Grav CMS + Helios Theme

Products

The catalog's core resource: products, their variants and the fields both carry.

The catalog's core resource. Field lists, variant rules and the is_default contract are on Products and catalog.

List products

GET /api/v1/kahunacart/products Permission kahunacart.products.manage

List catalog products, newest added first, with their variants, option axes, category ids, tags, attribute values, primary image and per-language translation overrides. Rows stay in the base language; use get_product for one product with its whole gallery and its recent orders.

Parameters

Name Type Description
q optional string Substring match on the title, the slug or a variant SKU
sort optional string One of the storefront's own orders (newest, oldest, manual, title, price_asc, price_desc), or cf:<name> / cf:<name>:desc to order by a custom field declared sortable on the product. Omit for the order this list has always had, newest added first, which is the id and not the publication date so drafts sort with everything else. manual is the merchant's own order, the position column. The price orders read the default variant. Featured products are not pulled to the front here even when the store puts them first on the storefront, so manual means the positions themselves. The admin's column headings sort by title, status, price (the cheapest variant), cogs, stock (what the stock-managed variants hold) or updated, together with dir, before the catalogue is paged, and they order a list narrowed by cf[...] filters too; title with a dir sorts both ways by the same case-insensitive title, while a bare title is the storefront word, and a product with nothing to sort by goes last whichever way the column runs.
dir optional string The direction of a column sort; ignored with the storefront's own words
One of: asc, desc
Default: asc
featured optional integer 1 for the featured products alone, 0 for everything else; omit for both
One of: 0, 1
missing_price optional string A currency code the store sells in besides its base currency, for example EUR. Lists only the products with at least one variant that has no price in that currency, so what cannot be bought there yet is easy to find. Refused with a 422 for any other code, which includes every code on a single-currency store.
cf optional object Custom fields to narrow by, name to value, every one required: cf[warranty_months]=24. Only a field declared filterable on this entity; any other name is refused with a 422
page optional integer At least 1.
Default: 1
per_page optional integer At least 1.
Default: 25

Get a product

GET /api/v1/kahunacart/products/{id} Permission kahunacart.products.manage

One product with everything on it: variants and their option combinations, option axes, category ids, tags, the attribute value map, the full image gallery, translation overrides, plus its ten most recent completed orders and the total count of them. Use this before updating a product so you know which variant ids exist. On a store that sells in more than one currency each variant, each add-on price and the product itself also carry currency_amounts, the prices set in the other currencies as decimal strings; a currency with no entry is one the variant is not sold in. A grouped product or a kit also carries set_price: what one set of its children costs at their own prices (parts_minor), its bundle price when sell_as_set is on (price_minor) and the saving, in the store's own currency.

Parameters

Name Type Description
id required integer Product id

Response

On a grouped product or a kit, kit_items lists the parts. Each has an id (the number a buy form, a counter cart line or an order line names the part by in parts), child_product_id, child_variant_id, required_quantity, shopper_picks as true or false, and resolved_variant_id, which is the default variant for a part the shopper picks. lock_parts is on the product itself.

Create a product

POST /api/v1/kahunacart/products Permission kahunacart.products.manage

Create a product and at least one variant, and return the created product in the same form get_product does. A title is required; the slug is derived from it when omitted and a slug already in use answers 409. Send one variant under variant or several under variants; exactly one becomes the default, the one flagged is_default or else the first. Variant prices are decimal strings in the store currency, not minor units. Option axes and images are added against an existing product, so they are never part of a create. On a store that sells in more than one currency a variant, and the product (for pay what you want), may carry currency_amounts with the same prices in the other currencies.

Parameters

Name Type Description
title required string Product title
slug optional string URL slug; derived from the title when omitted, and must be unique
type_code optional string What kind of thing this is. Any code list_product_types answers with — physical, digital, grouped, external, or one an add-on registers. A code nothing declares is refused with a sentence naming it. `type` is accepted as an older spelling of the same field and means exactly the same thing.
type optional string The older spelling of type_code, still accepted. Physical products are shippable and count toward shipping zones.
type_fields optional object Values for the extra fields this type declares, as list_product_types reports them — an external product's external_url and external_label, for instance. Merged rather than replaced, and kept when the type changes, so turning a product external, back, and external again keeps the link.
kit_items optional array of object The child products a grouped product or a kit contains, replacing whatever it named before. Each entry is {child_product_id, child_variant_id?, required_quantity?}. Adding the parent to a cart adds one line per child, each at required_quantity times the parent's quantity, and the children are what hold stock. Omit to leave the children alone; send an empty list to empty it. Keys: `child_product_id`, `child_variant_id`, `required_quantity`, `shopper_picks`
sell_as_set optional boolean On a grouped product or a kit, sell the children as a set at this product's own price (its default variant's price, in each currency the way any variant is priced). Whenever a cart holds a full set of the children, however they were added, the set costs that price; the parts stay separate lines and each takes its own stock. Off, the children cost their own prices and the product's price is not charged anywhere.
lock_parts optional boolean On a grouped product or a kit, lock its parts in the cart: they show their quantity with no box and no remove button and change only through the set's own row (how many sets, or remove the whole set). Off, a shopper can change or remove one part on its own.
option_prices optional object What the product's add-on options cost, keyed by option id and then by option value id, with 0 for the option itself (which is what a text or checkbox add-on charges). Each price is {mode: fixed|percent, amount_minor|percent}. A percentage is a decimal string of the line's own unit price. An option the body does not name keeps its prices.
variant_attributes optional object Per-variant attribute answers, keyed by variant id, each the same {slug: value} map the product-level attributes key takes. Replaced per variant, so an empty map clears that variant's own answers and it goes back to inheriting the product's.
min_qty optional integer or null The smallest quantity of this product a customer may buy. Null uses the store default (cart.quantity_min_default).
max_qty optional integer or null The largest quantity of this product one order may hold, summed across every line of it. Null uses the store default (cart.quantity_max_default). Keep it low to stop one basket holding the whole shelf. Enforced at add-to-cart, at the quantity box and again at checkout, so a cart built before the limit was set is still refused.
qty_step optional integer or null The quantity has to be a multiple of this: beer by the six, tiles by the box of ten. Null or 1 is no step. The minimum rounds up to the next multiple and the maximum down, so both ends of the range are quantities the store will actually sell.
combine_variants optional boolean Whether the limits are counted over every line of the product together (true, the default and the behaviour of every store before this existed) or over each line on its own (false, the variants are separate shelves).
sold_individually optional boolean One per order, whatever min_qty, max_qty and qty_step say. The refusal names the product rather than quoting a number.
exclude_order_rules optional boolean This product's lines count for nothing in the store-wide order rules (cart.order_min_qty, cart.order_max_qty, cart.order_min_total, cart.order_max_total) — neither their units nor their money. Set it on gift cards, donations, deposits and shipping protection, so nobody reaches a minimum order by buying one.
exclude_category_rules optional boolean This product ignores the quantity rules its categories carry (min_qty, max_qty, qty_step on kahunacart_categories) and falls straight from its own values to the store defaults. Set it on the single-bottle sampler filed under a category sold in sixes.
pay_what_you_want optional boolean Shoppers name their own price. Each variant's price is the least they may pay, so a price of 0 is free, or pay what you like. Sales, coupons and price lists never apply to a price the shopper chose.
pwyw_suggested_minor optional integer or null What the price box starts at, in minor units. Null starts it at the minimum.
pwyw_max_minor optional integer or null The most a shopper may pay, in minor units, to catch a typo. Null is no maximum.
currency_amounts optional object The product's own amounts in the store's other currencies, only on a store that sells in more than one: currency code to {pwyw_suggested, pwyw_max, deposit} as decimal strings in that currency, for example {"EUR": {"pwyw_suggested": "8.00", "pwyw_max": "50.00"}}. An empty string or null removes an amount. Variant prices go under `variants`, not here.
buy_now optional boolean or null Whether the buy button adds the product and goes straight to checkout instead of back to the cart. Null follows the store's cart.buy_now setting. Anything already in the cart stays there.
button_label optional string or null The buy button's own words, like "Rent now" or "Reserve". Null or blank keeps "Add to cart" or "Buy now". Translatable.
badge_text optional string or null Short words drawn as a badge over the product's pictures, like "BOGO" or "Last one", at most 40 characters. Null or blank for no badge. Translatable.
badge_tone optional string or null The badge's tone, one of accent, red, green, amber, blue or dark, which each theme paints in its own shade. Null is accent, the theme's highlight.
release_at optional string or null When the product comes out, which makes it a pre-order until then: the buy button says Pre-order, the page says when it is out, and its downloads open on the day. A date and time such as "2026-11-13 00:00" (read in the store's timezone), ISO 8601 with an offset, or epoch seconds. Null or empty is an ordinary product, and clearing it releases the product at once.
brand optional string or null The brand the product is sold under, as structured data and the product feeds publish it. Not translated. Null or blank falls back to the store's seller.default_brand.
item_condition optional string or null The product's condition for structured data and feeds. Null reads as new.
One of: new, refurbished, used
final_sale optional boolean No returns on this product. Its structured data says returns are not permitted, and an agent may never check it out.
agent_checkout optional boolean or null Whether an AI agent may complete checkout for this product. Null follows the automatic rules, true allows it, false never. Some products are never eligible whatever this says, such as final sale, backorder or ones with required add-ons.
google_category optional string or null The Google product taxonomy id or path, for example 1604 or "Apparel & Accessories > Clothing". Null uses feeds.google_category.
manufacturer_id optional integer or null The manufacturer shown in the product page's Product safety section, from list_manufacturers. Null uses the store's catalog.default_manufacturer. An id that names no manufacturer is refused with 422
safety_info optional string or null Warnings and safety instructions shoppers need before buying, in Markdown, shown in the Product safety section. Translatable like the description. Null or empty removes it
status optional string
One of: draft, published, archived
visibility optional string Unlisted keeps the product out of listings, facets, search and the sitemap while its own URL and its buy button keep working
One of: listed, unlisted
summary optional string
description optional string Markdown, rendered on the product page
tax_class optional string Tax class the tax zones' rates are keyed on, for example standard
shipping_class optional string Shipping class a method's per-class cost is keyed on
position optional integer Place in the manual order, read only while the store's catalog.sort is manual. Omit and the product joins the end of that order
featured optional boolean Featured products lead every storefront listing whatever the sort, while catalog.featured_first is on. Comes back as 1 or 0 like every other flag
variant optional object A single variant, when the product has only one. Keys: `sku`, `title`, `price`, `compare_price`, `currency_amounts`, `stock_managed`, `stock_qty`, `backorders`, `backorder_note`, `is_digital`, `weight_grams`, `length_mm`, `width_mm`, `height_mm`, `gtin`, `mpn`, `position`, `is_default`, `min_qty`, `max_qty`, `qty_step`
variants optional array of object Several variants; exactly one becomes the default. Keys: `sku`, `title`, `price`, `compare_price`, `currency_amounts`, `stock_managed`, `stock_qty`, `backorders`, `backorder_note`, `is_digital`, `weight_grams`, `length_mm`, `width_mm`, `height_mm`, `gtin`, `mpn`, `position`, `is_default`, `min_qty`, `max_qty`, `qty_step`
category_ids optional array of integer Category ids this product is filed under; replaces the whole set
customer_group_ids optional array of integer Customer group ids allowed to see and buy this product; replaces the whole set. An empty list, which is the default, means anybody may. One or more ids makes this a member-only product: it is absent from every storefront listing, search result and sitemap for a shopper outside those groups, its page shows a sign-in notice instead of a buy button, and add-to-cart and checkout refuse it
tags optional array of string Tag labels, not ids. A label this store has never seen is created, which is what makes the admin's type-to-add tag input work
attributes optional object Attribute slug to value, using the canonical form for the attribute's type: an option slug for select, a list of slugs for multiselect, a decimal string for number, true or false for boolean, YYYY-MM-DD for date, "#rrggbb" for color. A slug this store does not define, or a value the type will not take, is refused with 422 before anything is written
translations optional object Language code to a map of field overrides, for example {"de": {"title": "..."}}

Update a product

PATCH /api/v1/kahunacart/products/{id} Permission kahunacart.products.manage

Change one or more fields of a product and return the updated product. Only the keys sent are touched. category_ids, customer_group_ids, tags and translations replace their whole set when present and are left alone when absent. attributes is different: it is a merge keyed by attribute slug, so a key you do not send keeps its value and a key sent as null removes that one value. variants is a map keyed by variant id, and a variant can be promoted to default but never demoted, so move the flag by naming its replacement. sell_as_set turns a grouped product's own price into a bundle price for a full set of its children. On a store that sells in more than one currency, a variant in variants and each add-on price in option_prices may carry currency_amounts (see update_variant), and the product itself may carry currency_amounts for pay what you want and the deposit; the whole request is checked first, so one bad amount writes nothing.

Parameters

Name Type Description
id required integer Product id
title optional string
slug optional string Answers 409 when another product already holds it
type_code optional string Change what kind of thing this is. Any code list_product_types answers with. The response carries a type_change plan saying what the store now reads differently — files that stopped granting downloads, stock that stopped being counted, fields the new type wants that this product has no value for yet. Nothing is deleted, so changing it back restores the previous behaviour exactly.
type optional string The older spelling of type_code, still accepted
type_fields optional object Values for the extra fields this type declares; merged, not replaced
kit_items optional array of object The child products this grouped product or kit contains; replaces the list. Keys: `child_product_id`, `child_variant_id`, `required_quantity`, `shopper_picks`
sell_as_set optional boolean On a grouped product or a kit, a full set of the children in a cart costs this product's own price (the bundle price)
lock_parts optional boolean On a grouped product or a kit, its parts change in the cart only through the set's own row
option_prices optional object Add-on option prices, keyed by option id then value id (0 = the option itself). A fixed price may carry currency_amounts as {"EUR": {"amount": "4.50"}}; an empty string removes it, which means the choice cannot be picked in that currency
variant_attributes optional object Per-variant attribute maps, keyed by variant id; replaced per variant
min_qty optional integer or null The smallest quantity of this product a customer may buy. Null uses the store default (cart.quantity_min_default).
max_qty optional integer or null The largest quantity of this product one order may hold, summed across every line of it. Null uses the store default (cart.quantity_max_default). Keep it low to stop one basket holding the whole shelf. Enforced at add-to-cart, at the quantity box and again at checkout, so a cart built before the limit was set is still refused.
qty_step optional integer or null The quantity has to be a multiple of this: beer by the six, tiles by the box of ten. Null or 1 is no step. The minimum rounds up to the next multiple and the maximum down, so both ends of the range are quantities the store will actually sell.
combine_variants optional boolean Whether the limits are counted over every line of the product together (true, the default and the behaviour of every store before this existed) or over each line on its own (false, the variants are separate shelves).
sold_individually optional boolean One per order, whatever min_qty, max_qty and qty_step say. The refusal names the product rather than quoting a number.
exclude_order_rules optional boolean This product's lines count for nothing in the store-wide order rules (cart.order_min_qty, cart.order_max_qty, cart.order_min_total, cart.order_max_total) — neither their units nor their money. Set it on gift cards, donations, deposits and shipping protection, so nobody reaches a minimum order by buying one.
exclude_category_rules optional boolean This product ignores the quantity rules its categories carry (min_qty, max_qty, qty_step on kahunacart_categories) and falls straight from its own values to the store defaults. Set it on the single-bottle sampler filed under a category sold in sixes.
pay_what_you_want optional boolean Shoppers name their own price. Each variant's price is the least they may pay, so a price of 0 is free, or pay what you like. Sales, coupons and price lists never apply to a price the shopper chose.
pwyw_suggested_minor optional integer or null What the price box starts at, in minor units. Null starts it at the minimum.
pwyw_max_minor optional integer or null The most a shopper may pay, in minor units, to catch a typo. Null is no maximum.
currency_amounts optional object The product's own amounts in the store's other currencies, only on a store that sells in more than one: currency code to {pwyw_suggested, pwyw_max, deposit} as decimal strings in that currency, for example {"EUR": {"pwyw_suggested": "8.00", "pwyw_max": "50.00"}}. An empty string or null removes an amount. Variant prices go under `variants`, not here.
buy_now optional boolean or null Whether the buy button adds the product and goes straight to checkout instead of back to the cart. Null follows the store's cart.buy_now setting. Anything already in the cart stays there.
button_label optional string or null The buy button's own words, like "Rent now" or "Reserve". Null or blank keeps "Add to cart" or "Buy now". Translatable.
badge_text optional string or null Short words drawn as a badge over the product's pictures, like "BOGO" or "Last one", at most 40 characters. Null or blank for no badge. Translatable.
badge_tone optional string or null The badge's tone, one of accent, red, green, amber, blue or dark, which each theme paints in its own shade. Null is accent, the theme's highlight.
release_at optional string or null When the product comes out, which makes it a pre-order until then: the buy button says Pre-order, the page says when it is out, and its downloads open on the day. A date and time such as "2026-11-13 00:00" (read in the store's timezone), ISO 8601 with an offset, or epoch seconds. Null or empty is an ordinary product, and clearing it releases the product at once.
brand optional string or null The brand the product is sold under, as structured data and the product feeds publish it. Not translated. Null or blank falls back to the store's seller.default_brand.
item_condition optional string or null The product's condition for structured data and feeds. Null reads as new.
One of: new, refurbished, used
final_sale optional boolean No returns on this product. Its structured data says returns are not permitted, and an agent may never check it out.
agent_checkout optional boolean or null Whether an AI agent may complete checkout for this product. Null follows the automatic rules, true allows it, false never. Some products are never eligible whatever this says, such as final sale, backorder or ones with required add-ons.
google_category optional string or null The Google product taxonomy id or path, for example 1604 or "Apparel & Accessories > Clothing". Null uses feeds.google_category.
manufacturer_id optional integer or null The manufacturer shown in the product page's Product safety section, from list_manufacturers. Null uses the store's catalog.default_manufacturer. An id that names no manufacturer is refused with 422
safety_info optional string or null Warnings and safety instructions shoppers need before buying, in Markdown, shown in the Product safety section. Translatable like the description. Null or empty removes it
status optional string
One of: draft, published, archived
visibility optional string
One of: listed, unlisted
summary optional string
description optional string
tax_class optional string
shipping_class optional string
position optional integer Place in the manual order, read only while the store's catalog.sort is manual. Omit and the product joins the end of that order
featured optional boolean Featured products lead every storefront listing whatever the sort, while catalog.featured_first is on. Comes back as 1 or 0 like every other flag
variants optional object Variant id to the fields to change on it: sku, title, price and compare_price as decimal strings, stock_managed, stock_qty, backorders, backorder_note, is_digital (1 for a digital variant that needs no shipping), weight_grams, length_mm, width_mm and height_mm (the parcel in whole millimetres, null for a size nobody recorded), gtin (8, 12, 13 or 14 digits with a valid check digit, refused with 422 otherwise) and mpn (at most 70 characters), position, image_id (a picture from this product's gallery that overrides whatever its option values say, null to clear it, and an image of another product is refused with 422), is_default to promote that variant to the product's default, and currency_amounts, the variant's prices in the store's other currencies as {"EUR": {"price": "18.00", "compare_price": ""}} (an empty string removes an amount)
category_ids optional array of integer Replaces the whole set; omit to leave the filing alone
customer_group_ids optional array of integer Customer group ids allowed to see and buy this product; replaces the whole set. An empty list puts the product back on general sale; omit it to leave the restriction alone
tags optional array of string Tag labels; an empty list removes every tag
attributes optional object A merge keyed by attribute slug. A null value removes that attribute from the product; a slug left out keeps whatever it had
translations optional object Replaces the stored overrides wholesale when present

Reorder products

POST /api/v1/kahunacart/products/reorder Permission kahunacart.products.manage

Put the catalog in the order the ids are given, and answer with how many rows moved. The ids named take positions 0 to n in that order and everything else keeps its relative order behind them, so a page of a long catalog can be sent without disturbing the rest. Ids naming nothing are ignored. This is the order the storefront lists products in while the store's catalog.sort is manual, and nothing else reads it.

Parameters

Name Type Description
ids required array of integer Product ids in the order they should appear

Delete a product

DELETE /api/v1/kahunacart/products/{id} Permission kahunacart.products.manage

Delete a product, its variants, its option axes, its attribute values, its releases and file rows, and its images including the bytes on disk. Orders that bought it are untouched, because an order line keeps its own snapshot of what was sold.

Parameters

Name Type Description
id required integer Product id

Update a variant

PATCH /api/v1/kahunacart/products/{id}/variants/{variantId} Permission kahunacart.products.manage

Change one variant of a product and return it with the rest of the product's variants beside it. This is the route for a price change: send price_minor in minor units, the way every read of this API hands it back, or price as a decimal string. sku, position, stock_qty, stock_managed, backorders, backorder_note, is_digital, title, weight_grams, length_mm, width_mm, height_mm, compare_price, image_id, gtin, mpn, min_qty, max_qty and qty_step are all accepted, and only the keys sent are touched. bundle_of makes this variant a bundle of the listed variant ids, one of each, for the cart's bundle suggestion; an empty list stops it being a bundle, and fewer than two parts, the variant itself, a duplicate or an unknown id is refused with a 422. The three millimetre figures are the parcel's size; null is how you say a size has not been recorded, and a zero, a negative or a decimal is refused with a 422 naming the field. The three quantity keys are this variant's own limits; null on any of them inherits the product's. A key nobody declared is refused rather than dropped. is_default promotes this variant and never demotes another, so move the flag by naming its replacement. On a store that sells in more than one currency, currency_amounts sets the variant's prices there: a currency code to {price, compare_price} as decimal strings in that currency's own decimal places (yen take whole numbers). An empty string or null removes an amount, which means the variant cannot be bought in that currency, and a key left out is left alone. A currency the store does not sell in, the base currency, a negative amount or too many decimals is refused with a 422 and nothing is written, the base price included.

Parameters

Name Type Description
id required integer Product id
variantId required integer Variant id
price_minor optional integer Price in minor units, e.g. 700 for $7.00
price optional string Price as a decimal in the store currency, e.g. "7.00"
currency_amounts optional object Prices in the store's other currencies, for example {"EUR": {"price": "6.50", "compare_price": "8.00"}, "JPY": {"price": "1000"}}. Only on a store that sells in more than one currency; empty string or null removes an amount.
sku optional string
position optional integer
is_default optional boolean Make this the default variant
min_qty optional integer or null Smallest quantity of this variant; null inherits the product's
max_qty optional integer or null Largest quantity of this variant; null inherits the product's
qty_step optional integer or null Quantity must be a multiple of this; null inherits the product's
bundle_of optional array of integer Variant ids this variant replaces as a bundle, one of each; empty to clear
gtin optional string or null Barcode: 8, 12, 13 or 14 digits with a valid check digit; spaces and dashes are removed. A bad one is refused with 422. Null clears it
mpn optional string or null Manufacturer part number. Null clears it
backorder_note optional string or null What shoppers see while this variant is on backorder. Longer text is cut to 190 characters; empty or null uses the store's wording
is_digital optional integer 1 makes this variant digital (no shipping), like a record's Download format beside its pressings; 0 makes it ship with its product
One of: 0, 1

Search variants

GET /api/v1/kahunacart/variants/search Permission kahunacart.products.manage

Find variants by product title, variant title or SKU, across every product whatever its status, for choosing the parts of a bundle. Each row carries a label ready to show, such as "Rentals — Rentals only".

Parameters

Name Type Description
q optional string Text to match against product title, variant title and SKU
limit optional integer Most rows to return, default 20
ids optional string Comma-separated variant ids to name instead of searching, such as "24,25". Answers those variants in that order, leaving out an id with no variant

Delete a variant

DELETE /api/v1/kahunacart/products/{id}/variants/{variantId} Permission kahunacart.products.manage

Remove one variant of a product and return the variants that remain. Refused with 422 when it is the last variant, because a product with no variant has no price and nothing to sell; delete the product instead. Deleting the default variant promotes a sibling first. Existing orders keep their own snapshot of what was bought.

Parameters

Name Type Description
id required integer Product id
variantId required integer Variant id

Delete several variants

POST /api/v1/kahunacart/products/{id}/variants/delete Permission kahunacart.products.manage

Remove several variants of one product in one call, under the same rules as delete_variant, and return the variants that remain. Ids that are not this product's are skipped. A list that names every variant keeps one (the default, where it is in the list), because a product needs at least one. The answer lists the ids deleted and, for each one skipped, the reason: not_found or last_variant. At most 1000 ids per call.

Parameters

Name Type Description
id required integer Product id
ids required array of integer Variant ids to delete

Settable product fields are slug, type, status, visibility, title, summary, description, tax_class and shipping_class. title is required on create, and a duplicate slug is a 409. Variant fields are sku, title, stock_managed, stock_qty, backorders, weight_grams, position, is_default, and the decimal money fields price and compare_price.

The listing carries one image per product plus an image_count; the single read carries the whole gallery.